diff --git a/src/agent/director.test.ts b/src/agent/director.test.ts index db57c7dca..7e1b6061a 100644 --- a/src/agent/director.test.ts +++ b/src/agent/director.test.ts @@ -374,9 +374,8 @@ describe("ChatDirector inference-error recovery (CL-6910)", () => { }, handleToolDone: () => false, } as unknown as WorkflowCoordinator; - const director = createChatDirector("system", [], { - workflowCoordinator: throwingCoordinator, - }); + const director = createChatDirector("system", [], {}); + director.setWorkflowCoordinator(throwingCoordinator); const capabilities = makeCapabilities(); const manageTasksTurn = { diff --git a/src/agent/director.ts b/src/agent/director.ts index a248bf200..7ab9f2c79 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 { compactionContinuationAction, @@ -153,6 +149,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 " + @@ -439,12 +459,22 @@ export const ChatToolsActivateDataSchema = type({ }); 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). inactivityTimeoutMs?: number | undefined; totalTimeoutMs?: number | undefined; - workflowCoordinator?: WorkflowCoordinator | undefined; provider?: { providerName: string; model?: string } | undefined; /** * CL-7918 decisions (both former closures removed, no new env key): @@ -499,13 +529,13 @@ class ChatDirectorImpl extends DefaultDirector { >(); private readonly lspTriggerCalls = new Set(); private readonly askOperatorCalls = new Set(); - 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; @@ -544,6 +574,12 @@ class ChatDirectorImpl extends DefaultDirector { // notifications ride along with every terminal action list (emit is // composable with all other actions). private pendingEmits: ReactorAction[] = []; + // Set when an inference-turn coordinator helper rethrows (the only path + // whose queued emits are stale by construction). decide()'s catch consults + // it so only that path drops the queue; any other mid-turn failure + // preserves the queue for the next successful turn instead of desyncing + // the host from already-mutated director state. + private coordinatorRethrowNoted = false; constructor( systemPrompt: string, @@ -570,8 +606,6 @@ class ChatDirectorImpl extends DefaultDirector { this._toolDefinitions = toolDefinitions; this.inactivityTimeoutMs = options.inactivityTimeoutMs; this.totalTimeoutMs = options.totalTimeoutMs; - this.taskClassifier = options.taskClassifier; - this.workflowCoordinator = options.workflowCoordinator; // The chat path holds no continuation closure: the governor expresses // continuation as an emit action the host answers with a deliver. this.compaction = createCompactionGovernor( @@ -592,9 +626,122 @@ 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 degrade to plain inference on non-inference events: + // a throwing host object must not take down the session before inference + // has produced a turn. On an inference turn (inference.done) throw + // semantics are preserved instead — the turn cannot be faithfully + // assembled, so the error rejects decide() (which drops the turn's queued + // task/tool notifications rather than flushing them stale) instead of + // resolving a silent plain-inference batch. Shape fallbacks below (a + // non-string directive or step id, an empty directive, truncation) never + // throw and apply on every event. Each inference-turn helper takes + // rethrowCoordinatorError (onTurnBoundary of the current event at every + // call site) to select between the two behaviors. coordinatorHandleToolDone + // is the exception: its sole call site runs mid-turn (tool.done, never the + // turn boundary), so it always degrades to plain inference on a + // coordinator throw and takes no rethrow parameter. + private coordinatorIsActive(rethrowCoordinatorError: boolean): boolean { + try { + return this.workflowCoordinator?.isActive() === true; + } catch (err) { + if (rethrowCoordinatorError) { + this.coordinatorRethrowNoted = true; + throw err; + } + logger.warn`workflow-coordinator-isActive-threw error=${err instanceof Error ? err.message : String(err)}`; + return false; + } + } + + private coordinatorDirective( + rethrowCoordinatorError: boolean, + ): 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) { + if (rethrowCoordinatorError) { + this.coordinatorRethrowNoted = true; + throw err; + } + logger.warn`workflow-coordinator-directive-threw error=${err instanceof Error ? err.message : String(err)}`; + return null; + } + } + + private coordinatorCurrentStepIsGate( + rethrowCoordinatorError: boolean, + ): boolean { + try { + return this.workflowCoordinator?.currentStepIsGate() === true; + } catch (err) { + if (rethrowCoordinatorError) { + this.coordinatorRethrowNoted = true; + throw err; + } + logger.warn`workflow-coordinator-gate-threw error=${err instanceof Error ? err.message : String(err)}`; + return false; + } + } + + private coordinatorCurrentStepId( + rethrowCoordinatorError: boolean, + ): 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) { + if (rethrowCoordinatorError) { + this.coordinatorRethrowNoted = true; + throw 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) { + // Mid-turn only (tool.done): a throwing coordinator degrades to plain + // inference rather than failing the turn. + logger.warn`workflow-coordinator-handleToolDone-threw error=${err instanceof Error ? err.message : String(err)}`; + return false; + } + } + // Narrow live setter for the idle-with-fleet allowance (CL-7972): the // fleet-wake publisher drives this on fleet-count transitions, so a drained // fleet resumes the open-task nudge instead of holding the seeded value. @@ -682,8 +829,9 @@ class ChatDirectorImpl extends DefaultDirector { private withCurrentTools( result: ReactorAction | ReactorAction[], + rethrowCoordinatorError: boolean, ): ReactorAction | ReactorAction[] { - const active = this.workflowCoordinator?.isActive() === true; + const active = this.coordinatorIsActive(rethrowCoordinatorError); // 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 @@ -695,7 +843,7 @@ class ChatDirectorImpl extends DefaultDirector { : [...this._toolDefinitions, submitOutputDefinition]; const directive = active - ? (this.workflowCoordinator?.directive() ?? null) + ? this.coordinatorDirective(rethrowCoordinatorError) : null; const rewrite = (action: ReactorAction): ReactorAction => { @@ -726,12 +874,13 @@ class ChatDirectorImpl extends DefaultDirector { state: ReactorState, capabilities: ReactorCapabilities, ): Promise { + this.coordinatorRethrowNoted = false; try { const settled = ensureCycleSettlesWithReply( await this.decideInner(event, state, capabilities), capabilities, ); - const withTools = this.withCurrentTools(settled); + const withTools = this.withCurrentTools(settled, onTurnBoundary(event)); if (this.pendingEmits.length === 0) return withTools; const emits = this.pendingEmits; this.pendingEmits = []; @@ -740,10 +889,15 @@ class ChatDirectorImpl extends DefaultDirector { ...emits, ]; } catch (err) { - // A failed turn must not leak its queued task/tool notifications into - // the next turn — drop them so the next turn starts clean instead of - // flushing stale updates. - this.pendingEmits = []; + // Only a coordinator rethrow on the turn boundary leaves queued + // task/tool notifications stale by construction (the turn cannot be + // faithfully assembled, so the queue is dropped and the next turn + // starts clean). Any other mid-turn failure preserves the queue: the + // turn's state mutations (tasks, LSP triggers) already persist, so + // dropping the queue would desync the host until the next + // task/tool-changing turn. The error still propagates either way. + if (this.coordinatorRethrowNoted) this.pendingEmits = []; + this.coordinatorRethrowNoted = false; throw err; } } @@ -861,52 +1015,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", ); @@ -938,7 +1047,7 @@ class ChatDirectorImpl extends DefaultDirector { this.pendingToolOnlyNudge = true; } - if (this.workflowCoordinator?.isActive()) { + if (this.coordinatorIsActive(onTurnBoundary(event))) { if (hasToolCalls) { this.workflowIdleTurns = 0; } else { @@ -986,7 +1095,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, @@ -1113,8 +1222,11 @@ class ChatDirectorImpl extends DefaultDirector { ); if (toolOnlyRewrite !== null) return toolOnlyRewrite; - const coordinator = this.workflowCoordinator; - if (coordinator?.isActive() && !coordinator.currentStepIsGate()) { + const coordinatorActive = this.coordinatorIsActive(onTurnBoundary(event)); + if ( + coordinatorActive && + !this.coordinatorCurrentStepIsGate(onTurnBoundary(event)) + ) { const hasTerminal = baseActions.some( (a) => a.type === "wait" || a.type === "reply", ); @@ -1135,7 +1247,7 @@ class ChatDirectorImpl extends DefaultDirector { ), ]; } - const stepId = coordinator.currentStepId(); + const stepId = this.coordinatorCurrentStepId(onTurnBoundary(event)); const stepClause = stepId !== null ? `call submit_output with { "step": "${stepId}" } now` @@ -1160,7 +1272,8 @@ 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(onTurnBoundary(event)); if (!atWorkflowGate && hasActiveTasks(this.tasks)) { const hasTerminal = baseActions.some( (a) => a.type === "wait" || a.type === "reply", @@ -1186,10 +1299,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 91b32d909..f4c3217ea 100644 --- a/src/director.test.ts +++ b/src/director.test.ts @@ -5,6 +5,7 @@ import { CHAT_TASKS_CHANGED_EVENT, CHAT_TOOLS_ACTIVATE_EVENT, } from "./agent/director.js"; +import type { WorkflowCoordinator } from "./workflows/coordinator.js"; import { COMPACTION_CONTINUATION_EVENT } from "./agent/compaction.js"; import { createAgentToolset } from "./agent/tools.js"; import { createAdvertisedToolset } from "./session/assemble-runtime.js"; @@ -15,7 +16,6 @@ import { LEGACY_COMPACT_SPACER_TEXT, compactorNoOpFloor, } from "./session/compactor.js"; -import type { SessionMetadata, TaskBoundary } from "./session/compactor.js"; import { validateActions, type ExtendedInferenceOptions, @@ -1451,12 +1451,10 @@ 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; - const director = createChatDirector("base-prompt", [], { - taskClassifier: classifier, - }); + // 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", [], {}); director.updateToolDefinitions([lateTool]); const result = await director.decide( @@ -1470,6 +1468,257 @@ 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", [], {}); + 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", [], {}); + 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(); + }); + + // 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", [], {}); + 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", [], {}); + 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", [], {}); + 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", [], {}); + 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", [], {}); + 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", [], {}); + 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(); }); }); diff --git a/tests/unit/workflows-director.test.ts b/tests/unit/workflows-director.test.ts index 16bf8e464..ee848c989 100644 --- a/tests/unit/workflows-director.test.ts +++ b/tests/unit/workflows-director.test.ts @@ -107,9 +107,8 @@ test("the active step directive is injected into the inferred system prompt", as ); runtime.start(flow); const coordinator = new WorkflowCoordinator(runtime); - const director = createChatDirector("BASE PROMPT", [], { - workflowCoordinator: coordinator, - }); + const director = createChatDirector("BASE PROMPT", [], {}); + director.setWorkflowCoordinator(coordinator); const event: ReactorInboundEvent = { type: "message.received", @@ -144,9 +143,8 @@ test("a submit_output tool call with the current step id advances the runtime th ); runtime.start(flow); const coordinator = new WorkflowCoordinator(runtime); - const director = createChatDirector("BASE", [], { - workflowCoordinator: coordinator, - }); + const director = createChatDirector("BASE", [], {}); + director.setWorkflowCoordinator(coordinator); const caps = makeCapabilities(); const turn: ReactorInboundEvent = { @@ -185,9 +183,8 @@ test("a stale submit_output does not skip ahead through the director", async () ); runtime.start(flow); const coordinator = new WorkflowCoordinator(runtime); - const director = createChatDirector("BASE", [], { - workflowCoordinator: coordinator, - }); + const director = createChatDirector("BASE", [], {}); + director.setWorkflowCoordinator(coordinator); const caps = makeCapabilities(); const turn: ReactorInboundEvent = { @@ -275,9 +272,8 @@ test("auto-continuation fires on reply() as well as wait() after a text turn", a ); runtime.start(flow); const coordinator = new WorkflowCoordinator(runtime); - const director = createChatDirector("BASE", [], { - workflowCoordinator: coordinator, - }); + const director = createChatDirector("BASE", [], {}); + director.setWorkflowCoordinator(coordinator); const caps = makeCapabilities(); // Simulate a text-only inference turn (no tool calls). @@ -350,9 +346,8 @@ test("a content-free workflow turn with open tasks nudges toward submit_output", ); runtime.start(flow); const coordinator = new WorkflowCoordinator(runtime); - const director = createChatDirector("BASE", [], { - workflowCoordinator: coordinator, - }); + const director = createChatDirector("BASE", [], {}); + director.setWorkflowCoordinator(coordinator); const caps = makeCapabilities(); await director.decide(manageTasksTurn("doing"), state, caps); @@ -379,9 +374,8 @@ test("open tasks do not defeat the workflow stuck-cutoff after 3 idle turns", as ); runtime.start(flow); const coordinator = new WorkflowCoordinator(runtime); - const director = createChatDirector("BASE", [], { - workflowCoordinator: coordinator, - }); + const director = createChatDirector("BASE", [], {}); + director.setWorkflowCoordinator(coordinator); const caps = makeCapabilities(); await director.decide(manageTasksTurn("doing"), state, caps); @@ -403,9 +397,8 @@ test("auto-continuation falls back after 3 consecutive text-only turns", async ( ); runtime.start(flow); const coordinator = new WorkflowCoordinator(runtime); - const director = createChatDirector("BASE", [], { - workflowCoordinator: coordinator, - }); + const director = createChatDirector("BASE", [], {}); + director.setWorkflowCoordinator(coordinator); const caps = makeCapabilities(); await director.decide(textTurn("text 1"), state, caps); @@ -427,9 +420,8 @@ test("after spacer echo-cap a non-gate workflow step does not empty-settle", asy ); runtime.start(flow); const coordinator = new WorkflowCoordinator(runtime); - const director = createChatDirector("BASE", [], { - workflowCoordinator: coordinator, - }); + const director = createChatDirector("BASE", [], {}); + director.setWorkflowCoordinator(coordinator); const caps = makeCapabilities(); for (let i = 0; i < 2; i++) {