From eea0c158b220c8cd7ff33ddb6ea0fb1e00b80dfd Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 14 Sep 2026 14:03:09 -0700 Subject: [PATCH] fix(director): guard coordinator step id and empty directive --- src/agent/director.ts | 9 +++++- src/director.test.ts | 66 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/src/agent/director.ts b/src/agent/director.ts index 102a6c9a5..750d33b78 100644 --- a/src/agent/director.ts +++ b/src/agent/director.ts @@ -596,6 +596,7 @@ class ChatDirectorImpl extends DefaultDirector { 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]`; @@ -618,7 +619,13 @@ class ChatDirectorImpl extends DefaultDirector { private coordinatorCurrentStepId(): string | null { try { - return this.workflowCoordinator?.currentStepId() ?? null; + 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; diff --git a/src/director.test.ts b/src/director.test.ts index 43d612321..d34d19820 100644 --- a/src/director.test.ts +++ b/src/director.test.ts @@ -1574,6 +1574,72 @@ describe("CL-7919 coordinator shape", () => { 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", () => {