fix: Add task hooks for plan state updates (#676)

This commit is contained in:
Ben Brandt
2026-05-16 23:05:27 +02:00
committed by GitHub
parent 47df1f2a0b
commit 5ff7d50f0e
3 changed files with 200 additions and 7 deletions

View File

@@ -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 {

View File

@@ -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);
});
});

View File

@@ -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<void> }): 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 };
};