From 48b2d6f3c03416e031d4b6046f8b7ceb2945fffb Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 14 Sep 2026 10:17:14 -0700 Subject: [PATCH 1/2] CL-7919: decide workflowCoordinator/taskClassifier shape, remove both from ChatDirectorOptions Decision: - workflow coordination is host-owned. WorkflowHost owns the runtime lifecycle (start/resume/reset/persist), which is load-bearing, so the WorkflowCoordinator instance stays host-owned and reaches the director only through the narrow setWorkflowCoordinator live-object seam (the only path production ever used; the constructor option had zero suppliers). All director consult sites (directive, idle rails, gate, handleToolDone) unchanged. - task-boundary classification is neither a host closure nor native director behavior. The taskClassifier seam had zero production suppliers (assembleChatAgent funnel never passes it; TUI and exec omit it), so the decide()-time new-task path was inert in product and its removal keeps TUI behavior byte-identical. The pure classifier (classifyTaskBoundary Tier-1 heuristics + caller-supplied Tier-2) stays in session/compactor.ts as host-free library. Rejected: - tools the director calls (loop-internal automation must not mount model-visible surface), - BaseEnv handles (live non-serializable objects are not config; no new env keys), - moving decide()-time directive/idle/gate rails out of the director (they are the loop), - keeping the constructor option (dead duplicate of the setter blocking zero-closure), - native heuristics-only classification in the director (would newly arm new-task envelopes in the TUI), - native full-LLM-tier classification (decide() has no side-channel inference handle; adding one would be a new host closure). Zero-closure: both closures removed from ChatDirectorOptions. Remaining closures (onActivateTools, onTasksChange, requestContinuation, getProviderId, getLiveFleetCount) belong to sibling lanes CL-7916/7917/7918; this branch merges last (7916->7917->7918->7919). Tests: migrated tests/unit/workflows-director.test.ts (8 sites) from the constructor option to setWorkflowCoordinator; replaced the injected- classifier test with a no-checkpoint normal-infer pin; added setter attach/detach directive tests. bun run check green (7385 pass, 0 fail). --- src/agent/director.ts | 81 ++++++------------------ src/director.test.ts | 89 +++++++++++++++++++++++++-- tests/unit/workflows-director.test.ts | 16 ++--- 3 files changed, 110 insertions(+), 76 deletions(-) diff --git a/src/agent/director.ts b/src/agent/director.ts index 628c75a12..2596a96be 100644 --- a/src/agent/director.ts +++ b/src/agent/director.ts @@ -14,11 +14,7 @@ import type { ConversationTurn, RetryPolicy, } from "@intx/types/runtime"; -import { - type SessionMetadata, - type TaskBoundary, - isCompactSpacerEchoTurn, -} from "../session/compactor.js"; +import { isCompactSpacerEchoTurn } from "../session/compactor.js"; import type { WorkflowCoordinator } from "../workflows/coordinator.js"; import { createCompactionGovernor, @@ -423,13 +419,23 @@ function applyManageTasksToolCall( } export interface ChatDirectorOptions { - taskClassifier?: - | ((message: string, metadata: SessionMetadata) => Promise) - | undefined; + // CL-7919: task-boundary classification and workflow coordination are not + // host-injected closures. Classification's pure core lives in + // session/compactor.ts (classifyTaskBoundary); the director runs no + // decide()-time classification — the taskClassifier seam had zero + // production suppliers, and a native heuristics-only hook would newly arm + // new-task envelopes in the TUI. Coordination is host-owned (WorkflowHost + // owns the runtime lifecycle: start/resume/reset/persist) and reaches the + // director only through setWorkflowCoordinator, the narrow live-object + // seam below — never through options. Rejected: tools the director calls + // (loop-internal automation must not mount model-visible surface), + // BaseEnv handles (live non-serializable objects are not config), moving + // decide()-time directive/idle/gate rails out of the director (they are + // the loop), and keeping the constructor option (dead duplicate of the + // setter that keeps a host closure in options). onActivateTools?: ((names: string[]) => void) | undefined; inactivityTimeoutMs?: number | undefined; totalTimeoutMs?: number | undefined; - workflowCoordinator?: WorkflowCoordinator | undefined; onTasksChange: (tasks: Task[]) => void; requestContinuation?: (() => void) | undefined; provider?: { providerName: string; model?: string } | undefined; @@ -465,13 +471,13 @@ class ChatDirectorImpl extends DefaultDirector { private readonly lspTriggerCalls = new Set(); private readonly askOperatorCalls = new Set(); private readonly onActivateTools: ((names: string[]) => void) | undefined; - private readonly taskClassifier: - | ((message: string, metadata: SessionMetadata) => Promise) - | undefined; private readonly _systemPrompt: string; private _toolDefinitions: ToolDefinition[]; private inactivityTimeoutMs: number | undefined; private totalTimeoutMs: number | undefined; + // CL-7919: host-owned live object, attached via setWorkflowCoordinator + // (WorkflowHost owns the runtime lifecycle). Consulted, never constructed + // here; deliberately not a constructor option. private workflowCoordinator: WorkflowCoordinator | undefined; private workflowIdleTurns = 0; private idleTerminationNudges = 0; @@ -482,10 +488,6 @@ class ChatDirectorImpl extends DefaultDirector { private operatorJustResponded = false; private tasks: Task[] = []; private readonly onTasksChange: ((tasks: Task[]) => void) | undefined; - private turnCount = 0; - private currentTaskLabel: string | undefined; - private lastTaskSummary: string | undefined; - private startedAt = Date.now(); private readonly compaction: CompactionGovernor; private readonly modelFamilyPolicy: ModelFamilyPolicy; private readonly retryPolicy: RetryPolicy; @@ -526,9 +528,7 @@ class ChatDirectorImpl extends DefaultDirector { this._toolDefinitions = toolDefinitions; this.inactivityTimeoutMs = options.inactivityTimeoutMs; this.totalTimeoutMs = options.totalTimeoutMs; - this.taskClassifier = options.taskClassifier; this.onActivateTools = options.onActivateTools; - this.workflowCoordinator = options.workflowCoordinator; this.onTasksChange = options.onTasksChange; this.compaction = createCompactionGovernor( options.requestContinuation, @@ -771,52 +771,7 @@ class ChatDirectorImpl extends DefaultDirector { } if (onTurnBoundary(event)) this.inferenceRecoveries = 0; - if ( - event.type === "message.received" && - this.taskClassifier !== undefined - ) { - const message = event.message; - const content = - typeof message.content === "string" ? message.content : ""; - const metadata: SessionMetadata = { - turnCount: this.turnCount, - currentTaskLabel: this.currentTaskLabel, - lastTaskSummary: this.lastTaskSummary, - minutesElapsed: Math.floor((Date.now() - this.startedAt) / 60000), - toolCallCount: 0, - }; - - try { - const boundary = await this.taskClassifier(content, metadata); - if (boundary.kind === "new_task") { - this.currentTaskLabel = undefined; - - const envelope = - this.lastTaskSummary !== undefined - ? `\n--- Compacted prior context ---\n${this.lastTaskSummary}\n---` + - `\n\nNew task starting now. Prior context summarized above.\n` - : "\n--- Context cleared for new task ---\n"; - - return [ - capabilities.checkpoint(`new-task: ${boundary.reason}`), - capabilities.infer( - withEphemeralNudge( - { - systemPrompt: this._systemPrompt, - tools: this._toolDefinitions, - }, - envelope, - ), - ), - ]; - } - } catch { - // Classifier failure should not break the session. Fall through to infer. - } - } - if (onTurnBoundary(event)) { - this.turnCount++; const hasToolCalls = event.turn.content.some( (b) => b.type === "tool_call", ); diff --git a/src/director.test.ts b/src/director.test.ts index 1be80a8d8..6024ea038 100644 --- a/src/director.test.ts +++ b/src/director.test.ts @@ -9,7 +9,6 @@ import { LEGACY_COMPACT_SPACER_TEXT, compactorNoOpFloor, } from "./session/compactor.js"; -import type { SessionMetadata, TaskBoundary } from "./session/compactor.js"; import { validateActions, type ExtendedInferenceOptions, @@ -1353,12 +1352,11 @@ describe("updateToolDefinitions rewrites infer tools", () => { await toolset.dispose(); }); - test("the new-task path also carries the current tools", async () => { - const classifier = async (_msg: string, _meta: SessionMetadata) => - ({ kind: "new_task" as const, reason: "pivot" }) as TaskBoundary; + // CL-7919: the taskClassifier host closure is gone, so a plain message + // flows to normal inference with no new-task checkpoint or envelope. + test("a message with no classifier configured takes the normal infer path", async () => { const director = createChatDirector("base-prompt", [], { onTasksChange: () => undefined, - taskClassifier: classifier, }); director.updateToolDefinitions([lateTool]); @@ -1373,6 +1371,87 @@ describe("updateToolDefinitions rewrites infer tools", () => { | undefined; expect(inferAction).toBeDefined(); expect(inferToolNames(inferAction)).toContain("mcp__acme__list_issues"); + expect(actions.some((a) => a.type === "checkpoint")).toBe(false); + }); +}); + +describe("CL-7919 coordinator shape", () => { + const makeMessageReceivedEvent = (content: string) => + ({ + type: "message.received", + message: { role: "user", content }, + }) as unknown as ReactorInboundEvent; + const capabilitiesWithInferArgs: ReactorCapabilities = { + ...mockCapabilities, + infer: (opts) => + ({ type: "infer", options: opts }) as unknown as ReactorAction, + }; + const inferEphemeralText = ( + action: ReactorAction | undefined, + ): string | undefined => { + if (action?.type !== "infer") return undefined; + const turns = (action.options as { ephemeralTurns?: unknown } | undefined) + ?.ephemeralTurns; + if (!Array.isArray(turns) || turns.length === 0) return undefined; + const first = turns[0] as { content?: { text?: string }[] }; + return first.content?.[0]?.text; + }; + + // CL-7919: coordination is host-owned and reaches the director only + // through setWorkflowCoordinator — the constructor takes no coordinator. + // Attaching a live coordinator injects its directive into the next infer. + test("setWorkflowCoordinator attaches live coordination to the loop", async () => { + const { WorkflowRuntime } = await import("./workflows/runtime.js"); + const { WorkflowCoordinator } = await import("./workflows/coordinator.js"); + const workflow = { + name: "shape", + description: "setter seam", + steps: [{ id: "a", label: "A" }], + }; + const runtime = new WorkflowRuntime(new Map(), () => workflow); + runtime.start(workflow); + const director = createChatDirector("base-prompt", [], { + onTasksChange: () => undefined, + }); + director.setWorkflowCoordinator(new WorkflowCoordinator(runtime)); + + const actions = actionsArray( + await director.decide( + makeMessageReceivedEvent("hello"), + mockState, + capabilitiesWithInferArgs, + ), + ); + const infer = actions.find((a) => a.type === "infer"); + expect(inferEphemeralText(infer)).toContain("[WORKFLOW STEP 1/1: A]"); + }); + + // Detaching restores the plain loop: no directive once cleared. + test("clearing the coordinator removes the directive", async () => { + const { WorkflowRuntime } = await import("./workflows/runtime.js"); + const { WorkflowCoordinator } = await import("./workflows/coordinator.js"); + const workflow = { + name: "shape", + description: "setter seam", + steps: [{ id: "a", label: "A" }], + }; + const runtime = new WorkflowRuntime(new Map(), () => workflow); + runtime.start(workflow); + const director = createChatDirector("base-prompt", [], { + onTasksChange: () => undefined, + }); + director.setWorkflowCoordinator(new WorkflowCoordinator(runtime)); + director.setWorkflowCoordinator(undefined); + + const actions = actionsArray( + await director.decide( + makeMessageReceivedEvent("hello"), + mockState, + capabilitiesWithInferArgs, + ), + ); + const infer = actions.find((a) => a.type === "infer"); + expect(inferEphemeralText(infer)).toBeUndefined(); }); }); diff --git a/tests/unit/workflows-director.test.ts b/tests/unit/workflows-director.test.ts index ea44b40a8..b372678b0 100644 --- a/tests/unit/workflows-director.test.ts +++ b/tests/unit/workflows-director.test.ts @@ -109,8 +109,8 @@ test("the active step directive is injected into the inferred system prompt", as const coordinator = new WorkflowCoordinator(runtime); const director = createChatDirector("BASE PROMPT", [], { onTasksChange: () => undefined, - workflowCoordinator: coordinator, }); + director.setWorkflowCoordinator(coordinator); const event: ReactorInboundEvent = { type: "message.received", @@ -147,8 +147,8 @@ test("a submit_output tool call with the current step id advances the runtime th const coordinator = new WorkflowCoordinator(runtime); const director = createChatDirector("BASE", [], { onTasksChange: () => undefined, - workflowCoordinator: coordinator, }); + director.setWorkflowCoordinator(coordinator); const caps = makeCapabilities(); const turn: ReactorInboundEvent = { @@ -189,8 +189,8 @@ test("a stale submit_output does not skip ahead through the director", async () const coordinator = new WorkflowCoordinator(runtime); const director = createChatDirector("BASE", [], { onTasksChange: () => undefined, - workflowCoordinator: coordinator, }); + director.setWorkflowCoordinator(coordinator); const caps = makeCapabilities(); const turn: ReactorInboundEvent = { @@ -280,8 +280,8 @@ test("auto-continuation fires on reply() as well as wait() after a text turn", a const coordinator = new WorkflowCoordinator(runtime); const director = createChatDirector("BASE", [], { onTasksChange: () => undefined, - workflowCoordinator: coordinator, }); + director.setWorkflowCoordinator(coordinator); const caps = makeCapabilities(); // Simulate a text-only inference turn (no tool calls). @@ -356,8 +356,8 @@ test("a content-free workflow turn with open tasks nudges toward submit_output", const coordinator = new WorkflowCoordinator(runtime); const director = createChatDirector("BASE", [], { onTasksChange: () => undefined, - workflowCoordinator: coordinator, }); + director.setWorkflowCoordinator(coordinator); const caps = makeCapabilities(); await director.decide(manageTasksTurn("doing"), state, caps); @@ -386,8 +386,8 @@ test("open tasks do not defeat the workflow stuck-cutoff after 3 idle turns", as const coordinator = new WorkflowCoordinator(runtime); const director = createChatDirector("BASE", [], { onTasksChange: () => undefined, - workflowCoordinator: coordinator, }); + director.setWorkflowCoordinator(coordinator); const caps = makeCapabilities(); await director.decide(manageTasksTurn("doing"), state, caps); @@ -411,8 +411,8 @@ test("auto-continuation falls back after 3 consecutive text-only turns", async ( const coordinator = new WorkflowCoordinator(runtime); const director = createChatDirector("BASE", [], { onTasksChange: () => undefined, - workflowCoordinator: coordinator, }); + director.setWorkflowCoordinator(coordinator); const caps = makeCapabilities(); await director.decide(textTurn("text 1"), state, caps); @@ -436,8 +436,8 @@ test("after spacer echo-cap a non-gate workflow step does not empty-settle", asy const coordinator = new WorkflowCoordinator(runtime); const director = createChatDirector("BASE", [], { onTasksChange: () => undefined, - workflowCoordinator: coordinator, }); + director.setWorkflowCoordinator(coordinator); const caps = makeCapabilities(); for (let i = 0; i < 2; i++) { From 8871c17dbaa723e9e48344f5de42dc397a754d39 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 14 Sep 2026 18:50:34 -0700 Subject: [PATCH 2/2] fix(director): harden coordinator seam with fallback and validation (#1050) * fix(director): harden coordinator seam with fallback and validation * fix(director): guard coordinator step id and empty directive (#1059) --- src/agent/director.ts | 128 +++++++++++++++++++++++++---- src/director.test.ts | 187 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 301 insertions(+), 14 deletions(-) diff --git a/src/agent/director.ts b/src/agent/director.ts index 2596a96be..750d33b78 100644 --- a/src/agent/director.ts +++ b/src/agent/director.ts @@ -147,6 +147,30 @@ const MAX_SPACER_ECHO_NUDGES = 2; const SPACER_ECHO_NUDGE = "Continue the task. Do not repeat internal markers."; +// Upper bound for a coordinator directive injected into the next infer. +// Directives are small step blocks; anything beyond this is a runaway prompt +// or a misbehaving host object. Capped with a marker (never dropped) and +// logged so the workflow owner can see the trim. +export const MAX_WORKFLOW_DIRECTIVE_CHARS = 8_000; + +// Runtime shape guard for the host-owned live object. TypeScript covers +// in-repo callers; this covers JS hosts handing back a lookalike with a +// missing or non-function member, which would otherwise reject decide() +// a turn later at the first consult site. +function isWorkflowCoordinatorLike( + value: unknown, +): value is WorkflowCoordinator { + if (typeof value !== "object" || value === null) return false; + const candidate = value as Record; + return ( + typeof candidate.directive === "function" && + typeof candidate.isActive === "function" && + typeof candidate.currentStepIsGate === "function" && + typeof candidate.currentStepId === "function" && + typeof candidate.handleToolDone === "function" + ); +} + const IDLE_OPEN_TASK_NUDGE = "\n\nYou are ending your turn while tasks are still open (todo/doing). " + "Finish the remaining work and mark each task done or cancelled with " + @@ -541,9 +565,88 @@ class ChatDirectorImpl extends DefaultDirector { } setWorkflowCoordinator(coordinator: WorkflowCoordinator | undefined): void { + if (coordinator !== undefined && !isWorkflowCoordinatorLike(coordinator)) { + throw new Error( + "setWorkflowCoordinator: invalid coordinator — expected a WorkflowCoordinator " + + "with directive(), isActive(), currentStepIsGate(), currentStepId(), " + + "and handleToolDone() functions.", + ); + } this.workflowCoordinator = coordinator; } + // Coordinator consults are best-effort per-turn rails: a throwing host + // object must degrade to plain inference, never reject decide(). Each + // helper below catches, warns once per call, and returns the plain-loop + // fallback so the session keeps running. + private coordinatorIsActive(): boolean { + try { + return this.workflowCoordinator?.isActive() === true; + } catch (err) { + logger.warn`workflow-coordinator-isActive-threw error=${err instanceof Error ? err.message : String(err)}`; + return false; + } + } + + private coordinatorDirective(): string | null { + try { + const directive = this.workflowCoordinator?.directive() ?? null; + if (directive === null) return null; + if (typeof directive !== "string") { + logger.warn`workflow-coordinator-directive-not-string`; + return null; + } + if (directive.length === 0) return null; + if (directive.length > MAX_WORKFLOW_DIRECTIVE_CHARS) { + logger.warn`workflow-coordinator-directive-truncated chars=${String(directive.length)} max=${String(MAX_WORKFLOW_DIRECTIVE_CHARS)}`; + return `${directive.slice(0, MAX_WORKFLOW_DIRECTIVE_CHARS)}\n…[truncated]`; + } + return directive; + } catch (err) { + logger.warn`workflow-coordinator-directive-threw error=${err instanceof Error ? err.message : String(err)}`; + return null; + } + } + + private coordinatorCurrentStepIsGate(): boolean { + try { + return this.workflowCoordinator?.currentStepIsGate() === true; + } catch (err) { + logger.warn`workflow-coordinator-gate-threw error=${err instanceof Error ? err.message : String(err)}`; + return false; + } + } + + private coordinatorCurrentStepId(): string | null { + try { + const stepId = this.workflowCoordinator?.currentStepId() ?? null; + if (stepId === null) return null; + if (typeof stepId !== "string") { + logger.warn`workflow-coordinator-step-id-not-string`; + return null; + } + return stepId; + } catch (err) { + logger.warn`workflow-coordinator-step-id-threw error=${err instanceof Error ? err.message : String(err)}`; + return null; + } + } + + private coordinatorHandleToolDone( + name: string | undefined, + args: unknown, + isError: boolean, + ): boolean { + try { + return ( + this.workflowCoordinator?.handleToolDone(name, args, isError) === true + ); + } catch (err) { + logger.warn`workflow-coordinator-handleToolDone-threw error=${err instanceof Error ? err.message : String(err)}`; + return false; + } + } + updateToolDefinitions(toolDefinitions: ToolDefinition[]): void { const before = toolSetDigest(this._toolDefinitions); const after = toolSetDigest(toolDefinitions); @@ -624,7 +727,7 @@ class ChatDirectorImpl extends DefaultDirector { private withCurrentTools( result: ReactorAction | ReactorAction[], ): ReactorAction | ReactorAction[] { - const active = this.workflowCoordinator?.isActive() === true; + const active = this.coordinatorIsActive(); // submit_output rides on the wire every turn, workflow or not, so // activating a workflow never grows the tools array and busts the cache // prefix. Outside a workflow it is a harmless no-op the director ignores @@ -635,9 +738,7 @@ class ChatDirectorImpl extends DefaultDirector { ? this._toolDefinitions : [...this._toolDefinitions, submitOutputDefinition]; - const directive = active - ? (this.workflowCoordinator?.directive() ?? null) - : null; + const directive = active ? this.coordinatorDirective() : null; const rewrite = (action: ReactorAction): ReactorAction => { if (action.type !== "infer") return action; @@ -803,7 +904,7 @@ class ChatDirectorImpl extends DefaultDirector { this.pendingToolOnlyNudge = true; } - if (this.workflowCoordinator?.isActive()) { + if (this.coordinatorIsActive()) { if (hasToolCalls) { this.workflowIdleTurns = 0; } else { @@ -847,7 +948,7 @@ class ChatDirectorImpl extends DefaultDirector { ) { const call = this.workflowCalls.get(event.result.callId); this.workflowCalls.delete(event.result.callId); - const advanced = this.workflowCoordinator?.handleToolDone( + const advanced = this.coordinatorHandleToolDone( call?.name, call?.args, event.result.isError === true, @@ -953,8 +1054,8 @@ class ChatDirectorImpl extends DefaultDirector { ); if (toolOnlyRewrite !== null) return toolOnlyRewrite; - const coordinator = this.workflowCoordinator; - if (coordinator?.isActive() && !coordinator.currentStepIsGate()) { + const coordinatorActive = this.coordinatorIsActive(); + if (coordinatorActive && !this.coordinatorCurrentStepIsGate()) { const hasTerminal = baseActions.some( (a) => a.type === "wait" || a.type === "reply", ); @@ -975,7 +1076,7 @@ class ChatDirectorImpl extends DefaultDirector { ), ]; } - const stepId = coordinator.currentStepId(); + const stepId = this.coordinatorCurrentStepId(); const stepClause = stepId !== null ? `call submit_output with { "step": "${stepId}" } now` @@ -1000,7 +1101,7 @@ class ChatDirectorImpl extends DefaultDirector { // yielding there with open tasks is not an invariant breach — leave it to // the workflow runtime and do not nudge. const atWorkflowGate = - coordinator?.isActive() === true && coordinator.currentStepIsGate(); + coordinatorActive && this.coordinatorCurrentStepIsGate(); if (!atWorkflowGate && hasActiveTasks(this.tasks)) { const hasTerminal = baseActions.some( (a) => a.type === "wait" || a.type === "reply", @@ -1022,10 +1123,9 @@ class ChatDirectorImpl extends DefaultDirector { // Inside a workflow the terminal action is submit_output with the // current step id, so point the nudge at it rather than the general // manage_tasks guidance. - const nudge = - coordinator?.isActive() === true - ? WORKFLOW_OPEN_TASK_NUDGE - : IDLE_OPEN_TASK_NUDGE; + const nudge = coordinatorActive + ? WORKFLOW_OPEN_TASK_NUDGE + : IDLE_OPEN_TASK_NUDGE; return [...passThrough, inferWithNudge(capabilities, nudge)]; } this.logTerminationWithOpenTasks("idle-stall"); diff --git a/src/director.test.ts b/src/director.test.ts index 6024ea038..d34d19820 100644 --- a/src/director.test.ts +++ b/src/director.test.ts @@ -1,5 +1,6 @@ import { describe, test, expect } from "bun:test"; import { createChatDirector, askOperatorDefinition } from "./agent/director.js"; +import type { WorkflowCoordinator } from "./workflows/coordinator.js"; import { createAgentToolset } from "./agent/tools.js"; import { createAdvertisedToolset } from "./session/assemble-runtime.js"; import { createPermissionGate } from "./permission/gate.js"; @@ -1453,6 +1454,192 @@ describe("CL-7919 coordinator shape", () => { const infer = actions.find((a) => a.type === "infer"); expect(inferEphemeralText(infer)).toBeUndefined(); }); + + // A throwing coordinator degrades to plain inference: decide() resolves + // with an infer free of the workflow directive instead of rejecting. + test("a throwing directive falls back to plain inference", async () => { + const { MAX_WORKFLOW_DIRECTIVE_CHARS } = + await import("./agent/director.js"); + expect(MAX_WORKFLOW_DIRECTIVE_CHARS).toBeGreaterThan(0); + const director = createChatDirector("base-prompt", [], { + onTasksChange: () => undefined, + }); + director.setWorkflowCoordinator({ + directive: () => { + throw new Error("boom"); + }, + isActive: () => true, + currentStepIsGate: () => false, + currentStepId: () => "a", + handleToolDone: () => false, + } as unknown as WorkflowCoordinator); + const actions = actionsArray( + await director.decide( + makeMessageReceivedEvent("hello"), + mockState, + capabilitiesWithInferArgs, + ), + ); + const infer = actions.find((a) => a.type === "infer"); + expect(infer).toBeDefined(); + expect(inferEphemeralText(infer)).toBeUndefined(); + }); + + // Every per-turn consult is guarded, not just directive(): a coordinator + // whose rails all throw still lets decide() (including the tool.done + // handleToolDone path) resolve to the plain loop. + test("throwing idle rails and handleToolDone fall back to the plain loop", async () => { + const director = createChatDirector("base-prompt", [], { + onTasksChange: () => undefined, + }); + director.setWorkflowCoordinator({ + directive: () => { + throw new Error("directive boom"); + }, + isActive: () => { + throw new Error("active boom"); + }, + currentStepIsGate: () => { + throw new Error("gate boom"); + }, + currentStepId: () => { + throw new Error("step boom"); + }, + handleToolDone: () => { + throw new Error("tool boom"); + }, + } as unknown as WorkflowCoordinator); + const fromMessage = actionsArray( + await director.decide( + makeMessageReceivedEvent("hello"), + mockState, + capabilitiesWithInferArgs, + ), + ); + expect(fromMessage.find((a) => a.type === "infer")).toBeDefined(); + const fromToolDone = actionsArray( + await director.decide( + { + type: "tool.done", + result: { callId: "missing", content: "ok" }, + } as unknown as ReactorInboundEvent, + mockState, + capabilitiesWithInferArgs, + ), + ); + expect(fromToolDone.length).toBeGreaterThan(0); + }); + + // The setter is the shape boundary: a lookalike missing coordinator + // members is rejected with a clear error instead of failing a turn later. + test("setWorkflowCoordinator rejects a misshapen coordinator", async () => { + const director = createChatDirector("base-prompt", [], { + onTasksChange: () => undefined, + }); + expect(() => + director.setWorkflowCoordinator({ + isActive: () => true, + } as unknown as Parameters[0]), + ).toThrow(/setWorkflowCoordinator.*invalid coordinator/); + }); + + // An oversized directive is capped with a marker, never dropped: the turn + // still carries workflow guidance within the bound. + test("an oversized directive is capped with a truncation marker", async () => { + const { MAX_WORKFLOW_DIRECTIVE_CHARS } = + await import("./agent/director.js"); + const director = createChatDirector("base-prompt", [], { + onTasksChange: () => undefined, + }); + const oversized = `prefix ${"x".repeat(MAX_WORKFLOW_DIRECTIVE_CHARS + 100)}`; + director.setWorkflowCoordinator({ + directive: () => oversized, + isActive: () => true, + currentStepIsGate: () => false, + currentStepId: () => "a", + handleToolDone: () => false, + } as unknown as WorkflowCoordinator); + const actions = actionsArray( + await director.decide( + makeMessageReceivedEvent("hello"), + mockState, + capabilitiesWithInferArgs, + ), + ); + const text = inferEphemeralText(actions.find((a) => a.type === "infer")); + expect(text).toBeDefined(); + expect(text).toContain("…[truncated]"); + expect(text?.startsWith("prefix")).toBe(true); + expect(text?.length ?? Number.POSITIVE_INFINITY).toBeLessThanOrEqual( + MAX_WORKFLOW_DIRECTIVE_CHARS + "…[truncated]".length + 1, + ); + }); + + // A non-string step id never reaches prompt text: the stall nudge falls + // back to the generic clause instead of interpolating the foreign value. + test("a non-string step id falls back to the generic submit_output clause", async () => { + const director = createChatDirector("base-prompt", [], { + onTasksChange: () => undefined, + }); + director.setWorkflowCoordinator({ + directive: () => "do the thing", + isActive: () => true, + currentStepIsGate: () => false, + currentStepId: () => 42, + handleToolDone: () => false, + } as unknown as WorkflowCoordinator); + const actions = actionsArray( + await director.decide( + { + type: "inference.done", + turn: { + role: "assistant", + model: "test", + timestamp: 0, + content: [{ type: "text", text: "all set" }], + }, + usage: { + input: 10, + output: 1, + cacheRead: 0, + cacheWrite: 0, + thinking: 0, + }, + source: { model: "test-model" }, + } as unknown as ReactorInboundEvent, + mockState, + capabilitiesWithInferArgs, + ), + ); + const text = inferEphemeralText(actions.find((a) => a.type === "infer")); + expect(text).toContain("call submit_output with this step's id now"); + expect(text).not.toContain("42"); + }); + + // An empty directive is absent guidance: no ephemeral turn is appended + // and the turn resolves as plain inference. + test("an empty-string directive resolves as plain inference", async () => { + const director = createChatDirector("base-prompt", [], { + onTasksChange: () => undefined, + }); + director.setWorkflowCoordinator({ + directive: () => "", + isActive: () => true, + currentStepIsGate: () => false, + currentStepId: () => "a", + handleToolDone: () => false, + } as unknown as WorkflowCoordinator); + const actions = actionsArray( + await director.decide( + makeMessageReceivedEvent("hello"), + mockState, + capabilitiesWithInferArgs, + ), + ); + const infer = actions.find((a) => a.type === "infer"); + expect(infer).toBeDefined(); + expect(inferEphemeralText(infer)).toBeUndefined(); + }); }); describe("submit_output workflow handler", () => {