mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 12:40:32 +00:00
fix: Add task hooks for plan state updates (#676)
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
49
src/tools.ts
49
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<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 };
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user