Skip to content

Commit bcacbaa

Browse files
TonybynMp4claude
andcommitted
fix(server): subagent task updates reach Claude task lists
Subagents write to the session's shared task store, but the adapter ignored their task tools, so the composer list went stale whenever a subagent created or finished a task. Their TaskCreate results also carry no structured output, only "Task #N created successfully: ...". Apply subagent task tools to the parent turn's list, read the created id from the result text, and skip calls that failed or never ran. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1 parent 13aa882 commit bcacbaa

2 files changed

Lines changed: 65 additions & 14 deletions

File tree

‎apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts‎

Lines changed: 52 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4085,7 +4085,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
40854085
function taskToolFrames(
40864086
tool: TaskToolCall,
40874087
nextUuid: () => string,
4088-
options?: { parentToolUseId?: string; isError?: boolean; extraToolResult?: boolean },
4088+
options?: {
4089+
parentToolUseId?: string;
4090+
isError?: boolean;
4091+
extraToolResult?: boolean;
4092+
nonExecutionKind?: string;
4093+
},
40894094
): ReadonlyArray<SDKMessage> {
40904095
const uuid = nextUuid();
40914096
const toolUseId = `tool-${uuid}`;
@@ -4120,15 +4125,24 @@ describe("ClaudeAdapterV2 background wake turns", () => {
41204125
{
41214126
type: "tool_result",
41224127
tool_use_id: toolUseId,
4123-
content: "Task tool finished.",
4128+
content:
4129+
parentToolUseId !== null && tool.name === "TaskCreate"
4130+
? `Task #${tool.output.task.id} created successfully: ${tool.output.task.subject}`
4131+
: "Task tool finished.",
41244132
is_error: options?.isError ?? false,
41254133
},
41264134
...(options?.extraToolResult
41274135
? [{ type: "tool_result", tool_use_id: "unrelated-tool", content: "ok" }]
41284136
: []),
41294137
],
41304138
},
4131-
tool_use_result: tool.output,
4139+
// Like real Claude, subagent results carry only the text, no `tool_use_result`.
4140+
...(parentToolUseId === null ? { tool_use_result: tool.output } : {}),
4141+
...(options?.nonExecutionKind === undefined
4142+
? {}
4143+
: {
4144+
tool_result_meta: [{ id: toolUseId, non_execution_kind: options.nonExecutionKind }],
4145+
}),
41324146
parent_tool_use_id: parentToolUseId,
41334147
uuid: nextUuid(),
41344148
session_id: WAKE_NATIVE_SESSION,
@@ -4157,7 +4171,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
41574171
});
41584172
const call = Effect.fnUntraced(function* (
41594173
tool: TaskToolCall,
4160-
options?: { parentToolUseId?: string; isError?: boolean; extraToolResult?: boolean },
4174+
options?: Parameters<typeof taskToolFrames>[2],
41614175
) {
41624176
for (const frame of taskToolFrames(tool, nextUuid, options)) {
41634177
yield* harness.offerAndWait(frame);
@@ -4173,7 +4187,22 @@ describe("ClaudeAdapterV2 background wake turns", () => {
41734187
harness.events.flatMap((event) =>
41744188
event.type === "plan.updated" && event.plan.kind === "todo_list" ? [event.plan] : [],
41754189
);
4176-
return { startTurn, call, finishTurn, plans };
4190+
// Registers a running subagent so frames carrying its tool_use_id as
4191+
// parent_tool_use_id are processed instead of held for an unknown owner.
4192+
const launchSubagent = (toolUseId: string) =>
4193+
harness.offerAndWait(
4194+
claudeSdkFrame({
4195+
type: "system",
4196+
subtype: "task_started",
4197+
task_id: `task-${toolUseId}`,
4198+
tool_use_id: toolUseId,
4199+
description: "Work on tasks",
4200+
task_type: "local_agent",
4201+
uuid: nextUuid(),
4202+
session_id: WAKE_NATIVE_SESSION,
4203+
}),
4204+
);
4205+
return { startTurn, call, launchSubagent, finishTurn, plans };
41774206
});
41784207

41794208
const createTask = (id: string, subject: string): TaskToolCall => ({
@@ -4404,20 +4433,31 @@ describe("ClaudeAdapterV2 background wake turns", () => {
44044433
),
44054434
);
44064435

4407-
it.effect("keeps subagent TaskCreate, TaskUpdate and TaskList out of the main plan", () =>
4436+
it.effect("applies subagent task tools to the parent turn's shared task list", () =>
44084437
Effect.gen(function* () {
44094438
const harness = yield* makeTaskToolHarness;
44104439
yield* harness.startTurn();
44114440
yield* harness.call(createTask("1", "Main task"));
4441+
yield* harness.launchSubagent("tool-parent-agent");
44124442
const child = { parentToolUseId: "tool-parent-agent" };
4413-
yield* harness.call(createTask("2", "Child task"), child);
44144443
yield* harness.call(updateTask({ taskId: "1", status: "completed" }), child);
4415-
yield* harness.call({ name: "TaskList", input: {}, output: { tasks: [] } }, child);
4416-
yield* harness.call(updateTask({ taskId: "1", status: "in_progress" }));
4444+
yield* harness.call(createTask("2", "Child task"), child);
4445+
yield* harness.call(updateTask({ taskId: "2", status: "in_progress" }), {
4446+
...child,
4447+
isError: true,
4448+
});
4449+
// A cancelled call never ran, even without `is_error`.
4450+
yield* harness.call(updateTask({ taskId: "2", status: "completed" }), {
4451+
...child,
4452+
nonExecutionKind: "cancelled",
4453+
});
44174454
yield* harness.finishTurn();
4418-
assert.lengthOf(harness.plans(), 2);
4419-
assert.deepEqual(harness.plans().at(-1)?.steps, [
4420-
{ id: "task-1", text: "Main task", status: "running" },
4455+
const plans = harness.plans();
4456+
assert.lengthOf(plans, 3);
4457+
assert.equal(new Set(plans.map((plan) => plan.id)).size, 1);
4458+
assert.deepEqual(plans.at(-1)?.steps, [
4459+
{ id: "task-1", text: "Main task", status: "completed" },
4460+
{ id: "task-2", text: "Requested Child task", status: "pending" },
44214461
]);
44224462
}).pipe(
44234463
Effect.scoped,

‎apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3293,7 +3293,7 @@ function applyClaudeTaskTool(
32933293
}
32943294
if (toolName === "TaskCreate") {
32953295
const created = claudeTaskField(result, "task");
3296-
const id = claudeTaskString(created, "id");
3296+
const id = claudeTaskString(created, "id") ?? claudeCreatedTaskIdFromText(result);
32973297
const text = claudeTaskString(created, "subject") ?? claudeTaskString(input, "subject");
32983298
if (id === undefined || text === undefined) return undefined;
32993299
return new Map(tasks).set(id, { text, status: "pending" });
@@ -3316,6 +3316,14 @@ function applyClaudeTaskTool(
33163316
return new Map(tasks).set(id, { text, status });
33173317
}
33183318

3319+
// Subagent results carry no structured `tool_use_result`, only the text
3320+
// "Task #3 created successfully: <subject>".
3321+
function claudeCreatedTaskIdFromText(result: unknown): string | undefined {
3322+
return typeof result === "string"
3323+
? /^Task #(\S+) created successfully/.exec(result)?.[1]
3324+
: undefined;
3325+
}
3326+
33193327
function claudeTaskSteps(tasks: ClaudeTaskList): ReadonlyArray<OrchestrationV2PlanStep> {
33203328
return [...tasks].map(([id, task]) => ({ id: `task-${id}`, ...task }));
33213329
}
@@ -7297,7 +7305,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
72977305
yield* stopTaskOutputTail(toolCall.nativeItemId);
72987306
yield* emitToolCallArtifacts(artifacts);
72997307
toolCallsFor(context, toolCall).delete(toolCall.nativeItemId);
7300-
if (parentToolUseId === null && !isClaudeToolResultError(toolResult)) {
7308+
// Subagents write to the session's shared task store, so their
7309+
// task tools update the parent turn's list. Failed or never-run
7310+
// calls changed nothing.
7311+
if (toolNonExecutionKind === undefined && !isClaudeToolResultError(toolResult)) {
73017312
const taskLists = yield* Ref.get(taskListsByNativeThread);
73027313
const taskList = applyClaudeTaskTool(
73037314
taskLists.get(liveQuery.nativeThreadId) ?? new Map(),

0 commit comments

Comments
 (0)