Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
185 changes: 185 additions & 0 deletions electron/ai-edition/agent-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,9 @@ describe("the mutating-tool table", () => {
"addCameraFullscreen",
"addSpeed",
"addTrim",
"addTrims",
"addZoom",
"addZooms",
"moveClip",
"removeClip",
"removeModifier",
Expand Down Expand Up @@ -227,6 +229,189 @@ describe("executeAgentTool", () => {
expect(result.summary).toMatch(/added trim 0:20\.0 – 0:22\.0/);
});

it("addTrims lands exactly what the same calls one at a time would", () => {
// The property the batch tools exist to have: they save round trips and
// change nothing else. If this ever diverges, the batch has grown a second
// implementation of the rules and the two will drift.
const ranges = [
{ startSec: 1, endSec: 2, reason: "silence" },
{ startSec: 40, endSec: 41, reason: "silence" },
{ startSec: 5, endSec: 4, reason: "silence" }, // reversed on purpose
];

let oneAtATime = fixtureDocument();
for (const range of ranges) {
const step = executeAgentTool(oneAtATime, "addTrim", JSON.stringify(range));
expect(step.ok).toBe(true);
oneAtATime = step.document as AxcutDocument;
}

const batch = executeAgentTool(fixtureDocument(), "addTrims", JSON.stringify({ ranges }));
expect(batch.ok).toBe(true);

const shape = (doc: AxcutDocument) =>
doc.timeline.trimRanges.map((t) => ({
startSec: t.startSec,
endSec: t.endSec,
reason: t.reason,
origin: t.origin,
clipId: t.clipId,
}));
expect(shape(batch.document as AxcutDocument)).toEqual(shape(oneAtATime));
});

it("addTrims applies the good ranges and refuses the bad one by itself", () => {
// `replaceTimeline`, the repo's other array-taking tool, refuses in one
// block. That is right for rebuilding a timeline and ruinous here: one bad
// bound must not cost the other nine, and the model must be able to see
// WHICH one without re-reading the document.
const result = executeAgentTool(
fixtureDocument(),
"addTrims",
JSON.stringify({
ranges: [
{ startSec: 1, endSec: 2 },
{ startSec: 25, endSec: 35 }, // spans both clips of asset_1 — ambiguous
{ startSec: 40, endSec: 41 },
],
}),
);

expect(result.ok).toBe(true);
const payload = JSON.parse(result.resultJson);
expect(payload.requested).toBe(3);
expect(payload.appliedCount).toBe(2);
expect(payload.refusedCount).toBe(1);
expect(payload.refused).toHaveLength(1);
expect(payload.refused[0].index).toBe(1);
// The refusal keeps the unitary wording, which names the clips and the fix.
expect(payload.refused[0].error).toMatch(/clipId/);
expect(payload.applied.map((a: { index: number }) => a.index)).toEqual([0, 2]);
// The fixture starts with one trim; two more landed.
expect(result.document?.timeline.trimRanges).toHaveLength(3);
expect(result.summary).toMatch(/added 2 trims, 1 refused/);
});

it("addTrims refuses a MALFORMED range by itself, not the whole call", () => {
// The batch schema advertises the element shape without enforcing it, so a
// bad entry reaches the unitary executor and is refused at its index. If it
// were enforced at the container, one typo would cost every other cut —
// which is precisely what `applyBatch` says it exists to prevent.
const result = executeAgentTool(
fixtureDocument(),
"addTrims",
JSON.stringify({
ranges: [{ startSec: 1, endSec: 2 }, { startSec: "oops" }, { startSec: 40, endSec: 41 }],
}),
);

expect(result.ok).toBe(true);
const payload = JSON.parse(result.resultJson);
expect(payload.appliedCount).toBe(2);
expect(payload.refused).toEqual([{ index: 1, error: expect.stringMatching(/endSec/) }]);
expect(result.document?.timeline.trimRanges).toHaveLength(3);
});

it("addZooms refuses a MALFORMED region by itself, not the whole call", () => {
const result = executeAgentTool(
fixtureDocument(),
"addZooms",
JSON.stringify({
regions: [
{ startSec: 1, endSec: 3, depth: 9 }, // depth is an ordinal 1–6
{ startSec: 10, endSec: 12 },
],
}),
);

expect(result.ok).toBe(true);
const payload = JSON.parse(result.resultJson);
expect(payload.appliedCount).toBe(1);
expect(payload.refused[0].index).toBe(0);
expect(result.document?.zoomRanges).toHaveLength(1);
});

it("addTrims still refuses a batch that is not a non-empty list", () => {
for (const args of ['{"ranges":[]}', '{"ranges":"1-2"}', "{}"]) {
const result = executeAgentTool(fixtureDocument(), "addTrims", args);
expect(result.ok).toBe(false);
expect(result.document).toBeUndefined();
}
});

it("addTrims reports a whole-batch refusal as a failure, not an empty success", () => {
const result = executeAgentTool(
fixtureDocument(),
"addTrims",
JSON.stringify({
ranges: [
{ startSec: 1, endSec: 2, assetId: "asset_missing" },
{ startSec: 3, endSec: 4, assetId: "asset_missing" },
],
}),
);
expect(result.ok).toBe(false);
expect(result.document).toBeUndefined();
const error = JSON.parse(result.resultJson).error;
expect(error).toMatch(/\[0\]/);
expect(error).toMatch(/\[1\]/);
expect(error).toMatch(/Nothing was modified/);
});

it("addZooms lands the reachable regions and names the one covering no clip", () => {
const result = executeAgentTool(
fixtureDocument(),
"addZooms",
JSON.stringify({
regions: [
{ startSec: 1, endSec: 3, depth: 2 },
{ startSec: 400, endSec: 402 }, // past the end of the timeline
{ startSec: 10, endSec: 12, depth: 4 },
],
}),
);

expect(result.ok).toBe(true);
const payload = JSON.parse(result.resultJson);
expect(payload.appliedCount).toBe(2);
expect(payload.refused[0].index).toBe(1);
// Each applied entry still carries what the unitary tool reports, so the
// model can quote the rendered scale instead of the depth ordinal.
expect(payload.applied[0].renderedScale).toBe(ZOOM_DEPTH_SCALES[2]);
expect(payload.applied[1].renderedScale).toBe(ZOOM_DEPTH_SCALES[4]);
expect(result.document?.zoomRanges).toHaveLength(2);
});

it("addZooms leaves overlapping regions overlapping, exactly as one-at-a-time does", () => {
// A deliberate non-decision, pinned so it stays deliberate.
//
// `timelineMap.ts` forbids two zooms of different identities from
// overlapping, but only the `set*` path clamps (via `replacePillSpan`) —
// no `add*` does, in the agent OR in the UI. So two overlapping addZoom
// calls already produce an overlapping document today. Deconflicting
// inside the batch would make `addZooms` mean something its unitary
// sibling does not, and the model would get different results depending on
// how it chose to group its calls. The batch saves round trips; it does
// not quietly hold different rules. The bench still flags the overlap
// (`editorial.ts` zoomIssues), which is where that argument belongs.
const regions = [
{ startSec: 1, endSec: 6 },
{ startSec: 4, endSec: 9 },
];

let oneAtATime = fixtureDocument();
for (const region of regions) {
oneAtATime = executeAgentTool(oneAtATime, "addZoom", JSON.stringify(region))
.document as AxcutDocument;
}
const batch = executeAgentTool(fixtureDocument(), "addZooms", JSON.stringify({ regions }));

const spans = (doc: AxcutDocument) =>
doc.zoomRanges.map((z) => ({ startMs: z.startMs, endMs: z.endMs, depth: z.depth }));
expect(spans(batch.document as AxcutDocument)).toEqual(spans(oneAtATime));
expect(batch.document?.zoomRanges).toHaveLength(2);
});

it("addTrim rejects unknown assets", () => {
const result = executeAgentTool(
fixtureDocument(),
Expand Down
119 changes: 119 additions & 0 deletions electron/ai-edition/agent-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,32 @@ export const addTrimArgs = z.object({
reason: z.string().default(""),
});

/**
* ponytail: the element schema is `addTrimArgs` itself, not a copy of it.
*
* A batch is N unitary calls and nothing else — same validation, same clip
* resolution, same refusal wording — so the two can never drift into meaning
* different things. A separate element schema would be one more place to forget
* `clipId` the next time the unitary one grows a field.
*
* The `union(…, unknown)` is what makes "each item stands or falls alone" true for
* MALFORMED items too, not just unplaceable ones. A bare `z.array(addTrimArgs)`
* rejects the whole call the moment one entry is bad — and it rejects it in
* LangChain, before `applyBatch` runs — so nine good cuts would be thrown away
* with the tenth and `refused[index]` could never name it. Advertising the
* union keeps the element shape in the JSON schema the model reads (it shows up
* as `anyOf: [addTrim, {}]`) while letting a bad entry through to the unitary
* executor, which refuses it by itself with the wording it always uses.
*
* No cap on the array. A half-hour recording has hundreds of silences, and the
* point of this tool is precisely that it should not have to guess how many are
* too many. Picking a number here would repeat the mistake `getTranscript` made
* with its 800.
*/
export const addTrimsArgs = z.object({
ranges: z.array(z.union([addTrimArgs, z.unknown()])).min(1),
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export const setTrimArgs = z.object({
trimRangeId: z.string().min(1),
startSec: secondsSchema,
Expand Down Expand Up @@ -410,6 +436,12 @@ export const addZoomArgs = z.object({
focus: focusSchema.default({ cx: 0.5, cy: 0.5 }),
});

/** Same contract as `addTrimsArgs`: the element schema IS the unitary one, and
* it is advertised rather than enforced so a bad region is refused by itself. */
export const addZoomsArgs = z.object({
regions: z.array(z.union([addZoomArgs, z.unknown()])).min(1),
});

export const setZoomArgs = z.object({
zoomId: z.string().min(1),
startSec: secondsSchema.optional(),
Expand Down Expand Up @@ -486,6 +518,8 @@ export const removeClipArgs = z.object({
*/
export const MUTATING_TOOL_NAMES: ReadonlySet<string> = new Set([
"addTrim",
"addTrims",
"addZooms",
"setTrim",
"setClipRange",
"moveClip",
Expand Down Expand Up @@ -654,6 +688,79 @@ function failure(message: string): AgentToolExecution {
return { ok: false, resultJson: JSON.stringify({ error: message }) };
}

/**
* Runs `unitName` once per item, folding the document forward.
*
* ponytail: the batch tools exist to save ROUND TRIPS, not to mean something new.
* Replaying the unitary executor is what guarantees that — anchoring, clip
* resolution, clamping, the wording of every refusal, all identical by
* construction rather than by a second implementation staying in step. A batch
* of N is exactly N unitary calls minus N-1 round trips, and `agent-tools.test`
* asserts that against a document built the long way.
*
* ponytail: PARTIAL application, deliberately. `replaceTimeline` is the repo's
* other array-taking tool and it refuses in one block — "Refused … Nothing was
* modified" — which is right for a tool that rebuilds the whole timeline and
* ruinous for one that adds ten independent cuts: a single bad bound would throw
* away nine good ones and the model would have to guess which. So each item
* stands or falls alone, and the result says which did what. `ok:false` is kept
* for the case where NOTHING landed, because that is the only one where the
* document did not move.
*/
function applyBatch(
document: AxcutDocument,
unitName: "addTrim" | "addZoom",
items: unknown[],
options: AgentToolOptions | undefined,
noun: string,
): AgentToolExecution {
let current = document;
const applied: Array<Record<string, unknown>> = [];
const refused: Array<{ index: number; error: string }> = [];

items.forEach((item, index) => {
const execution = executeAgentTool(current, unitName, JSON.stringify(item), options);
let payload: Record<string, unknown> = {};
try {
payload = JSON.parse(execution.resultJson) as Record<string, unknown>;
} catch {
payload = { error: execution.resultJson };
}
if (execution.ok && execution.document) {
current = execution.document;
applied.push({ index, ...payload });
} else {
refused.push({ index, error: String(payload.error ?? "refused") });
}
});

// Nothing landed: the document is untouched, so say so the way every other
// refusal does rather than reporting a success with an empty list.
if (applied.length === 0) {
return failure(
`No ${noun} was added. ` +
refused.map((r) => `[${r.index}] ${r.error}`).join(" | ") +
" Nothing was modified.",
);
}

const refusedSuffix = refused.length ? `, ${refused.length} refused` : "";
return {
ok: true,
document: current,
// The counts come first on purpose: the model must be able to see that one
// of ten was refused WITHOUT re-reading the document, and know which one.
resultJson: JSON.stringify({
requested: items.length,
appliedCount: applied.length,
refusedCount: refused.length,
applied,
...(refused.length ? { refused } : {}),
}),
summary: `added ${applied.length} ${noun}${applied.length === 1 ? "" : "s"}${refusedSuffix}`,
};
}

/** The clips as the model would have to name them, for an error about an id it
* got wrong — a bare "Unknown clip: demo" leaves it guessing twice. */
function clipRoster(document: AxcutDocument): string {
Expand Down Expand Up @@ -932,6 +1039,12 @@ export function executeAgentTool(
};
}

case "addTrims": {
const parsed = addTrimsArgs.safeParse(args);
if (!parsed.success) return failure(parsed.error.message);
return applyBatch(document, "addTrim", parsed.data.ranges, options, "trim");
}

case "setTrim": {
const parsed = setTrimArgs.safeParse(args);
if (!parsed.success) return failure(parsed.error.message);
Expand Down Expand Up @@ -1167,6 +1280,12 @@ export function executeAgentTool(
};
}

case "addZooms": {
const parsed = addZoomsArgs.safeParse(args);
if (!parsed.success) return failure(parsed.error.message);
return applyBatch(document, "addZoom", parsed.data.regions, options, "zoom");
}

case "setZoom": {
const parsed = setZoomArgs.safeParse(args);
if (!parsed.success) return failure(parsed.error.message);
Expand Down
Loading
Loading