diff --git a/scripts/check-dead-exports.test.ts b/scripts/check-dead-exports.test.ts index 6d02b7594..348f4b53d 100644 --- a/scripts/check-dead-exports.test.ts +++ b/scripts/check-dead-exports.test.ts @@ -28,20 +28,12 @@ import { const repoRoot = join(import.meta.dir, ".."); // A probe dead export in one of the scoped files must fail the guard: the -// exact-name exemptions cover only the five deferred-cleanup flags, never -// the whole module. +// exact-name exemptions cover only the remaining deferred-cleanup flags +// (the usage-formatter flags were removed with their exports by the CL-6815 +// trim), never the whole module. describe("scoped exemptions", () => { test("the real allowlist covers the named flags but not a sibling probe", () => { const rules = loadAllowlist(); - expect( - isAllowlisted(rules, "src/auth/codex/usage.ts", "fetchCodexUsage"), - ).toBe(true); - expect( - isAllowlisted(rules, "src/auth/codex/usage.ts", "fetchCodexModels"), - ).toBe(true); - expect(isAllowlisted(rules, "src/auth/xai/usage.ts", "fetchXaiUsage")).toBe( - true, - ); expect( isAllowlisted( rules, diff --git a/scripts/dead-export-allowlist.txt b/scripts/dead-export-allowlist.txt index 2041c4f28..91221f98a 100644 --- a/scripts/dead-export-allowlist.txt +++ b/scripts/dead-export-allowlist.txt @@ -26,9 +26,6 @@ vendor/ # flags so a new dead export in these files fails the guard instead of # hiding under a whole-file exemption. Remove each entry with the export it # names when the owning cleanup lands. -src/auth/codex/usage.ts: fetchCodexUsage -src/auth/codex/usage.ts: fetchCodexModels -src/auth/xai/usage.ts: fetchXaiUsage src/auth/codex/constants.ts: CODEX_REFRESH_SKEW_MS src/auth/codex/constants.ts: CODEX_HEADLESS_REFRESH_INTERVAL_MS @@ -36,6 +33,14 @@ src/auth/codex/constants.ts: CODEX_HEADLESS_REFRESH_INTERVAL_MS # manifest by the plugin-registration tests, so it has no static importers. tests/fixtures/plugins/implement-feature/src/index.ts +# Eval completion-harness task fixtures (CL-7932 lane). Loaded by file path +# from each task's script.json by the harness runner, so they have no static +# importers. +evals/completion/tasks/decline-refactor/fixture/notes.ts: addNote +evals/completion/tasks/stall-read/fixture/data.ts: data +evals/completion/tasks/sum-fix/fixture/sum.ts: total +evals/completion/tasks/version-endpoint/fixture/service.ts: handleRequest + # ts-prune parser false positives: bare tokens on `as const satisfies ...` # lines inside live exports. None of these is an export declaration. src/provider/reasoning-effort.ts: satisfies diff --git a/src/agent/chat-event-subscribers.test.ts b/src/agent/chat-event-subscribers.test.ts new file mode 100644 index 000000000..96aa88213 --- /dev/null +++ b/src/agent/chat-event-subscribers.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, test } from "bun:test"; +import { + CHAT_TASKS_CHANGED_EVENT, + CHAT_TOOLS_ACTIVATE_EVENT, +} from "./director.js"; +import { handleChatDirectorEvent } from "./chat-event-subscribers.js"; +import type { Task } from "./tasks.js"; + +function makeLog() { + const calls: { message: string; fields?: Record }[] = []; + return { + calls, + log: (message: string, fields?: Record): void => { + calls.push(fields !== undefined ? { message, fields } : { message }); + }, + }; +} + +describe("handleChatDirectorEvent", () => { + test("dispatches a valid tasks-changed payload without logging", () => { + const seen: Task[][] = []; + const log = makeLog(); + const handled = handleChatDirectorEvent( + { + type: CHAT_TASKS_CHANGED_EVENT, + data: { tasks: [{ id: "t1", title: "work", status: "doing" }] }, + }, + { + onTasksChanged: (tasks) => seen.push(tasks), + onToolsActivate: () => { + throw new Error("unexpected tools-activate dispatch"); + }, + }, + log.log, + ); + expect(handled).toBe(true); + expect(seen).toEqual([[{ id: "t1", title: "work", status: "doing" }]]); + expect(log.calls).toEqual([]); + }); + + test("dispatches a valid tools-activate payload without logging", () => { + const seen: string[][] = []; + const log = makeLog(); + const handled = handleChatDirectorEvent( + { type: CHAT_TOOLS_ACTIVATE_EVENT, data: { names: ["lsp"] } }, + { + onTasksChanged: () => { + throw new Error("unexpected tasks-changed dispatch"); + }, + onToolsActivate: (names) => seen.push([...names]), + }, + log.log, + ); + expect(handled).toBe(true); + expect(seen).toEqual([["lsp"]]); + expect(log.calls).toEqual([]); + }); + + test("drops an invalid tasks payload with a debug log naming the failure", () => { + let dispatched = false; + const log = makeLog(); + const handled = handleChatDirectorEvent( + { type: CHAT_TASKS_CHANGED_EVENT, data: { tasks: "not-a-list" } }, + { + onTasksChanged: () => { + dispatched = true; + }, + onToolsActivate: () => { + dispatched = true; + }, + }, + log.log, + ); + expect(handled).toBe(true); + expect(dispatched).toBe(false); + expect(log.calls).toHaveLength(1); + expect(log.calls[0]?.message).toMatch(/tasks-changed/); + expect(typeof log.calls[0]?.fields?.["error"]).toBe("string"); + }); + + test("drops an invalid tools payload with a debug log naming the failure", () => { + let dispatched = false; + const log = makeLog(); + const handled = handleChatDirectorEvent( + { type: CHAT_TOOLS_ACTIVATE_EVENT, data: { names: [42] } }, + { + onTasksChanged: () => { + dispatched = true; + }, + onToolsActivate: () => { + dispatched = true; + }, + }, + log.log, + ); + expect(handled).toBe(true); + expect(dispatched).toBe(false); + expect(log.calls).toHaveLength(1); + expect(log.calls[0]?.message).toMatch(/tools-activate/); + expect(typeof log.calls[0]?.fields?.["error"]).toBe("string"); + }); + + test("ignores unrelated events without logging or dispatching", () => { + let dispatched = false; + const log = makeLog(); + const handled = handleChatDirectorEvent( + { type: "inference.done", data: {} }, + { + onTasksChanged: () => { + dispatched = true; + }, + onToolsActivate: () => { + dispatched = true; + }, + }, + log.log, + ); + expect(handled).toBe(false); + expect(dispatched).toBe(false); + expect(log.calls).toEqual([]); + }); +}); diff --git a/src/agent/chat-event-subscribers.ts b/src/agent/chat-event-subscribers.ts new file mode 100644 index 000000000..ffc8a98d0 --- /dev/null +++ b/src/agent/chat-event-subscribers.ts @@ -0,0 +1,62 @@ +/** + * Shared subscriber for the chat-director reactor events. The TUI and exec + * stream sinks both listen for the task-list and tool-activation events the + * chat director emits in place of the former host closures; the parse, + * validation, and invalid-payload handling live here so the two sinks cannot + * drift apart. + */ + +import { type } from "arktype"; +import { + CHAT_TASKS_CHANGED_EVENT, + CHAT_TOOLS_ACTIVATE_EVENT, + ChatTasksChangedDataSchema, + ChatToolsActivateDataSchema, +} from "./director.js"; +import type { Task } from "./tasks.js"; + +export interface ChatDirectorEventHandlers { + onTasksChanged: (tasks: Task[]) => void; + onToolsActivate: (names: string[]) => void; +} + +export type ChatDirectorEventDebugLog = ( + message: string, + fields?: Record, +) => void; + +/** + * Dispatch one stream event to the chat-director handlers. Returns true when + * the event is a chat-director event (valid or not) so sinks can fall through + * to their own handling otherwise. Invalid payloads are dropped after a + * debug-level log naming the failure — never silently. + */ +export function handleChatDirectorEvent( + event: { type: string; data: unknown }, + handlers: ChatDirectorEventHandlers, + logDebug: ChatDirectorEventDebugLog, +): boolean { + if (event.type === CHAT_TASKS_CHANGED_EVENT) { + const parsed = ChatTasksChangedDataSchema(event.data); + if (parsed instanceof type.errors) { + logDebug("chat tasks-changed event dropped invalid payload: {error}", { + error: parsed.summary, + }); + return true; + } + handlers.onTasksChanged(parsed.tasks); + return true; + } + if (event.type === CHAT_TOOLS_ACTIVATE_EVENT) { + const parsed = ChatToolsActivateDataSchema(event.data); + if (parsed instanceof type.errors) { + logDebug("chat tools-activate event dropped invalid payload: {error}", { + error: parsed.summary, + }); + return true; + } + handlers.onToolsActivate(parsed.names); + return true; + } + return false; +} diff --git a/src/agent/director.test.ts b/src/agent/director.test.ts index 364d19d4e..db57c7dca 100644 --- a/src/agent/director.test.ts +++ b/src/agent/director.test.ts @@ -5,7 +5,12 @@ import type { ReactorInboundEvent, ReactorState, } from "@intx/types/runtime"; -import { createChatDirector, toolSetDigest } from "./director.js"; +import { + CHAT_TASKS_CHANGED_EVENT, + createChatDirector, + toolSetDigest, +} from "./director.js"; +import type { WorkflowCoordinator } from "../workflows/coordinator.js"; const mockState: ReactorState = { turns: [] } as unknown as ReactorState; @@ -135,7 +140,6 @@ describe("ChatDirector tool-only loop protection", () => { test("nudges once at the family threshold, after pending tools execute", async () => { const director = createChatDirector("system", [], { - onTasksChange: () => undefined, provider: providerlessPolicy, }); const capabilities = makeCapabilities(); @@ -149,7 +153,6 @@ describe("ChatDirector tool-only loop protection", () => { test("the nudge is one-shot — it does not repeat on the next tool-only turn", async () => { const director = createChatDirector("system", [], { - onTasksChange: () => undefined, provider: providerlessPolicy, }); const capabilities = makeCapabilities(); @@ -168,7 +171,6 @@ describe("ChatDirector tool-only loop protection", () => { // well past any prior hard-pause threshold without ever pausing. test("a long productive tool-only streak continues without pausing", async () => { const director = createChatDirector("system", [], { - onTasksChange: () => undefined, provider: providerlessPolicy, }); const capabilities = makeCapabilities(); @@ -210,7 +212,6 @@ describe("ChatDirector inference-error recovery (CL-6910)", () => { "does not re-issue inference for a %s error already exhausted by the harness", async (category) => { const director = createChatDirector("system", [], { - onTasksChange: () => undefined, provider: providerlessPolicy, }); const capabilities = makeCapabilities(); @@ -232,7 +233,6 @@ describe("ChatDirector inference-error recovery (CL-6910)", () => { test("still recovers on internal-recovery abort, bounded by MAX_INFERENCE_RECOVERIES", async () => { const director = createChatDirector("system", [], { - onTasksChange: () => undefined, provider: providerlessPolicy, }); const capabilities = makeCapabilities(); @@ -262,7 +262,6 @@ describe("ChatDirector inference-error recovery (CL-6910)", () => { test("an unrelated aborted error (not internal-recovery) is not recovered by the director", async () => { const director = createChatDirector("system", [], { - onTasksChange: () => undefined, provider: providerlessPolicy, }); const capabilities = makeCapabilities(); @@ -279,7 +278,6 @@ describe("ChatDirector inference-error recovery (CL-6910)", () => { test("inference-recovery budget resets at the next turn boundary", async () => { const director = createChatDirector("system", [], { - onTasksChange: () => undefined, provider: providerlessPolicy, }); const capabilities = makeCapabilities(); @@ -323,7 +321,6 @@ describe("ChatDirector inference-error recovery (CL-6910)", () => { // bounded, not open-ended, and never reaches 9. test("worst case: director-owned recovery path issues at most 1 + MAX_INFERENCE_RECOVERIES infer calls", async () => { const director = createChatDirector("system", [], { - onTasksChange: () => undefined, provider: providerlessPolicy, }); const capabilities = makeCapabilities(); @@ -344,7 +341,6 @@ describe("ChatDirector inference-error recovery (CL-6910)", () => { test("timeout category produces the timeout preamble, not the fatal fallback", async () => { const director = createChatDirector("system", [], { - onTasksChange: () => undefined, provider: providerlessPolicy, }); const capabilities = makeCapabilities(); @@ -365,6 +361,72 @@ describe("ChatDirector inference-error recovery (CL-6910)", () => { "unrecoverable inference error", ); }); + + // A turn that throws after queueing task-change notifications must drop the + // queue instead of flushing it stale on the next turn. + test("a throwing turn drops queued task-change notifications", async () => { + const throwingCoordinator = { + isActive: () => true, + currentStepIsGate: () => true, + currentStepId: () => null, + directive: () => { + throw new Error("tool-listing exploded"); + }, + handleToolDone: () => false, + } as unknown as WorkflowCoordinator; + const director = createChatDirector("system", [], { + workflowCoordinator: throwingCoordinator, + }); + const capabilities = makeCapabilities(); + + const manageTasksTurn = { + type: "inference.done", + turn: { + role: "assistant", + model: "test", + timestamp: 0, + content: [ + { + type: "tool_call", + id: "manage-tasks", + name: "manage_tasks", + arguments: { + action: "create", + tasks: [{ id: "t1", title: "work", status: "doing" }], + }, + }, + ], + }, + usage: { input: 0, output: 0 }, + source: "test", + } as unknown as ReactorInboundEvent; + + await expect( + director.decide(manageTasksTurn, mockState, capabilities), + ).rejects.toThrow("tool-listing exploded"); + + director.setWorkflowCoordinator(undefined); + const textTurn = { + type: "inference.done", + turn: { + role: "assistant", + model: "test", + timestamp: 0, + content: [{ type: "text", text: "all set" }], + }, + usage: { input: 0, output: 0 }, + source: "test", + } as unknown as ReactorInboundEvent; + const actions = actionsArray( + await director.decide(textTurn, mockState, capabilities), + ); + const stale = actions.filter( + (a) => + a.type === "emit" && + (a as { eventType?: string }).eventType === CHAT_TASKS_CHANGED_EVENT, + ); + expect(stale).toEqual([]); + }); }); // CL-7973: the director's live source id (which stamps retry decisions so a @@ -438,7 +500,6 @@ async function isXaiStamped(policy: LiveRetryPolicy): Promise { describe("ChatDirector live source-id tracking (CL-7973)", () => { test("a sourceless or empty-string completion never wipes the learned id", async () => { const director = createChatDirector("system", [], { - onTasksChange: () => undefined, provider: { providerName: "test-provider" }, }); const capabilities = makeCapabilities(); @@ -474,7 +535,6 @@ describe("ChatDirector live source-id tracking (CL-7973)", () => { test("a cycle source remaps tracking on a non-inference event", async () => { const director = createChatDirector("system", [], { - onTasksChange: () => undefined, provider: { providerName: "test-provider" }, }); const capabilities = makeCapabilities(); @@ -493,7 +553,6 @@ describe("ChatDirector live source-id tracking (CL-7973)", () => { test("a drained fleet capitulates to the terminal action after the nudge budget", async () => { const director = createChatDirector("system", [], { - onTasksChange: () => undefined, provider: { providerName: "test-provider" }, }); director.restoreTasks([{ id: "t1", title: "keep going", status: "todo" }]); @@ -519,7 +578,6 @@ describe("ChatDirector live source-id tracking (CL-7973)", () => { test("a cycle source wins over a contradictory event source", async () => { const director = createChatDirector("system", [], { - onTasksChange: () => undefined, provider: { providerName: "test-provider" }, }); const capabilities = makeCapabilities(); diff --git a/src/agent/director.ts b/src/agent/director.ts index 02d761b30..9d62e0450 100644 --- a/src/agent/director.ts +++ b/src/agent/director.ts @@ -30,6 +30,7 @@ import { applyManageTasks, hasActiveTasks, parseManageTasksArgs, + TaskSchema, type Task, } from "./tasks.js"; import { createCorbitsRetryPolicy } from "./retry-policy.js"; @@ -412,7 +413,7 @@ function isCodeFile(path: string): boolean { // Returns null when the call is not manage_tasks or its arguments don't // parse, so callers can distinguish "no valid manage_tasks call here" from // "a valid call that happened to be a no-op" — the latter still counts as an -// update for onTasksChange purposes. +// update for tasks-changed event purposes. function applyManageTasksToolCall( tasks: Task[], block: { name: string; arguments: unknown }, @@ -422,15 +423,27 @@ function applyManageTasksToolCall( return taskArgs !== null ? applyManageTasks(tasks, taskArgs) : null; } +// Reactor events the chat director emits in place of host closures. Hosts +// (TUI, exec) subscribe on the agent stream: task-list changes replace the +// former onTasksChange callback, tool activation replaces onActivateTools. +// Neither namespace collides with the reactor's reserved prefixes +// (inference., tool., reactor., fork.). +export const CHAT_TASKS_CHANGED_EVENT = "custom.chat.tasks.changed"; +export const CHAT_TOOLS_ACTIVATE_EVENT = "custom.chat.tools.activate"; +export const ChatTasksChangedDataSchema = type({ + tasks: TaskSchema.array(), +}); +export const ChatToolsActivateDataSchema = type({ + names: "string[]", +}); + export interface ChatDirectorOptions { taskClassifier?: | ((message: string, metadata: SessionMetadata) => Promise) | undefined; - 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; /** @@ -486,7 +499,6 @@ 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; @@ -503,7 +515,6 @@ class ChatDirectorImpl extends DefaultDirector { private lastInferenceTurnHadContent = false; private operatorJustResponded = false; private tasks: Task[] = []; - private readonly onTasksChange: ((tasks: Task[]) => void) | undefined; private turnCount = 0; private currentTaskLabel: string | undefined; private lastTaskSummary: string | undefined; @@ -528,6 +539,11 @@ class ChatDirectorImpl extends DefaultDirector { private toolOnlyStreak = 0; private toolOnlyNudgeFired = false; private pendingToolOnlyNudge = false; + // Reactor events queued while scanning the current inbound event. Drained + // in decide() and appended to whatever the turn returns, so task/tool + // notifications ride along with every terminal action list (emit is + // composable with all other actions). + private pendingEmits: ReactorAction[] = []; constructor( systemPrompt: string, @@ -555,9 +571,7 @@ class ChatDirectorImpl extends DefaultDirector { 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, composedPrompt, @@ -601,10 +615,11 @@ class ChatDirectorImpl extends DefaultDirector { // A resumed session's task list lives in the transcript, not in the freshly // constructed director. Without this the chrome panel would read an empty // list until the model happened to call manage_tasks again, disagreeing - // with the task block already painted in the transcript. + // with the task block already painted in the transcript. The host emits + // the tasks-changed reactor event after calling this (the director cannot + // emit outside decide()). restoreTasks(tasks: Task[]): void { this.tasks = [...tasks]; - this.onTasksChange?.(this.tasks); } // The status bar's context meter falls back to this when a provider omits @@ -709,11 +724,26 @@ class ChatDirectorImpl extends DefaultDirector { state: ReactorState, capabilities: ReactorCapabilities, ): Promise { - const settled = ensureCycleSettlesWithReply( - await this.decideInner(event, state, capabilities), - capabilities, - ); - return this.withCurrentTools(settled); + try { + const settled = ensureCycleSettlesWithReply( + await this.decideInner(event, state, capabilities), + capabilities, + ); + const withTools = this.withCurrentTools(settled); + if (this.pendingEmits.length === 0) return withTools; + const emits = this.pendingEmits; + this.pendingEmits = []; + return [ + ...(Array.isArray(withTools) ? withTools : [withTools]), + ...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 = []; + throw err; + } } private async decideInner( @@ -909,7 +939,11 @@ class ChatDirectorImpl extends DefaultDirector { const next = applyManageTasksToolCall(this.tasks, block); if (next !== null) { this.tasks = next; - this.onTasksChange?.(this.tasks); + this.pendingEmits.push( + capabilities.emit(CHAT_TASKS_CHANGED_EVENT, { + tasks: this.tasks, + }), + ); } } else if (block.name === "read_file" || block.name === "edit_file") { const pathResult = PathArgSchema(block.arguments); @@ -957,7 +991,10 @@ class ChatDirectorImpl extends DefaultDirector { this.lspTriggerCalls.has(event.result.callId) ) { this.lspTriggerCalls.delete(event.result.callId); - if (!event.result.isError) this.onActivateTools?.(["lsp"]); + if (!event.result.isError) + this.pendingEmits.push( + capabilities.emit(CHAT_TOOLS_ACTIVATE_EVENT, { names: ["lsp"] }), + ); } if (event.type === "tool.done") { diff --git a/src/director.test.ts b/src/director.test.ts index e17faac8b..b2b78c360 100644 --- a/src/director.test.ts +++ b/src/director.test.ts @@ -1,5 +1,10 @@ import { describe, test, expect } from "bun:test"; -import { createChatDirector, askOperatorDefinition } from "./agent/director.js"; +import { + createChatDirector, + askOperatorDefinition, + CHAT_TASKS_CHANGED_EVENT, + CHAT_TOOLS_ACTIVATE_EVENT, +} from "./agent/director.js"; import { createAgentToolset } from "./agent/tools.js"; import { createAdvertisedToolset } from "./session/assemble-runtime.js"; import { createPermissionGate } from "./permission/gate.js"; @@ -129,9 +134,7 @@ describe("operator declined tool calls", () => { // reactor and break further sends, and it does not re-infer off a bare // decline. test("chat director surfaces the decline and waits, keeping the reactor alive", async () => { - const director = createChatDirector("", [], { - onTasksChange: () => undefined, - }); + const director = createChatDirector("", [], {}); const actions = actionsArray( await director.decide( makeToolErrorEvent("c", declined), @@ -149,9 +152,7 @@ describe("operator declined tool calls", () => { // Reactor path: a reason-bearing approver rejection must re-infer so the // model can respond to the reason — never the canned decline. test("reason-bearing approver rejection re-infers on the reason", async () => { - const director = createChatDirector("", [], { - onTasksChange: () => undefined, - }); + const director = createChatDirector("", [], {}); const actions = actionsArray( await director.decide( makeToolErrorEvent("c", "denied by approver: never touch /etc"), @@ -165,9 +166,7 @@ describe("operator declined tool calls", () => { }); test("middleware rejection with a reason re-infers on the reason", async () => { - const director = createChatDirector("", [], { - onTasksChange: () => undefined, - }); + const director = createChatDirector("", [], {}); const actions = actionsArray( await director.decide( makeToolErrorEvent( @@ -186,9 +185,7 @@ describe("operator declined tool calls", () => { // Reactor path: a reason-less approver rejection has nothing for the model // to respond to; the canned reply stands. test("reason-less approver rejection takes the canned path", async () => { - const director = createChatDirector("", [], { - onTasksChange: () => undefined, - }); + const director = createChatDirector("", [], {}); const actions = actionsArray( await director.decide( makeToolErrorEvent("c", "denied by approver"), @@ -204,9 +201,7 @@ describe("operator declined tool calls", () => { // Policy denies and no-grant blocks are not operator decisions: the model // adapts to the deny text like any tool error. test("policy deny is not classified as an operator decline", async () => { - const director = createChatDirector("", [], { - onTasksChange: () => undefined, - }); + const director = createChatDirector("", [], {}); for (const content of [ "Denied by policy: tool:run_shell/invoke", "No matching grants for tool:run_shell/invoke", @@ -260,9 +255,7 @@ describe("open-task termination guard", () => { a.some((x) => x.type === "reply"); test("re-infers instead of ending the turn while a task is still open", async () => { - const director = createChatDirector("base", [], { - onTasksChange: () => undefined, - }); + const director = createChatDirector("base", [], {}); await director.decide( manageTasksEvent("doing"), mockState, @@ -277,9 +270,7 @@ describe("open-task termination guard", () => { }); test("ends the turn normally once every task is terminal", async () => { - const director = createChatDirector("base", [], { - onTasksChange: () => undefined, - }); + const director = createChatDirector("base", [], {}); await director.decide( manageTasksEvent("done"), mockState, @@ -293,10 +284,32 @@ describe("open-task termination guard", () => { expect(hasInfer(actions)).toBe(false); }); + test("a task change emits the updated task list on the chat event", async () => { + const director = createChatDirector("base", [], {}); + const actions = actionsArray( + await director.decide( + manageTasksEvent("doing"), + mockState, + mockCapabilities, + ), + ); + expect( + actions.filter( + (a) => + a.type === "emit" && + (a as { eventType?: string }).eventType === CHAT_TASKS_CHANGED_EVENT, + ), + ).toEqual([ + { + type: "emit", + eventType: CHAT_TASKS_CHANGED_EVENT, + data: { tasks: [{ id: "t1", title: "work", status: "doing" }] }, + }, + ]); + }); + test("stops nudging and lets the turn end after the cap of content-free attempts", async () => { - const director = createChatDirector("base", [], { - onTasksChange: () => undefined, - }); + const director = createChatDirector("base", [], {}); await director.decide( manageTasksEvent("doing"), mockState, @@ -318,7 +331,6 @@ describe("open-task termination guard", () => { test("idle-with-fleet allows terminal wait/reply with open tasks and spends no nudge budget", async () => { const director = createChatDirector("base", [], { - onTasksChange: () => undefined, allowIdleWithFleet: true, }); await director.decide( @@ -338,7 +350,6 @@ describe("open-task termination guard", () => { test("mid-session source switch remaps retry stamping without host closures", async () => { const director = createChatDirector("base", [], { - onTasksChange: () => undefined, provider: { providerName: "openai" }, }); await director.decide( @@ -408,9 +419,7 @@ describe("open-task termination guard", () => { }); test("omitted or false idle-with-fleet still nudges while a task is open", async () => { - const omitted = createChatDirector("base", [], { - onTasksChange: () => undefined, - }); + const omitted = createChatDirector("base", [], {}); await omitted.decide( manageTasksEvent("doing"), mockState, @@ -425,7 +434,6 @@ describe("open-task termination guard", () => { ).toBe(true); const disabled = createChatDirector("base", [], { - onTasksChange: () => undefined, allowIdleWithFleet: false, }); await disabled.decide( @@ -444,7 +452,6 @@ describe("open-task termination guard", () => { test("setAllowIdleWithFleet tracks fleet transitions off the seeded value", async () => { const director = createChatDirector("base", [], { - onTasksChange: () => undefined, allowIdleWithFleet: true, }); await director.decide( @@ -480,9 +487,7 @@ describe("open-task termination guard", () => { test("empty model turn settles with a valid empty reply", async () => { // DefaultDirector ends empty responses with bare wait; without a reply, // agent.send hangs and the TUI Working spinner sticks forever. - const director = createChatDirector("base", [], { - onTasksChange: () => undefined, - }); + const director = createChatDirector("base", [], {}); const emptyTurn = { type: "inference.done", turn: { role: "assistant", model: "test", timestamp: 0, content: [] }, @@ -509,9 +514,7 @@ describe("open-task termination guard", () => { }); test("a declined tool with open tasks re-infers, then terminates after its cap", async () => { - const director = createChatDirector("base", [], { - onTasksChange: () => undefined, - }); + const director = createChatDirector("base", [], {}); await director.decide( manageTasksEvent("doing"), mockState, @@ -553,9 +556,7 @@ describe("open-task termination guard", () => { // single user turn — the budget is monotonic per inbound message, not per // tool call, so it does not matter whether a tool call happens at all. test("a no-op tool call between nudges does not reset the idle budget", async () => { - const director = createChatDirector("base", [], { - onTasksChange: () => undefined, - }); + const director = createChatDirector("base", [], {}); await director.decide( manageTasksEvent("doing"), mockState, @@ -601,9 +602,7 @@ describe("open-task termination guard", () => { }); test("a new user message resets the idle budget for the next turn", async () => { - const director = createChatDirector("base", [], { - onTasksChange: () => undefined, - }); + const director = createChatDirector("base", [], {}); await director.decide( manageTasksEvent("doing"), mockState, @@ -640,9 +639,7 @@ describe("open-task termination guard", () => { }); test("a successful tool call between declines does not reset the declined budget", async () => { - const director = createChatDirector("base", [], { - onTasksChange: () => undefined, - }); + const director = createChatDirector("base", [], {}); await director.decide( manageTasksEvent("doing"), mockState, @@ -696,9 +693,7 @@ describe("open-task termination guard", () => { }); test("a declined tool with no open tasks surfaces the decline immediately", async () => { - const director = createChatDirector("base", [], { - onTasksChange: () => undefined, - }); + const director = createChatDirector("base", [], {}); const actions = actionsArray( await director.decide( makeToolErrorEvent("c", declined), @@ -749,7 +744,6 @@ describe("chatDirector compaction", () => { test("schedules idle compaction after an over-threshold text-only reply", async () => { let continuations = 0; const director = createChatDirector("", [], { - onTasksChange: () => undefined, requestContinuation: () => { continuations++; }, @@ -795,7 +789,6 @@ describe("chatDirector compaction", () => { test("idle empty compact makes the post-compact estimate authoritative without inferring", async () => { const director = createChatDirector("", [], { - onTasksChange: () => undefined, requestContinuation: () => undefined, }); const largeTurns = Array.from( @@ -888,7 +881,6 @@ describe("chatDirector compaction", () => { onContinuation?: () => void, ) { return createChatDirector(systemPrompt, [], { - onTasksChange: () => undefined, requestContinuation: onContinuation ?? (() => undefined), }); } @@ -1065,12 +1057,17 @@ describe("chatDirector compaction", () => { }); describe("chatDirector LSP auto-activation", () => { + const activateEmits = ( + actions: ReactorAction | ReactorAction[], + ): ReactorAction[] => + actionsArray(actions).filter( + (a) => + a.type === "emit" && + (a as { eventType?: string }).eventType === CHAT_TOOLS_ACTIVATE_EVENT, + ); + test("reading a code file activates the lsp tool on success", async () => { - const activated: string[][] = []; - const director = createChatDirector("", [], { - onTasksChange: () => undefined, - onActivateTools: (names: string[]) => activated.push(names), - }); + const director = createChatDirector("", [], {}); await director.decide( makeInferenceDoneEvent([ { id: "c", name: "read_file", args: { path: "src/foo.ts" } }, @@ -1078,16 +1075,22 @@ describe("chatDirector LSP auto-activation", () => { mockState, mockCapabilities, ); - await director.decide(makeToolDoneEvent("c"), mockState, mockCapabilities); - expect(activated).toEqual([["lsp"]]); + const actions = await director.decide( + makeToolDoneEvent("c"), + mockState, + mockCapabilities, + ); + expect(activateEmits(actions)).toEqual([ + { + type: "emit", + eventType: CHAT_TOOLS_ACTIVATE_EVENT, + data: { names: ["lsp"] }, + }, + ]); }); test("editing a code file activates lsp", async () => { - const activated: string[][] = []; - const director = createChatDirector("", [], { - onTasksChange: () => undefined, - onActivateTools: (names: string[]) => activated.push(names), - }); + const director = createChatDirector("", [], {}); await director.decide( makeInferenceDoneEvent([ { id: "c", name: "edit_file", args: { path: "lib/bar.rs" } }, @@ -1095,16 +1098,22 @@ describe("chatDirector LSP auto-activation", () => { mockState, mockCapabilities, ); - await director.decide(makeToolDoneEvent("c"), mockState, mockCapabilities); - expect(activated).toEqual([["lsp"]]); + const actions = await director.decide( + makeToolDoneEvent("c"), + mockState, + mockCapabilities, + ); + expect(activateEmits(actions)).toEqual([ + { + type: "emit", + eventType: CHAT_TOOLS_ACTIVATE_EVENT, + data: { names: ["lsp"] }, + }, + ]); }); test("a non-code file does not activate lsp", async () => { - const activated: string[][] = []; - const director = createChatDirector("", [], { - onTasksChange: () => undefined, - onActivateTools: (names: string[]) => activated.push(names), - }); + const director = createChatDirector("", [], {}); await director.decide( makeInferenceDoneEvent([ { id: "c", name: "read_file", args: { path: "README.md" } }, @@ -1112,16 +1121,16 @@ describe("chatDirector LSP auto-activation", () => { mockState, mockCapabilities, ); - await director.decide(makeToolDoneEvent("c"), mockState, mockCapabilities); - expect(activated).toEqual([]); + const actions = await director.decide( + makeToolDoneEvent("c"), + mockState, + mockCapabilities, + ); + expect(activateEmits(actions)).toEqual([]); }); test("a failed read does not activate lsp", async () => { - const activated: string[][] = []; - const director = createChatDirector("", [], { - onTasksChange: () => undefined, - onActivateTools: (names: string[]) => activated.push(names), - }); + const director = createChatDirector("", [], {}); await director.decide( makeInferenceDoneEvent([ { id: "c", name: "read_file", args: { path: "src/foo.ts" } }, @@ -1129,12 +1138,12 @@ describe("chatDirector LSP auto-activation", () => { mockState, mockCapabilities, ); - await director.decide( + const actions = await director.decide( makeToolErrorEvent("c", "Error: not found"), mockState, mockCapabilities, ); - expect(activated).toEqual([]); + expect(activateEmits(actions)).toEqual([]); }); }); @@ -1184,9 +1193,7 @@ describe("updateToolDefinitions rewrites infer tools", () => { }; test("a tool registered after construction is advertised on the next inference", async () => { - const director = createChatDirector("base-prompt", [], { - onTasksChange: () => undefined, - }); + const director = createChatDirector("base-prompt", [], {}); director.updateToolDefinitions([lateTool]); const result = await director.decide( @@ -1205,9 +1212,7 @@ describe("updateToolDefinitions rewrites infer tools", () => { // The provider cache is a prefix cache keyed on the tools array; a tool_search // between turns must not reshape it. test("wire tools are byte-identical across a turn that ran tool_search", async () => { - const director = createChatDirector("base-prompt", [lateTool], { - onTasksChange: () => undefined, - }); + const director = createChatDirector("base-prompt", [lateTool], {}); const before = await firstInferTools( director, @@ -1271,7 +1276,7 @@ describe("updateToolDefinitions rewrites infer tools", () => { const director = createChatDirector( "base-prompt", advertised.computeAdvertised(toolset.dynamicRunner.currentDefinitions()), - { onTasksChange: () => undefined }, + {}, ); const before = await firstInferTools( @@ -1327,9 +1332,7 @@ describe("updateToolDefinitions rewrites infer tools", () => { // submit_output is always on the wire so a workflow going active never grows // the array and busts the provider cache prefix. test("submit_output is advertised even with no active workflow", async () => { - const director = createChatDirector("base-prompt", [], { - onTasksChange: () => undefined, - }); + const director = createChatDirector("base-prompt", [], {}); director.updateToolDefinitions([lateTool]); const result = await director.decide( @@ -1381,7 +1384,7 @@ describe("updateToolDefinitions rewrites infer tools", () => { const director = createChatDirector( "base-prompt", computeAdvertised(toolset.dynamicRunner.currentDefinitions()), - { onTasksChange: () => undefined }, + {}, ); // Before discovery: the MCP tool is registered (dispatchable) but not wired. @@ -1452,7 +1455,6 @@ describe("updateToolDefinitions rewrites infer tools", () => { const classifier = async (_msg: string, _meta: SessionMetadata) => ({ kind: "new_task" as const, reason: "pivot" }) as TaskBoundary; const director = createChatDirector("base-prompt", [], { - onTasksChange: () => undefined, taskClassifier: classifier, }); director.updateToolDefinitions([lateTool]); @@ -1634,9 +1636,7 @@ describe("transient nudges", () => { }) as unknown as ReactorInboundEvent; test("open-task nudge uses ephemeralTurns and keeps the stable system prompt", async () => { - const director = createChatDirector("stable-base", [], { - onTasksChange: () => undefined, - }); + const director = createChatDirector("stable-base", [], {}); await director.decide( manageTasksEvent("doing"), mockState, @@ -1702,9 +1702,7 @@ describe("chatDirector spacer echo", () => { test("spacer-only assistant reply is not a finished turn", async () => { for (const text of [LEGACY_COMPACT_SPACER_TEXT, COMPACT_SPACER_TEXT]) { - const director = createChatDirector("base", [], { - onTasksChange: () => undefined, - }); + const director = createChatDirector("base", [], {}); const actions = actionsArray( await director.decide( spacerInferenceDone(text), @@ -1724,7 +1722,6 @@ describe("chatDirector spacer echo", () => { test("spacer-echo does not arm idle compact, including after the nudge cap", async () => { let continuations = 0; const director = createChatDirector("base", [], { - onTasksChange: () => undefined, requestContinuation: () => { continuations++; }, @@ -1757,9 +1754,7 @@ describe("chatDirector spacer echo", () => { }); test("echo-nudge cap is two then empty settle, and resets on message.received", async () => { - const director = createChatDirector("base", [], { - onTasksChange: () => undefined, - }); + const director = createChatDirector("base", [], {}); for (let i = 0; i < 2; i++) { const nudged = actionsArray( await director.decide( @@ -1801,9 +1796,7 @@ describe("chatDirector spacer echo", () => { }); test("after echo-cap with open tasks, falls through to open-task rails", async () => { - const director = createChatDirector("base", [], { - onTasksChange: () => undefined, - }); + const director = createChatDirector("base", [], {}); await director.decide( makeInferenceDoneEvent([ { @@ -1870,7 +1863,6 @@ describe("chatDirector spacer echo", () => { describe("tool-discipline rules on the wire", () => { async function promptSentFor(model: string): Promise { const director = createChatDirector("BASE PROMPT", [], { - onTasksChange: () => undefined, provider: { providerName: "opencode-go", model }, }); const event = { diff --git a/src/exec/runner.ts b/src/exec/runner.ts index b1dc3e0e5..1196a1594 100644 --- a/src/exec/runner.ts +++ b/src/exec/runner.ts @@ -22,6 +22,7 @@ import { formatDirectorSystemPrompt } from "../agent/directors/identity.js"; import { DIRECTOR_REGISTRY } from "../agent/directors/registry.js"; import type { DirectorId, DirectorPackage } from "../agent/directors/types.js"; import { submitOutputDefinition } from "../agent/director.js"; +import { handleChatDirectorEvent } from "../agent/chat-event-subscribers.js"; import { shellDefinition, updatePlanDefinition, @@ -842,17 +843,8 @@ export async function runExec(config: Config): Promise { systemPrompt, getDynamicRunner: () => agentToolset.dynamicRunner, computeAdvertised, - activateTools: (names) => activatedToolNames.activate(names), inactivityTimeoutMs: config.inactivityTimeoutMs ?? 750_000, totalTimeoutMs: config.totalTimeoutMs, - // Exec mode has no live task panel or task stdout output today (unlike - // the TUI's chrome zone) — debug logging is the closest match to how - // this mode already surfaces other in-session state changes. - onTasksChange: (tasks) => { - logger.debug("tasks updated: {tasks}", { - tasks: tasks.map((t) => `${t.status}:${t.title}`).join(", "), - }); - }, requestContinuation: () => { // Compaction governor self-delivers after compact so the loop re-enters. currentAgent?.deliver(buildCompactionContinuationMessage()); @@ -973,6 +965,23 @@ export async function runExec(config: Config): Promise { // keeps the in-flight cycle's text so an errored or aborted turn leaves // its partial output in partial.jsonl instead of vanishing. const sink = (event: ReactorEmittedEvent): void => { + // Chat-director reactor events (replacing the former onTasksChange / + // onActivateTools closures). Exec mode has no live task panel or task + // stdout output today (unlike the TUI's chrome zone) — debug logging + // is the closest match to how this mode already surfaces other + // in-session state changes. + handleChatDirectorEvent( + event, + { + onTasksChanged: (tasks) => { + logger.debug("tasks updated: {tasks}", { + tasks: tasks.map((t) => `${t.status}:${t.title}`).join(", "), + }); + }, + onToolsActivate: (names) => activatedToolNames.activate(names), + }, + (message, fields) => logger.debug(message, fields), + ); if (event.type === "inference.start" || event.type === "inference.done") { providerFailureObserved = false; providerError = undefined; diff --git a/src/permission/classify.ts b/src/permission/classify.ts index 9e2f95ba9..3a26ac464 100644 --- a/src/permission/classify.ts +++ b/src/permission/classify.ts @@ -32,7 +32,8 @@ import { AUTO_ALLOW_READ_TOOLS as READ_ONLY_TOOLS } from "../agent/tool-classifi // Read-only tools never need approval as long as they don't touch a restricted // path; they cannot change the workspace. `lsp` is included here even though -// it is activated dynamically mid-session (see director.ts onActivateTools) — +// it is activated dynamically mid-session (see CHAT_TOOLS_ACTIVATE_EVENT in +// director.ts) — // hover/definition/reference lookups are as inert as a grep. `manage_tasks` is // included for a related but distinct reason: its handler (src/agent/tools.ts) // has no side effect of its own — the task list is mutated earlier, by the diff --git a/src/prompts.test.ts b/src/prompts.test.ts index 9b79286d5..73f9d2e74 100644 --- a/src/prompts.test.ts +++ b/src/prompts.test.ts @@ -26,9 +26,7 @@ const minimalToolDefinitions = [manageTasksDefinition, submitOutputDefinition]; test("buildChatSystemPrompt wires into createChatDirector without error", () => { const prompt = buildChatSystemPrompt(); expect(() => - createChatDirector(prompt, minimalToolDefinitions, { - onTasksChange: () => undefined, - }), + createChatDirector(prompt, minimalToolDefinitions, {}), ).not.toThrow(); }); diff --git a/src/session/assemble-runtime.test.ts b/src/session/assemble-runtime.test.ts index 8b96b87df..f805e97b2 100644 --- a/src/session/assemble-runtime.test.ts +++ b/src/session/assemble-runtime.test.ts @@ -237,9 +237,7 @@ function stubChatAgentWiring( ); }, computeAdvertised: () => [], - activateTools: () => false, inactivityTimeoutMs: 1_000, - onTasksChange: () => undefined, requestContinuation: () => undefined, getProvider: () => ({ providerName: "test", model: "m" }), getWorkdir: () => "/build-dir", diff --git a/src/session/assemble-runtime.ts b/src/session/assemble-runtime.ts index f7cdb1f68..25fcde2a8 100644 --- a/src/session/assemble-runtime.ts +++ b/src/session/assemble-runtime.ts @@ -52,7 +52,6 @@ import { normalizeToolDefinitionsForProvider } from "../agent/tool-schema-normal import { resolveModelFamilyPolicy } from "../agent/model-family-policy.js"; import { createChatDirector, type ChatDirector } from "../agent/director.js"; import { createDoomLoopCorrectiveNote } from "../agent/doom-loop-note.js"; -import type { Task } from "../agent/tasks.js"; import type { AgentToolset } from "../agent/tools.js"; import { createAgentWithLiveToolDispatch } from "../agent/live-tool-dispatch.js"; import { createSessionStores } from "./optimized-context-store.js"; @@ -469,10 +468,8 @@ export interface ChatAgentWiring { systemPrompt: string; getDynamicRunner: () => AgentToolset["dynamicRunner"]; computeAdvertised: (all: readonly ToolDefinition[]) => ToolDefinition[]; - activateTools: (names: readonly string[]) => boolean; inactivityTimeoutMs: number; totalTimeoutMs?: number | undefined; - onTasksChange: (tasks: Task[]) => void; /** * Seed for the idle-with-fleet allowance for the chat director (CL-7918: * replaces the former getLiveFleetCount closure; CL-7972 keeps it live via @@ -530,17 +527,8 @@ export function assembleChatAgent(wiring: ChatAgentWiring): AssembledChatAgent { agentCtx.systemPrompt, wiring.computeAdvertised([...agentCtx.toolDefinitions]), { - onActivateTools: (names) => { - // Gate-only: activation lets the model invoke the tool from the - // tool_search result card's schema at once. The schema itself - // stays off the wire array until flushPromotions commits it at a - // cache-safe boundary, so mid-session promotion never reshapes - // the provider's cached prefix (CL-7868). - wiring.activateTools(names); - }, inactivityTimeoutMs: wiring.inactivityTimeoutMs, totalTimeoutMs: wiring.totalTimeoutMs, - onTasksChange: wiring.onTasksChange, requestContinuation: wiring.requestContinuation, provider: { ...wiring.getProvider() }, // CL-7918: idle-with-fleet seed replaces the former diff --git a/src/tui/runner-host.test.ts b/src/tui/runner-host.test.ts index ff483d651..34a5461c0 100644 --- a/src/tui/runner-host.test.ts +++ b/src/tui/runner-host.test.ts @@ -257,7 +257,7 @@ describe("mountRunnerHost chrome wiring", () => { expect(host.shell.taskBox.visible).toBe(false); expect(notify).toBeDefined(); - // Mirrors createChatDirector's onTasksChange: live source changes, then + // Mirrors the chat tasks-changed event path: live source changes, then // the runner notifies the host. formatChromeZones parks the checklist. liveTasks = [{ title: "wire task panel", status: "doing" }]; notify?.(); diff --git a/src/tui/runner/exit.test.ts b/src/tui/runner/exit.test.ts index 00c3d2c0f..ca173e788 100644 --- a/src/tui/runner/exit.test.ts +++ b/src/tui/runner/exit.test.ts @@ -347,7 +347,6 @@ describe("rebuild re-syncs idle-with-fleet while drained", () => { // Every rebuild mints a fresh director from the static true seed (fleet // lanes may appear mid-session), exactly like the TUI session assembly. directorHolder.instance = createChatDirector("base", [], { - onTasksChange: () => undefined, allowIdleWithFleet: true, }); return agent; diff --git a/src/tui/runner/exit.ts b/src/tui/runner/exit.ts index a313d5e45..2a2bca448 100644 --- a/src/tui/runner/exit.ts +++ b/src/tui/runner/exit.ts @@ -59,6 +59,7 @@ import { type SnapshotKind, type SnapshotStatus, } from "./state.js"; +import { handleChatDirectorEvent } from "../../agent/chat-event-subscribers.js"; const tuiLogger = getLogger([LOG_NAMESPACE_ROOT, "tui"]); @@ -312,6 +313,17 @@ export async function createRunLifecycle( } else if (event.type === "message.run.ended") { providerFailureAttempts.consumeTerminal(); } + // Chat-director reactor events (replacing the former onTasksChange / + // onActivateTools closures): task-list changes repaint the chrome panel, + // tool activation opens the call gate for the named tools. + handleChatDirectorEvent( + event, + { + onTasksChanged: (tasks) => services.emitter.emit("tasks", tasks), + onToolsActivate: (names) => services.activatedToolNames.activate(names), + }, + (message, fields) => tuiLogger.debug(message, fields), + ); services.runSink.sink(eventForSink); services.cycleRecorder.handleEvent(event); if (onTurnBoundary(event)) { diff --git a/src/tui/runner/session.ts b/src/tui/runner/session.ts index a18f010f4..745c1400d 100644 --- a/src/tui/runner/session.ts +++ b/src/tui/runner/session.ts @@ -611,13 +611,14 @@ export async function assembleTUISession( systemPrompt, getDynamicRunner: () => toolset.dynamicRunner, computeAdvertised, - activateTools: (names) => activatedToolNames.activate(names), inactivityTimeoutMs: config.inactivityTimeoutMs ?? 750_000, totalTimeoutMs: config.totalTimeoutMs, - onTasksChange: (tasks) => emitter.emit("tasks", tasks), // CL-7918 seed for the idle-with-fleet allowance (fleet lanes may appear // mid-session; CL-7972 keeps it live via the fleet-wake publisher); // retry stamping tracks the live source id in-reactor now. + // (No onTasksChange: task/tool updates arrive as reactor events consumed + // in the stream sink; no getLiveFleetCount: the publisher drives the + // allowance through setAllowIdleWithFleet.) allowIdleWithFleet: true, requestContinuation: () => { const targetAgent = liveAgent(state); diff --git a/src/tui/runner/wiring.ts b/src/tui/runner/wiring.ts index d322b3ecb..5c276169f 100644 --- a/src/tui/runner/wiring.ts +++ b/src/tui/runner/wiring.ts @@ -433,8 +433,12 @@ export function wirePostStartup( // Restored tasks go to the panel only. They are live state, not something // that happened in the conversation, so putting them in scrollback as well // renders the same list twice on one screen. - if (tasks.length > 0) + if (tasks.length > 0) { + // The director holds the restored list; the host owns the panel, so + // it announces the same list the tasks-changed event would carry. services.directorHolder.instance?.restoreTasks(tasks); + services.emitter.emit("tasks", tasks); + } if (blocks.length > 0) services.emitter.emit("history.hydrate", blocks); }) .catch((err: unknown) => { diff --git a/tests/integration/harness.ts b/tests/integration/harness.ts index 868f5494b..381f3adcf 100644 --- a/tests/integration/harness.ts +++ b/tests/integration/harness.ts @@ -128,7 +128,6 @@ export async function openIntegrationSession( configSchema: type({}), factory: (_config, _env, agentCtx) => createChatDirector(agentCtx.systemPrompt, [...agentCtx.toolDefinitions], { - onTasksChange: () => undefined, inactivityTimeoutMs: 750_000, ...(opts.compactionCompletion !== undefined ? { diff --git a/tests/unit/director.test.ts b/tests/unit/director.test.ts index 46e7245b9..bd422c2f2 100644 --- a/tests/unit/director.test.ts +++ b/tests/unit/director.test.ts @@ -143,7 +143,6 @@ const manyTurnsState: ReactorState = { function makeChatDirectorWithContinuation(onContinue: () => void) { return createChatDirector("sys", [], { - onTasksChange: () => undefined, requestContinuation: onContinue, }); } @@ -270,7 +269,6 @@ async function runToolOnlyStreak( test("a grok provider no longer pauses a 10-turn productive tool-only streak", async () => { const grokDirector = createChatDirector("sys", [], { - onTasksChange: () => undefined, provider: { providerName: "xai", model: "grok-4" }, }); const grokActions = await runToolOnlyStreak(grokDirector, 10); @@ -281,7 +279,6 @@ test("a grok provider no longer pauses a 10-turn productive tool-only streak", a ).toBe(false); const defaultDirector = createChatDirector("sys", [], { - onTasksChange: () => undefined, provider: { providerName: "openai", model: "gpt-4" }, }); const defaultActions = await runToolOnlyStreak(defaultDirector, 10); diff --git a/tests/unit/workflows-director.test.ts b/tests/unit/workflows-director.test.ts index ea44b40a8..16bf8e464 100644 --- a/tests/unit/workflows-director.test.ts +++ b/tests/unit/workflows-director.test.ts @@ -108,7 +108,6 @@ 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", [], { - onTasksChange: () => undefined, workflowCoordinator: coordinator, }); @@ -146,7 +145,6 @@ 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", [], { - onTasksChange: () => undefined, workflowCoordinator: coordinator, }); const caps = makeCapabilities(); @@ -188,7 +186,6 @@ 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", [], { - onTasksChange: () => undefined, workflowCoordinator: coordinator, }); const caps = makeCapabilities(); @@ -279,7 +276,6 @@ 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", [], { - onTasksChange: () => undefined, workflowCoordinator: coordinator, }); const caps = makeCapabilities(); @@ -355,7 +351,6 @@ 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", [], { - onTasksChange: () => undefined, workflowCoordinator: coordinator, }); const caps = makeCapabilities(); @@ -385,7 +380,6 @@ 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", [], { - onTasksChange: () => undefined, workflowCoordinator: coordinator, }); const caps = makeCapabilities(); @@ -410,7 +404,6 @@ test("auto-continuation falls back after 3 consecutive text-only turns", async ( runtime.start(flow); const coordinator = new WorkflowCoordinator(runtime); const director = createChatDirector("BASE", [], { - onTasksChange: () => undefined, workflowCoordinator: coordinator, }); const caps = makeCapabilities(); @@ -435,7 +428,6 @@ 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", [], { - onTasksChange: () => undefined, workflowCoordinator: coordinator, }); const caps = makeCapabilities();