diff --git a/src/acp-agent.ts b/src/acp-agent.ts index c33f7d6c..adc75972 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -74,6 +74,7 @@ import { applyTaskUpdate, ClaudePlanEntry, createPostToolUseHook, + createTaskHook, parseTaskCreateOutput, planEntries, registerHookCallback, @@ -1874,6 +1875,11 @@ export class ClaudeAcpAgent implements Agent { const abortController = userProvidedOptions?.abortController || new AbortController(); + // Per-session task state. Created here (rather than in the session record + // below) so the TaskCreated/TaskCompleted hook callbacks can close over + // the same Map that the streaming message handler will read from. + const taskState: TaskState = new Map(); + const options: Options = { systemPrompt, settingSources: ["user", "project", "local"], @@ -1934,6 +1940,44 @@ export class ClaudeAcpAgent implements Agent { ], }, ], + TaskCreated: [ + ...(userProvidedOptions?.hooks?.TaskCreated || []), + { + hooks: [ + createTaskHook({ + taskState, + onChange: async () => { + await this.client.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "plan", + entries: taskStateToPlanEntries(taskState), + }, + }); + }, + }), + ], + }, + ], + TaskCompleted: [ + ...(userProvidedOptions?.hooks?.TaskCompleted || []), + { + hooks: [ + createTaskHook({ + taskState, + onChange: async () => { + await this.client.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "plan", + entries: taskStateToPlanEntries(taskState), + }, + }); + }, + }), + ], + }, + ], }, ...creationOpts, abortController, @@ -2076,7 +2120,7 @@ export class ClaudeAcpAgent implements Agent { emitRawSDKMessages: sessionMeta?.claudeCode?.emitRawSDKMessages ?? false, contextWindowSize: inferContextWindowFromModel(models.currentModelId) ?? DEFAULT_CONTEXT_WINDOW, - taskState: new Map(), + taskState, }; return { diff --git a/src/tests/tools.test.ts b/src/tests/tools.test.ts index 19c9dfe8..2642b3c5 100644 --- a/src/tests/tools.test.ts +++ b/src/tests/tools.test.ts @@ -14,6 +14,7 @@ import { toAcpNotifications, ToolUseCache, Logger } from "../acp-agent.js"; import { toolUpdateFromToolResult, createPostToolUseHook, + createTaskHook, toolInfoFromToolUse, planEntries, applyTaskCreate, @@ -1610,7 +1611,7 @@ describe("applyTaskCreate / applyTaskUpdate", () => { expect(state.size).toBe(0); }); - it("creates a placeholder entry when TaskUpdate references an unseen task ID", () => { + it("creates a placeholder entry when TaskUpdate carries a subject for an unseen task", () => { const state: TaskState = new Map(); applyTaskUpdate(state, { taskId: "5", subject: "Late arrival", status: "in_progress" }); expect(state.get("5")).toEqual({ @@ -1620,6 +1621,14 @@ describe("applyTaskCreate / applyTaskUpdate", () => { description: undefined, }); }); + + it("skips TaskUpdate for an unseen task when no subject is available", () => { + const state: TaskState = new Map(); + applyTaskUpdate(state, { taskId: "5", status: "in_progress" }); + // Without a subject we'd render an empty-content plan entry, so the + // update is dropped instead of synthesizing a blank placeholder. + expect(state.has("5")).toBe(false); + }); }); describe("toAcpNotifications - Task* tools", () => { @@ -1838,3 +1847,104 @@ describe("toAcpNotifications - Task* tools", () => { expect(taskState.size).toBe(0); }); }); + +describe("createTaskHook", () => { + it("registers a task on TaskCreated and fires onChange", async () => { + const taskState: TaskState = new Map(); + let changes = 0; + const hook = createTaskHook({ + taskState, + onChange: async () => { + changes++; + }, + }); + + await hook( + { + hook_event_name: "TaskCreated", + task_id: "t-1", + task_subject: "Investigate flaky test", + task_description: "Repro and fix", + } as any, + undefined, + { signal: new AbortController().signal }, + ); + + expect(taskState.get("t-1")).toEqual({ + subject: "Investigate flaky test", + status: "pending", + description: "Repro and fix", + }); + expect(changes).toBe(1); + }); + + it("does not clobber an existing entry on TaskCreated", async () => { + const taskState: TaskState = new Map([ + ["t-1", { subject: "Investigate flaky test", status: "in_progress" as const }], + ]); + let changes = 0; + const hook = createTaskHook({ + taskState, + onChange: async () => { + changes++; + }, + }); + + await hook( + { + hook_event_name: "TaskCreated", + task_id: "t-1", + task_subject: "Investigate flaky test", + } as any, + undefined, + { signal: new AbortController().signal }, + ); + + expect(taskState.get("t-1")?.status).toBe("in_progress"); + expect(changes).toBe(0); + }); + + it("marks a task completed on TaskCompleted", async () => { + const taskState: TaskState = new Map([ + ["t-1", { subject: "Investigate flaky test", status: "in_progress" as const }], + ]); + let changes = 0; + const hook = createTaskHook({ + taskState, + onChange: async () => { + changes++; + }, + }); + + await hook( + { + hook_event_name: "TaskCompleted", + task_id: "t-1", + task_subject: "Investigate flaky test", + } as any, + undefined, + { signal: new AbortController().signal }, + ); + + expect(taskState.get("t-1")?.status).toBe("completed"); + expect(changes).toBe(1); + }); + + it("is a no-op for unrelated hook events", async () => { + const taskState: TaskState = new Map(); + let changes = 0; + const hook = createTaskHook({ + taskState, + onChange: async () => { + changes++; + }, + }); + + await hook({ hook_event_name: "PostToolUse", tool_name: "Read" } as any, undefined, { + signal: new AbortController().signal, + }); + + expect(taskState.size).toBe(0); + expect(changes).toBe(0); + }); +}); diff --git a/src/tools.ts b/src/tools.ts index e242714e..71a90fe1 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -782,12 +782,16 @@ export function applyTaskUpdate(state: TaskState, input: TaskUpdateInput | undef state.delete(input.taskId); return; } - const existing = state.get(input.taskId) ?? { subject: "", status: "pending" as const }; + const existing = state.get(input.taskId); + // Without a subject from either the existing entry or the update payload, + // we'd produce a plan entry with empty `content` — drop the update. + const subject = input.subject ?? existing?.subject; + if (!subject) return; state.set(input.taskId, { - subject: input.subject ?? existing.subject, - status: input.status ?? existing.status, - activeForm: input.activeForm ?? existing.activeForm, - description: input.description ?? existing.description, + subject, + status: input.status ?? existing?.status ?? "pending", + activeForm: input.activeForm ?? existing?.activeForm, + description: input.description ?? existing?.description, }); } @@ -927,3 +931,38 @@ export const createPostToolUseHook = } return { continue: true }; }; + +/** + * Hook callback for `TaskCreated` / `TaskCompleted` events. The SDK fires + * these for both user-facing TaskCreate tool calls and subagent task + * creation, giving us `task_id` + `task_subject` without having to parse + * tool_result payloads. + * + * Populating `taskState` from the hook means a later `TaskUpdate` (which + * typically only carries `taskId` + `status`) finds an existing entry with + * a real subject, instead of synthesizing a placeholder with empty content. + */ +export const createTaskHook = + (options: { taskState: TaskState; onChange?: () => Promise }): HookCallback => + async (input): Promise<{ continue: boolean }> => { + const taskId = + "task_id" in input && typeof input.task_id === "string" ? input.task_id : undefined; + if (!taskId) return { continue: true }; + + if (input.hook_event_name === "TaskCreated") { + if (!input.task_subject) return { continue: true }; + if (options.taskState.has(taskId)) return { continue: true }; + options.taskState.set(taskId, { + subject: input.task_subject, + status: "pending", + description: input.task_description, + }); + if (options.onChange) await options.onChange(); + } else if (input.hook_event_name === "TaskCompleted") { + const existing = options.taskState.get(taskId); + if (!existing || existing.status === "completed") return { continue: true }; + options.taskState.set(taskId, { ...existing, status: "completed" }); + if (options.onChange) await options.onChange(); + } + return { continue: true }; + };