Skip to content
111 changes: 99 additions & 12 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5808,7 +5808,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
function taskToolFrames(
tool: TaskToolCall,
nextUuid: () => string,
options?: { parentToolUseId?: string; isError?: boolean; extraToolResult?: boolean },
options?: {
parentToolUseId?: string;
isError?: boolean;
extraToolResult?: boolean;
nonExecutionKind?: string;
},
): ReadonlyArray<SDKMessage> {
const uuid = nextUuid();
const toolUseId = `tool-${uuid}`;
Expand Down Expand Up @@ -5843,15 +5848,28 @@ describe("ClaudeAdapterV2 background wake turns", () => {
{
type: "tool_result",
tool_use_id: toolUseId,
content: "Task tool finished.",
is_error: options?.isError ?? false,
content:
parentToolUseId !== null && tool.name === "TaskCreate"
? `Task #${tool.output.task.id} created successfully: ${tool.output.task.subject}`
: parentToolUseId !== null && tool.name === "TaskUpdate"
? tool.output.success
? `Updated task #${tool.output.taskId} ${tool.output.updatedFields.join(", ")}`
: tool.output.error
: "Task tool finished.",
...(options?.isError === undefined ? {} : { is_error: options.isError }),
},
...(options?.extraToolResult
? [{ type: "tool_result", tool_use_id: "unrelated-tool", content: "ok" }]
: []),
],
},
tool_use_result: tool.output,
// Like real Claude, subagent results carry only the text, no `tool_use_result`.
...(parentToolUseId === null ? { tool_use_result: tool.output } : {}),
...(options?.nonExecutionKind === undefined
? {}
: {
tool_result_meta: [{ id: toolUseId, non_execution_kind: options.nonExecutionKind }],
}),
parent_tool_use_id: parentToolUseId,
uuid: nextUuid(),
session_id: WAKE_NATIVE_SESSION,
Expand Down Expand Up @@ -5890,7 +5908,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
});
const call = Effect.fnUntraced(function* (
tool: TaskToolCall,
options?: { parentToolUseId?: string; isError?: boolean; extraToolResult?: boolean },
options?: Parameters<typeof taskToolFrames>[2],
) {
for (const frame of taskToolFrames(tool, nextUuid, options)) {
yield* harness.offerAndWait(frame);
Expand All @@ -5906,7 +5924,22 @@ describe("ClaudeAdapterV2 background wake turns", () => {
harness.events.flatMap((event) =>
event.type === "plan.updated" && event.plan.kind === "todo_list" ? [event.plan] : [],
);
return { configDir, startTurn, call, finishTurn, plans };
// Registers a running subagent so frames carrying its tool_use_id as
// parent_tool_use_id are processed instead of held for an unknown owner.
const launchSubagent = (toolUseId: string) =>
harness.offerAndWait(
claudeSdkFrame({
type: "system",
subtype: "task_started",
task_id: `task-${toolUseId}`,
tool_use_id: toolUseId,
description: "Work on tasks",
task_type: "local_agent",
uuid: nextUuid(),
session_id: WAKE_NATIVE_SESSION,
}),
);
return { configDir, startTurn, call, launchSubagent, finishTurn, plans };
});

const makeTaskToolHarness = makeTaskToolHarnessWithOptions();
Expand Down Expand Up @@ -6220,20 +6253,74 @@ describe("ClaudeAdapterV2 background wake turns", () => {
),
);

it.effect("keeps subagent TaskCreate, TaskUpdate and TaskList out of the main plan", () =>
it.effect("applies subagent task tools to the parent turn's shared task list", () =>
Effect.gen(function* () {
const harness = yield* makeTaskToolHarness;
yield* harness.startTurn();
yield* harness.call(createTask("1", "Main task"));
yield* harness.launchSubagent("tool-parent-agent");
const child = { parentToolUseId: "tool-parent-agent" };
yield* harness.call(createTask("2", "Child task"), child);
yield* harness.call(updateTask({ taskId: "1", status: "completed" }), child);
yield* harness.call({ name: "TaskList", input: {}, output: { tasks: [] } }, child);
yield* harness.call(updateTask({ taskId: "1", status: "in_progress" }));
yield* harness.call(createTask("2", "Child task"), child);
yield* harness.call(
{
name: "TaskUpdate",
input: { taskId: "2", status: "completed" },
output: {
success: false,
taskId: "2",
updatedFields: [],
error: "Tests must pass before completing.",
},
},
child,
);
yield* harness.call(updateTask({ taskId: "2", status: "in_progress" }), {
...child,
isError: true,
});
// A cancelled call never ran, even without `is_error`.
yield* harness.call(updateTask({ taskId: "2", status: "completed" }), {
...child,
nonExecutionKind: "cancelled",
});
yield* harness.finishTurn();
const plans = harness.plans();
assert.lengthOf(plans, 3);
assert.equal(new Set(plans.map((plan) => plan.id)).size, 1);
assert.deepEqual(plans.at(-1)?.steps, [
{ id: "task-1", text: "Main task", status: "completed" },
{ id: "task-2", text: "Child task", status: "pending" },
]);
}).pipe(
Effect.scoped,
Effect.provide(
Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
),
),
);

it.effect("recovers persisted tasks when a subagent's TaskList comes first", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const harness = yield* makeTaskToolHarness;
const tasksDir = `${harness.configDir}/tasks/${WAKE_NATIVE_SESSION}`;
yield* fs.makeDirectory(tasksDir, { recursive: true });
yield* fs.writeFileString(
`${tasksDir}/1.json`,
JSON.stringify({ id: "1", subject: "Inspect", status: "pending", blockedBy: [] }),
);

yield* harness.startTurn();
yield* harness.launchSubagent("tool-parent-agent");
yield* harness.call(
{ name: "TaskList", input: {}, output: { tasks: [] } },
{ parentToolUseId: "tool-parent-agent" },
);
yield* harness.call(updateTask({ taskId: "1", status: "completed" }));
yield* harness.finishTurn();
assert.lengthOf(harness.plans(), 2);
assert.deepEqual(harness.plans().at(-1)?.steps, [
{ id: "task-1", text: "Main task", status: "running" },
{ id: "task-1", text: "Inspect", status: "completed" },
]);
}).pipe(
Effect.scoped,
Expand Down
43 changes: 35 additions & 8 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3576,12 +3576,20 @@ function applyClaudeTaskTool(
}
if (toolName === "TaskCreate") {
const created = claudeTaskField(result, "task");
const id = claudeTaskString(created, "id");
const text = claudeTaskString(created, "subject") ?? claudeTaskString(input, "subject");
const fromText = claudeCreatedTaskFromText(result);
const id = claudeTaskString(created, "id") ?? fromText?.id;
const text =
claudeTaskString(created, "subject") ??
fromText?.subject ??
claudeTaskString(input, "subject");
if (id === undefined || text === undefined) return undefined;
return new Map(tasks).set(id, { text, status: "pending" });
}
if (toolName !== "TaskUpdate" || claudeTaskField(result, "success") === false) {
if (
toolName !== "TaskUpdate" ||
claudeTaskField(result, "success") === false ||
(typeof result === "string" && !result.startsWith("Updated task #"))
) {
return undefined;
}
const id = claudeTaskString(input, "taskId") ?? claudeTaskString(result, "taskId");
Expand All @@ -3599,6 +3607,16 @@ function applyClaudeTaskTool(
return new Map(tasks).set(id, { text, status });
}

// Subagent results carry no structured `tool_use_result`, only the text
// "Task #3 created successfully: <subject>".
function claudeCreatedTaskFromText(
result: unknown,
): { readonly id: string; readonly subject: string } | undefined {
const match =
typeof result === "string" ? /^Task #(\S+) created successfully: (.+)$/s.exec(result) : null;
return match === null ? undefined : { id: match[1]!, subject: match[2]!.trim() };
}

const CLAUDE_TASK_TOOL_NAMES = new Set(["TaskCreate", "TaskUpdate", "TaskList"]);
const decodeClaudeTaskFile = Schema.decodeUnknownOption(
Schema.fromJsonString(
Expand Down Expand Up @@ -8046,17 +8064,26 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
output,
completedAt: yield* DateTime.now,
});
// Subagents write to the session's shared task store, so their
// task tools update the parent turn's list. Failed or never-run
// calls changed nothing.
if (
parentToolUseId === null &&
claudeToolNonExecutionKind(message, toolResult.tool_use_id) === undefined &&
!isClaudeToolResultError(toolResult) &&
CLAUDE_TASK_TOOL_NAMES.has(toolCall.toolName)
) {
const taskLists = yield* Ref.get(taskListsByNativeThread);
const knownTaskList = taskLists.get(liveQuery.nativeThreadId);
// TaskList already supplies a snapshot. Mutations need Claude's persisted
// list once; afterward the cache also records an unavailable store.
const outputValue = claudeNativeToolOutputValue(output);
// A structured TaskList already supplies a snapshot. Anything else, including
// a subagent's text-only TaskList, needs Claude's persisted list once;
// afterward the cache also records an unavailable store.
const storedTaskList =
!taskLists.has(liveQuery.nativeThreadId) && toolCall.toolName !== "TaskList"
!taskLists.has(liveQuery.nativeThreadId) &&
!(
toolCall.toolName === "TaskList" &&
Array.isArray(claudeTaskField(outputValue, "tasks"))
)
? yield* readClaudeTaskList({
environment: adapterOptions.environment,
cwd: context.input.runtimePolicy.cwd,
Expand All @@ -8071,7 +8098,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
knownTaskList ?? storedTaskList ?? new Map(),
toolCall.toolName,
claudeNativeToolInputValue(toolCall.input),
claudeNativeToolOutputValue(output),
outputValue,
) ?? storedTaskList;
yield* Ref.set(
taskListsByNativeThread,
Expand Down
Loading