Skip to content

Commit 853c78d

Browse files
committed
test(director): pin boundary rethrow and emit preservation
1 parent 069a4e0 commit 853c78d

1 file changed

Lines changed: 189 additions & 0 deletions

File tree

‎src/agent/director.test.ts‎

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,195 @@ describe("ChatDirector inference-error recovery (CL-6910)", () => {
426426
);
427427
expect(stale).toEqual([]);
428428
});
429+
430+
function keeperManageTasksTurn(): ReactorInboundEvent {
431+
return {
432+
type: "inference.done",
433+
turn: {
434+
role: "assistant",
435+
model: "test",
436+
timestamp: 0,
437+
content: [
438+
{
439+
type: "tool_call",
440+
id: "manage-tasks",
441+
name: "manage_tasks",
442+
arguments: {
443+
action: "create",
444+
tasks: [{ id: "t1", title: "work", status: "doing" }],
445+
},
446+
},
447+
],
448+
},
449+
usage: { input: 0, output: 0 },
450+
source: "test",
451+
} as unknown as ReactorInboundEvent;
452+
}
453+
454+
function keeperManageTasksPlusSubmitTurn(): ReactorInboundEvent {
455+
return {
456+
type: "inference.done",
457+
turn: {
458+
role: "assistant",
459+
model: "test",
460+
timestamp: 0,
461+
content: [
462+
{
463+
type: "tool_call",
464+
id: "manage-tasks",
465+
name: "manage_tasks",
466+
arguments: {
467+
action: "create",
468+
tasks: [{ id: "t1", title: "work", status: "doing" }],
469+
},
470+
},
471+
{
472+
type: "tool_call",
473+
id: "submit-1",
474+
name: "submit_output",
475+
arguments: { step: "step-1" },
476+
},
477+
],
478+
},
479+
usage: { input: 0, output: 0 },
480+
source: "test",
481+
} as unknown as ReactorInboundEvent;
482+
}
483+
484+
function keeperTextTurn(): ReactorInboundEvent {
485+
return {
486+
type: "inference.done",
487+
turn: {
488+
role: "assistant",
489+
model: "test",
490+
timestamp: 0,
491+
content: [{ type: "text", text: "all set" }],
492+
},
493+
usage: { input: 0, output: 0 },
494+
source: "test",
495+
} as unknown as ReactorInboundEvent;
496+
}
497+
498+
function keeperTasksChanged(actions: ReactorAction[]): ReactorAction[] {
499+
return actions.filter(
500+
(a) =>
501+
a.type === "emit" &&
502+
(a as { eventType?: string }).eventType === CHAT_TASKS_CHANGED_EVENT,
503+
);
504+
}
505+
506+
// CL-7992 K1: every coordinator rail consulted on the turn boundary
507+
// rethrows instead of degrading — a throwing rail rejects decide() and
508+
// the next turn carries no stale task-change notifications.
509+
test.each(["isActive", "currentStepIsGate", "currentStepId"] as const)(
510+
"a throwing %s rail rejects the inference turn without leaking task-change emits",
511+
async (rail) => {
512+
const failure = new Error(`${rail} exploded`);
513+
const coordinator = {
514+
isActive: () => {
515+
if (rail === "isActive") throw failure;
516+
return true;
517+
},
518+
currentStepIsGate: () => {
519+
if (rail === "currentStepIsGate") throw failure;
520+
return false;
521+
},
522+
currentStepId: () => {
523+
if (rail === "currentStepId") throw failure;
524+
return null;
525+
},
526+
directive: () => null,
527+
handleToolDone: () => false,
528+
} as unknown as WorkflowCoordinator;
529+
const director = createChatDirector("system", [], {});
530+
director.setWorkflowCoordinator(coordinator);
531+
const capabilities = makeCapabilities();
532+
533+
// The step-id rail only runs past a terminal base action, so it
534+
// throws on a text turn; the earlier rails throw on a manage_tasks
535+
// turn after it queues its task-change notification.
536+
const triggering =
537+
rail === "currentStepId" ? keeperTextTurn() : keeperManageTasksTurn();
538+
await expect(
539+
director.decide(triggering, mockState, capabilities),
540+
).rejects.toBe(failure);
541+
542+
director.setWorkflowCoordinator(undefined);
543+
const actions = actionsArray(
544+
await director.decide(keeperTextTurn(), mockState, capabilities),
545+
);
546+
expect(keeperTasksChanged(actions)).toEqual([]);
547+
},
548+
);
549+
550+
// CL-7992 K2: a mid-turn failure outside the coordinator still rejects
551+
// the turn, but already-queued notifications survive for the next turn.
552+
test("a non-coordinator mid-turn failure preserves queued task-change emits for the next turn", async () => {
553+
const failure = new Error("tool execution exploded");
554+
const director = createChatDirector("system", [], {});
555+
const capabilities = makeCapabilities();
556+
const executing: ReactorCapabilities = {
557+
...capabilities,
558+
executeTools: (): ReactorAction => {
559+
throw failure;
560+
},
561+
};
562+
563+
await expect(
564+
director.decide(keeperManageTasksTurn(), mockState, executing),
565+
).rejects.toBe(failure);
566+
567+
const actions = actionsArray(
568+
await director.decide(keeperTextTurn(), mockState, capabilities),
569+
);
570+
expect(keeperTasksChanged(actions)).toHaveLength(1);
571+
});
572+
573+
// CL-7992 K2 variant: a throwing handleToolDone degrades (it takes no
574+
// rethrow parameter) instead of marking the turn stale, so a later
575+
// non-coordinator failure still preserves the queued notifications.
576+
test("a throwing handleToolDone degrades without dropping preserved task-change emits", async () => {
577+
let handleToolDoneSeen = false;
578+
const coordinator = {
579+
isActive: () => false,
580+
currentStepIsGate: () => false,
581+
currentStepId: () => null,
582+
directive: () => null,
583+
handleToolDone: (): boolean => {
584+
handleToolDoneSeen = true;
585+
throw new Error("coordinator completion exploded");
586+
},
587+
} as unknown as WorkflowCoordinator;
588+
const director = createChatDirector("system", [], {});
589+
director.setWorkflowCoordinator(coordinator);
590+
const capabilities = makeCapabilities();
591+
const executing: ReactorCapabilities = {
592+
...capabilities,
593+
executeTools: (): ReactorAction => {
594+
throw new Error("tool execution exploded");
595+
},
596+
};
597+
598+
await expect(
599+
director.decide(keeperManageTasksPlusSubmitTurn(), mockState, executing),
600+
).rejects.toThrow("tool execution exploded");
601+
602+
const brokenState = {
603+
get turns(): never {
604+
throw new Error("history unavailable");
605+
},
606+
} as unknown as ReactorState;
607+
await expect(
608+
director.decide(toolDoneEvent("submit-1"), brokenState, capabilities),
609+
).rejects.toThrow("history unavailable");
610+
expect(handleToolDoneSeen).toBe(true);
611+
612+
director.setWorkflowCoordinator(undefined);
613+
const actions = actionsArray(
614+
await director.decide(keeperTextTurn(), mockState, capabilities),
615+
);
616+
expect(keeperTasksChanged(actions)).toHaveLength(1);
617+
});
429618
});
430619

431620
// CL-7973: the director's live source id (which stamps retry decisions so a

0 commit comments

Comments
 (0)