authorgravatar for 56691628+Mnigos@users.noreply.github.comIgor Makowski <56691628+Mnigos@users.noreply.github.com> 2026-10-10 23:34:56+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-10 16:34:56-05:00
loga8bdfdb54181fb5c9d43f2747112c137a804d016
tree6b495f1bb32a558614becfeefd4cc2b608e3b01f
parent38c187bfb7a0020db36ccc1479e7c3501b205fb8
signature Signed by PGP key B5690EEEBB952194

fix(server): Claude 5 task lists reach the tasks drawer (#14964)


2 files changed, 523 insertions(+), 8 deletions(-)

apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts+417-8
......@@ -11,7 +11,14 @@ import type {
1111 SpawnedProcess,
1212 SpawnOptions,
1313} from "@anthropic-ai/claude-agent-sdk";
14import type { AskUserQuestionInput } from "@anthropic-ai/claude-agent-sdk/sdk-tools";
14import type {
15 AskUserQuestionInput,
16 TaskCreateInput,
17 TaskCreateOutput,
18 TaskListOutput,
19 TaskUpdateInput,
20 TaskUpdateOutput,
21} from "@anthropic-ai/claude-agent-sdk/sdk-tools";
1522import * as NodeServices from "@effect/platform-node/NodeServices";
1623import {
1724 ChatAttachmentId,
......@@ -26,6 +33,9 @@ import {
2633 type OrchestrationV2ProviderThread,
2734 ProjectId,
2835 ProviderInstanceId,
36 type ProviderInstanceEnvironment,
37 ProviderDriverKind,
38 type ProviderReplayTranscript,
2939 type ProviderApprovalDecision,
3040 ProviderSessionId,
3141 ProviderTurnId,
......@@ -74,6 +84,17 @@ import type * as ProviderContinuationRequests from "@t3tools/provider-core/serve
7484import { makeProviderFailure } from "@t3tools/provider-core/server/failure";
7585import * as ClaudeAdapterV2 from "./ClaudeAdapterV2.ts";
7686import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
87import { ClaudeOrchestratorReplayHarness } from "./ClaudeAdapterV2.testkit.ts";
88import { provideDeterministicTestRuntime } from "../testkit/DeterministicRuntime.ts";
89import { runOrchestratorV2Scenario } from "../testkit/OrchestratorScenario.ts";
90import { layerProviderReplay } from "../testkit/ProviderReplayHarness.ts";
91import {
92 assertBaseProjection,
93 assertSemanticProjectionIntegrity,
94 CLAUDE_MODEL_SELECTION,
95 materializeFixtureInput,
96 projectionFor,
97} from "../testkit/fixtures/shared.ts";
7798
7899const DEFAULT_CLAUDE_SETTINGS = Schema.decodeSync(ClaudeSettings)({});
79100const AUTO_COMPACT_CLAUDE_SETTINGS = Schema.decodeSync(ClaudeSettings)({
......@@ -1102,17 +1123,20 @@ describe("ClaudeAdapterV2 approval cancellation", () => {
11021123 );
11031124});
11041125
1105// Opens a session with the given configured binary path, runs one turn, and
1106// returns the executable paths the SDK was asked to spawn.
1126// Opens a session with the given configured binary path and environment, runs
1127// one turn, and returns the executable paths the SDK was asked to spawn and the
1128// CLAUDE_CODE_ENABLE_TODO_TOOLS value it was given.
11071129const captureSdkExecutablePaths = Effect.fn("captureSdkExecutablePaths")(function* (
11081130 binaryPath: string,
1131 environment: ProviderInstanceEnvironment = [],
11091132) {
11101133 const executablePaths: Array<string | undefined> = [];
1134 const taskToolEnvironment: Array<string | undefined> = [];
11111135 const adapter = yield* ClaudeAdapterV2.createClaudeAdapterV2(
11121136 {
11131137 instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID,
11141138 displayName: undefined,
1115 environment: [],
1139 environment,
11161140 enabled: true,
11171141 config: { ...DEFAULT_CLAUDE_SETTINGS, binaryPath },
11181142 },
......@@ -1128,6 +1152,7 @@ const captureSdkExecutablePaths = Effect.fn("captureSdkExecutablePaths")(functio
11281152 open: (input) =>
11291153 Effect.sync(() => {
11301154 executablePaths.push(input.options.pathToClaudeCodeExecutable);
1155 taskToolEnvironment.push(input.options.env?.CLAUDE_CODE_ENABLE_TODO_TOOLS);
11311156 return {
11321157 messages: Stream.never,
11331158 offer: () => Effect.void,
......@@ -1164,19 +1189,38 @@ const captureSdkExecutablePaths = Effect.fn("captureSdkExecutablePaths")(functio
11641189 attachments: [],
11651190 }),
11661191 );
1167 return executablePaths;
1192 return { executablePaths, taskToolEnvironment };
11681193});
11691194
11701195describe("ClaudeAdapterV2 executable path", () => {
1171 it.effect("expands ~ in the configured binary path for the SDK", () =>
1196 it.effect.each([
1197 { source: "the default", environment: [], hostEnvironment: {}, expected: undefined },
1198 {
1199 source: "an instance opt-in",
1200 environment: [{ name: "CLAUDE_CODE_ENABLE_TODO_TOOLS", value: "1", sensitive: false }],
1201 hostEnvironment: {},
1202 expected: "1",
1203 },
1204 {
1205 source: "a host opt-in",
1206 environment: [],
1207 hostEnvironment: { CLAUDE_CODE_ENABLE_TODO_TOOLS: "1" },
1208 expected: "1",
1209 },
1210 ])("expands the executable path and respects $source for task tools", (testCase) =>
11721211 Effect.scoped(
11731212 Effect.gen(function* () {
11741213 const path = yield* Path.Path;
1175 const executablePaths = yield* captureSdkExecutablePaths("~/bin/claude");
1214 const { executablePaths, taskToolEnvironment } = yield* captureSdkExecutablePaths(
1215 "~/bin/claude",
1216 testCase.environment,
1217 );
11761218
11771219 assert.deepEqual(executablePaths, [path.join(NodeOS.homedir(), "bin", "claude")]);
1220 assert.deepEqual(taskToolEnvironment, [testCase.expected]);
11781221 }),
11791222 ).pipe(
1223 Effect.provideService(HostProcess.Environment, testCase.hostEnvironment),
11801224 Effect.provide(
11811225 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
11821226 ),
......@@ -1188,7 +1232,7 @@ describe("ClaudeAdapterV2 executable path", () => {
11881232 Effect.gen(function* () {
11891233 const npmDir = "C:\\Users\\dev\\AppData\\Roaming\\npm";
11901234 const packageExe = `${npmDir}\\node_modules\\@anthropic-ai\\claude-code\\bin\\claude.exe`;
1191 const executablePaths = yield* captureSdkExecutablePaths("claude").pipe(
1235 const { executablePaths } = yield* captureSdkExecutablePaths("claude").pipe(
11921236 Effect.provideService(HostProcess.Platform, "win32"),
11931237 Effect.provideService(SpawnExecutableResolution, () => `${npmDir}\\claude.cmd`),
11941238 Effect.provideService(ClaudeExecutableFileCheck, (filePath) => filePath === packageExe),
......@@ -3722,6 +3766,371 @@ describe("ClaudeAdapterV2 background wake turns", () => {
37223766 ),
37233767 );
37243768
3769 type TaskToolCall =
3770 | { name: "TaskCreate"; input: TaskCreateInput; output: TaskCreateOutput }
3771 | { name: "TaskUpdate"; input: TaskUpdateInput; output: TaskUpdateOutput }
3772 | { name: "TaskList"; input: Record<string, never>; output: TaskListOutput };
3773
3774 function taskToolFrames(
3775 tool: TaskToolCall,
3776 nextUuid: () => string,
3777 options?: { parentToolUseId?: string; isError?: boolean; extraToolResult?: boolean },
3778 ): ReadonlyArray<SDKMessage> {
3779 const uuid = nextUuid();
3780 const toolUseId = `tool-${uuid}`;
3781 const parentToolUseId = options?.parentToolUseId ?? null;
3782 return [
3783 claudeSdkFrame({
3784 type: "assistant",
3785 message: {
3786 id: `msg_${uuid}`,
3787 model: "claude-sonnet-4-6",
3788 type: "message",
3789 role: "assistant",
3790 content: [{ type: "tool_use", id: toolUseId, name: tool.name, input: tool.input }],
3791 stop_reason: "tool_use",
3792 stop_sequence: null,
3793 usage: {
3794 input_tokens: 1,
3795 output_tokens: 1,
3796 cache_creation_input_tokens: 0,
3797 cache_read_input_tokens: 0,
3798 },
3799 },
3800 parent_tool_use_id: parentToolUseId,
3801 uuid,
3802 session_id: WAKE_NATIVE_SESSION,
3803 }),
3804 claudeSdkFrame({
3805 type: "user",
3806 message: {
3807 role: "user",
3808 content: [
3809 {
3810 type: "tool_result",
3811 tool_use_id: toolUseId,
3812 content: "Task tool finished.",
3813 is_error: options?.isError ?? false,
3814 },
3815 ...(options?.extraToolResult
3816 ? [{ type: "tool_result", tool_use_id: "unrelated-tool", content: "ok" }]
3817 : []),
3818 ],
3819 },
3820 tool_use_result: tool.output,
3821 parent_tool_use_id: parentToolUseId,
3822 uuid: nextUuid(),
3823 session_id: WAKE_NATIVE_SESSION,
3824 }),
3825 ];
3826 }
3827
3828 const makeTaskToolHarness = Effect.gen(function* () {
3829 const harness = yield* makeWakeHarness;
3830 let sequence = 600;
3831 let turn = 0;
3832 const nextUuid = () => `00000000-0000-4000-8000-${String(++sequence).padStart(12, "0")}`;
3833 const startTurn = Effect.fnUntraced(function* () {
3834 turn++;
3835 yield* harness.runtime.startTurn(
3836 makeClaudeTestTurnInput({
3837 threadId: harness.threadId,
3838 providerThread: harness.providerThread,
3839 now: yield* DateTime.now,
3840 attemptId: RunAttemptId.make(`attempt-claude-task-tools-${turn}`),
3841 providerTurnOrdinal: turn,
3842 text: "Update the task list.",
3843 attachments: [],
3844 }),
3845 );
3846 });
3847 const call = Effect.fnUntraced(function* (
3848 tool: TaskToolCall,
3849 options?: { parentToolUseId?: string; isError?: boolean; extraToolResult?: boolean },
3850 ) {
3851 for (const frame of taskToolFrames(tool, nextUuid, options)) {
3852 yield* harness.offerAndWait(frame);
3853 }
3854 });
3855 const finishTurn = Effect.fnUntraced(function* () {
3856 yield* harness.offerAndWait(
3857 makeResultFrame({ uuid: nextUuid(), result: "Task list updated." }),
3858 );
3859 yield* Queue.take(harness.terminalReceipts);
3860 });
3861 const plans = () =>
3862 harness.events.flatMap((event) =>
3863 event.type === "plan.updated" && event.plan.kind === "todo_list" ? [event.plan] : [],
3864 );
3865 return { startTurn, call, finishTurn, plans };
3866 });
3867
3868 const createTask = (id: string, subject: string): TaskToolCall => ({
3869 name: "TaskCreate",
3870 input: { subject: `Requested ${subject}`, description: `Work on ${subject}.` },
3871 output: { task: { id, subject } },
3872 });
3873 const updateTask = (input: TaskUpdateInput, success = true): TaskToolCall => ({
3874 name: "TaskUpdate",
3875 input,
3876 output: { success, taskId: input.taskId, updatedFields: ["status"] },
3877 });
3878
3879 it.effect("persists a todo_list from inline Claude task-tool frames", () =>
3880 Effect.gen(function* () {
3881 let sequence = 700;
3882 const nextUuid = () => `00000000-0000-4000-8000-${String(++sequence).padStart(12, "0")}`;
3883 const prompt = "Track a synthetic task.";
3884 const transcript = yield* ClaudeOrchestratorReplayHarness.decodeTranscript({
3885 provider: "claudeAgent",
3886 protocol: "claude-agent-sdk.query",
3887 version: "1",
3888 scenario: "claude_task_tools_inline",
3889 metadata: { nativeSessionId: WAKE_NATIVE_SESSION },
3890 entries: [
3891 {
3892 type: "expect_outbound",
3893 frame: {
3894 type: "query.open",
3895 options: {
3896 model: "claude-sonnet-4-6",
3897 tools: { type: "preset", preset: "claude_code" },
3898 permissionMode: "bypassPermissions",
3899 allowDangerouslySkipPermissions: true,
3900 settings: { showThinkingSummaries: true },
3901 sessionId: WAKE_NATIVE_SESSION,
3902 },
3903 },
3904 },
3905 {
3906 type: "expect_outbound",
3907 frame: {
3908 type: "prompt.offer",
3909 message: {
3910 type: "user",
3911 message: { role: "user", content: prompt },
3912 parent_tool_use_id: null,
3913 },
3914 },
3915 },
3916 ...[
3917 ...taskToolFrames(createTask("1", "Inspect"), nextUuid),
3918 ...taskToolFrames(updateTask({ taskId: "1", status: "in_progress" }), nextUuid),
3919 makeResultFrame({ uuid: nextUuid(), result: "Task recorded." }),
3920 ].map((frame) => ({ type: "emit_inbound" as const, frame })),
3921 ],
3922 } satisfies ProviderReplayTranscript);
3923 const materialized = yield* materializeFixtureInput({
3924 scenario: transcript.scenario,
3925 fixtureInput: { steps: [{ type: "message", text: prompt }] },
3926 driver: ProviderDriverKind.make("claudeAgent"),
3927 modelSelection: CLAUDE_MODEL_SELECTION,
3928 }).pipe(Effect.provide(IdAllocator.layer), provideDeterministicTestRuntime);
3929 const scenario = { name: transcript.scenario, transcript, ...materialized };
3930 yield* Effect.gen(function* () {
3931 const result = yield* runOrchestratorV2Scenario(scenario);
3932 assertBaseProjection({ result, transcript, runCount: 1, runStatuses: ["completed"] });
3933 const projection = projectionFor(result, transcript.scenario);
3934 assertSemanticProjectionIntegrity(projection);
3935 const plans = projection.plans.filter((plan) => plan.kind === "todo_list");
3936 assert.lengthOf(plans, 1);
3937 assert.deepEqual(
3938 plans[0]?.steps.map(({ id, text, status }) => ({ id, text, status })),
3939 [{ id: "task-1", text: "Inspect", status: "running" }],
3940 );
3941 assert.lengthOf(
3942 projection.turnItems.filter((item) => item.type === "todo_list"),
3943 1,
3944 );
3945 }).pipe(
3946 Effect.provide(layerProviderReplay(scenario, ClaudeOrchestratorReplayHarness)),
3947 provideDeterministicTestRuntime,
3948 Effect.scoped,
3949 );
3950 }),
3951 );
3952
3953 it.effect(
3954 "projects TaskCreate and TaskUpdate into one plan per turn and supersedes it later",
3955 () =>
3956 Effect.gen(function* () {
3957 const harness = yield* makeTaskToolHarness;
3958 yield* harness.startTurn();
3959 yield* harness.call(createTask("1", "Inspect"));
3960 yield* harness.call(createTask("2", "Implement"));
3961 yield* harness.call(updateTask({ taskId: "1", status: "in_progress" }));
3962 yield* harness.finishTurn();
3963
3964 const firstTurnPlans = harness.plans();
3965 assert.lengthOf(firstTurnPlans, 3);
3966 assert.equal(new Set(firstTurnPlans.map((plan) => plan.id)).size, 1);
3967 assert.deepEqual(firstTurnPlans.at(-1)?.steps, [
3968 { id: "task-1", text: "Inspect", status: "running" },
3969 { id: "task-2", text: "Implement", status: "pending" },
3970 ]);
3971 const firstPlan = firstTurnPlans.at(-1)!;
3972
3973 yield* harness.startTurn();
3974 yield* harness.call(updateTask({ taskId: "1", status: "completed", subject: "Inspected" }));
3975 yield* harness.finishTurn();
3976
3977 const laterPlans = harness.plans().slice(firstTurnPlans.length);
3978 assert.lengthOf(laterPlans, 2);
3979 assert.equal(laterPlans[0]?.id, firstPlan.id);
3980 assert.equal(laterPlans[0]?.status, "superseded");
3981 const newPlan = laterPlans[1]!;
3982 assert.notEqual(newPlan.id, firstPlan.id);
3983 assert.notEqual(newPlan.runId, firstPlan.runId);
3984 assert.deepEqual(newPlan.steps, [
3985 { id: "task-1", text: "Inspected", status: "completed" },
3986 { id: "task-2", text: "Implement", status: "pending" },
3987 ]);
3988 }).pipe(
3989 Effect.scoped,
3990 Effect.provide(
3991 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
3992 ),
3993 ),
3994 );
3995
3996 it.effect("removes deleted tasks without resurrecting them on later updates or turns", () =>
3997 Effect.gen(function* () {
3998 const harness = yield* makeTaskToolHarness;
3999 yield* harness.startTurn();
4000 yield* harness.call(createTask("1", "Discard"));
4001 yield* harness.call(createTask("2", "Keep"));
4002 yield* harness.call(updateTask({ taskId: "1", status: "deleted" }));
4003 yield* harness.call(updateTask({ taskId: "1", status: "pending" }));
4004 yield* harness.finishTurn();
4005 assert.lengthOf(harness.plans(), 3);
4006 assert.deepEqual(harness.plans().at(-1)?.steps, [
4007 { id: "task-2", text: "Keep", status: "pending" },
4008 ]);
4009
4010 yield* harness.startTurn();
4011 yield* harness.call(updateTask({ taskId: "2", status: "in_progress" }));
4012 yield* harness.finishTurn();
4013 assert.deepEqual(harness.plans().at(-1)?.steps, [
4014 { id: "task-2", text: "Keep", status: "running" },
4015 ]);
4016 for (const plan of harness.plans().slice(2)) {
4017 assert.isFalse(plan.steps.some((step) => step.id === "task-1"));
4018 }
4019 }).pipe(
4020 Effect.scoped,
4021 Effect.provide(
4022 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
4023 ),
4024 ),
4025 );
4026
4027 it.effect.each(["success false", "error result", "unknown id"] as const)(
4028 "does not emit a plan update for TaskUpdate with %s",
4029 (reason) =>
4030 Effect.gen(function* () {
4031 const harness = yield* makeTaskToolHarness;
4032 yield* harness.startTurn();
4033 yield* harness.call(createTask("1", "Inspect"));
4034 yield* harness.call(
4035 updateTask(
4036 { taskId: reason === "unknown id" ? "missing" : "1", status: "completed" },
4037 reason !== "success false",
4038 ),
4039 { isError: reason === "error result" },
4040 );
4041 yield* harness.finishTurn();
4042 assert.lengthOf(harness.plans(), 1);
4043 assert.deepEqual(harness.plans()[0]?.steps, [
4044 { id: "task-1", text: "Inspect", status: "pending" },
4045 ]);
4046 }).pipe(
4047 Effect.scoped,
4048 Effect.provide(
4049 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
4050 ),
4051 ),
4052 );
4053
4054 it.effect("replaces task state with TaskList, including an empty list", () =>
4055 Effect.gen(function* () {
4056 const harness = yield* makeTaskToolHarness;
4057 yield* harness.startTurn();
4058 yield* harness.call(createTask("1", "Stale"));
4059 yield* harness.call({
4060 name: "TaskList",
4061 input: {},
4062 output: {
4063 tasks: [
4064 { id: "2", subject: "Queued", status: "pending", blockedBy: [] },
4065 { id: "3", subject: "Working", status: "in_progress", blockedBy: [] },
4066 { id: "4", subject: "Done", status: "completed", blockedBy: [] },
4067 ],
4068 },
4069 });
4070 yield* harness.call(updateTask({ taskId: "1", status: "in_progress" }));
4071 yield* harness.call(updateTask({ taskId: "2", status: "in_progress" }));
4072 yield* harness.call({ name: "TaskList", input: {}, output: { tasks: [] } });
4073 yield* harness.call(updateTask({ taskId: "2", status: "pending" }));
4074 yield* harness.finishTurn();
4075 const plans = harness.plans();
4076 assert.lengthOf(plans, 4);
4077 assert.deepEqual(plans[1]?.steps, [
4078 { id: "task-2", text: "Queued", status: "pending" },
4079 { id: "task-3", text: "Working", status: "running" },
4080 { id: "task-4", text: "Done", status: "completed" },
4081 ]);
4082 assert.deepEqual(plans[2]?.steps, [
4083 { id: "task-2", text: "Queued", status: "running" },
4084 { id: "task-3", text: "Working", status: "running" },
4085 { id: "task-4", text: "Done", status: "completed" },
4086 ]);
4087 assert.deepEqual(plans[3]?.steps, []);
4088 }).pipe(
4089 Effect.scoped,
4090 Effect.provide(
4091 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
4092 ),
4093 ),
4094 );
4095
4096 it.effect("keeps subagent TaskCreate, TaskUpdate and TaskList out of the main plan", () =>
4097 Effect.gen(function* () {
4098 const harness = yield* makeTaskToolHarness;
4099 yield* harness.startTurn();
4100 yield* harness.call(createTask("1", "Main task"));
4101 const child = { parentToolUseId: "tool-parent-agent" };
4102 yield* harness.call(createTask("2", "Child task"), child);
4103 yield* harness.call(updateTask({ taskId: "1", status: "completed" }), child);
4104 yield* harness.call({ name: "TaskList", input: {}, output: { tasks: [] } }, child);
4105 yield* harness.call(updateTask({ taskId: "1", status: "in_progress" }));
4106 yield* harness.finishTurn();
4107 assert.lengthOf(harness.plans(), 2);
4108 assert.deepEqual(harness.plans().at(-1)?.steps, [
4109 { id: "task-1", text: "Main task", status: "running" },
4110 ]);
4111 }).pipe(
4112 Effect.scoped,
4113 Effect.provide(
4114 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
4115 ),
4116 ),
4117 );
4118
4119 it.effect("does not associate structured task output with multiple tool results", () =>
4120 Effect.gen(function* () {
4121 const harness = yield* makeTaskToolHarness;
4122 yield* harness.startTurn();
4123 yield* harness.call(createTask("1", "Ambiguous"), { extraToolResult: true });
4124 yield* harness.finishTurn();
4125 assert.deepEqual(harness.plans(), []);
4126 }).pipe(
4127 Effect.scoped,
4128 Effect.provide(
4129 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
4130 ),
4131 ),
4132 );
4133
37254134 it.effect("resolves API retries on resumed assistant activity", () =>
37264135 Effect.scoped(
37274136 Effect.gen(function* () {
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts+106
......@@ -3150,6 +3150,87 @@ export function claudeTodoSteps(input: unknown): ReadonlyArray<OrchestrationV2Pl
31503150 });
31513151}
31523152
3153/** One native thread's Claude task list, keyed by Claude's task id, in creation order. */
3154type ClaudeTaskList = ReadonlyMap<string, Omit<OrchestrationV2PlanStep, "id">>;
3155
3156function claudeTaskField(value: unknown, key: string): unknown {
3157 return typeof value === "object" && value !== null ? Reflect.get(value, key) : undefined;
3158}
3159
3160function claudeTaskString(value: unknown, key: string): string | undefined {
3161 const field = claudeTaskField(value, key);
3162 return typeof field === "string" && field.trim().length > 0 ? field.trim() : undefined;
3163}
3164
3165function claudeTaskStatus(value: unknown): OrchestrationV2PlanStep["status"] | undefined {
3166 switch (value) {
3167 case "pending":
3168 return "pending";
3169 case "in_progress":
3170 return "running";
3171 case "completed":
3172 return "completed";
3173 default:
3174 return undefined;
3175 }
3176}
3177
3178/**
3179 * Applies a finished TaskCreate, TaskUpdate or TaskList call to a task list.
3180 * Claude 5 tracks todos with these tools instead of TodoWrite. Ids come from
3181 * Claude (TaskCreate reports them in its result), a `deleted` status removes
3182 * the task, and TaskList replaces the whole list. Returns undefined when the
3183 * call is not a task tool or leaves the list unchanged.
3184 */
3185function applyClaudeTaskTool(
3186 tasks: ClaudeTaskList,
3187 toolName: string,
3188 input: unknown,
3189 result: unknown,
3190): ClaudeTaskList | undefined {
3191 if (toolName === "TaskList") {
3192 const listed = claudeTaskField(result, "tasks");
3193 if (!Array.isArray(listed)) return undefined;
3194 const next = new Map<string, Omit<OrchestrationV2PlanStep, "id">>();
3195 for (const task of listed) {
3196 const id = claudeTaskString(task, "id");
3197 const text = claudeTaskString(task, "subject");
3198 const status = claudeTaskStatus(claudeTaskField(task, "status"));
3199 if (id !== undefined && text !== undefined && status !== undefined) {
3200 next.set(id, { text, status });
3201 }
3202 }
3203 return next.size === 0 && tasks.size === 0 ? undefined : next;
3204 }
3205 if (toolName === "TaskCreate") {
3206 const created = claudeTaskField(result, "task");
3207 const id = claudeTaskString(created, "id");
3208 const text = claudeTaskString(created, "subject") ?? claudeTaskString(input, "subject");
3209 if (id === undefined || text === undefined) return undefined;
3210 return new Map(tasks).set(id, { text, status: "pending" });
3211 }
3212 if (toolName !== "TaskUpdate" || claudeTaskField(result, "success") === false) {
3213 return undefined;
3214 }
3215 const id = claudeTaskString(input, "taskId") ?? claudeTaskString(result, "taskId");
3216 const task = id === undefined ? undefined : tasks.get(id);
3217 if (id === undefined || task === undefined) return undefined;
3218 const nativeStatus = claudeTaskField(input, "status");
3219 if (nativeStatus === "deleted") {
3220 const next = new Map(tasks);
3221 next.delete(id);
3222 return next;
3223 }
3224 const text = claudeTaskString(input, "subject") ?? task.text;
3225 const status = claudeTaskStatus(nativeStatus) ?? task.status;
3226 if (text === task.text && status === task.status) return undefined;
3227 return new Map(tasks).set(id, { text, status });
3228}
3229
3230function claudeTaskSteps(tasks: ClaudeTaskList): ReadonlyArray<OrchestrationV2PlanStep> {
3231 return [...tasks].map(([id, task]) => ({ id: `task-${id}`, ...task }));
3232}
3233
31533234export function claudeProposedPlan(input: unknown): string | null {
31543235 const value =
31553236 typeof input === "object" && input !== null && Reflect.get(input, "type") === "record"
......@@ -3238,6 +3319,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
32383319 const queryContext = yield* Ref.make<ClaudeLiveQueryContext | null>(null);
32393320 const openedNativeThreads = yield* Ref.make(new Set<string>());
32403321 const latestPlanByKind = yield* Ref.make(new Map<string, OrchestrationV2PlanArtifact>());
3322 // Claude 5 task tools edit one list per native session, across turns.
3323 const taskListsByNativeThread = yield* Ref.make(new Map<string, ClaudeTaskList>());
32413324 const planIdsByNativeItem = yield* Ref.make(
32423325 new Map<string, OrchestrationV2PlanArtifact["id"]>(),
32433326 );
......@@ -6639,6 +6722,29 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
66396722 });
66406723 yield* emitToolCallArtifacts(artifacts);
66416724 toolCallsFor(context, toolCall).delete(toolCall.nativeItemId);
6725 // A subagent's task tools must not replace the parent's list.
6726 if (parentToolUseId === null && !isClaudeToolResultError(toolResult)) {
6727 const taskLists = yield* Ref.get(taskListsByNativeThread);
6728 const taskList = applyClaudeTaskTool(
6729 taskLists.get(liveQuery.nativeThreadId) ?? new Map(),
6730 toolCall.toolName,
6731 claudeNativeToolInputValue(toolCall.input),
6732 claudeNativeToolOutputValue(output),
6733 );
6734 if (taskList !== undefined) {
6735 yield* Ref.set(
6736 taskListsByNativeThread,
6737 new Map(taskLists).set(liveQuery.nativeThreadId, taskList),
6738 );
6739 // One list per turn, updated in place; a later turn supersedes it.
6740 yield* emitClaudePlanProjection({
6741 context,
6742 nativeItemId: `claude-tasks:${context.providerTurnId}`,
6743 kind: "todo_list",
6744 steps: claudeTaskSteps(taskList),
6745 }).pipe(Effect.orDie);
6746 }
6747 }
66426748 }
66436749
66446750 const assistantParentToolUseId = parentToolUseIdFromSdkMessage(message);