| ... | ... | @@ -11,7 +11,14 @@ import type { |
| 11 | 11 | SpawnedProcess, |
| 12 | 12 | SpawnOptions, |
| 13 | 13 | } from "@anthropic-ai/claude-agent-sdk"; |
| 14 | | import type { AskUserQuestionInput } from "@anthropic-ai/claude-agent-sdk/sdk-tools"; |
| 14 | import type { |
| 15 | AskUserQuestionInput, |
| 16 | TaskCreateInput, |
| 17 | TaskCreateOutput, |
| 18 | TaskListOutput, |
| 19 | TaskUpdateInput, |
| 20 | TaskUpdateOutput, |
| 21 | } from "@anthropic-ai/claude-agent-sdk/sdk-tools"; |
| 15 | 22 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 16 | 23 | import { |
| 17 | 24 | ChatAttachmentId, |
| ... | ... | @@ -26,6 +33,9 @@ import { |
| 26 | 33 | type OrchestrationV2ProviderThread, |
| 27 | 34 | ProjectId, |
| 28 | 35 | ProviderInstanceId, |
| 36 | type ProviderInstanceEnvironment, |
| 37 | ProviderDriverKind, |
| 38 | type ProviderReplayTranscript, |
| 29 | 39 | type ProviderApprovalDecision, |
| 30 | 40 | ProviderSessionId, |
| 31 | 41 | ProviderTurnId, |
| ... | ... | @@ -74,6 +84,17 @@ import type * as ProviderContinuationRequests from "@t3tools/provider-core/serve |
| 74 | 84 | import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; |
| 75 | 85 | import * as ClaudeAdapterV2 from "./ClaudeAdapterV2.ts"; |
| 76 | 86 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; |
| 87 | import { ClaudeOrchestratorReplayHarness } from "./ClaudeAdapterV2.testkit.ts"; |
| 88 | import { provideDeterministicTestRuntime } from "../testkit/DeterministicRuntime.ts"; |
| 89 | import { runOrchestratorV2Scenario } from "../testkit/OrchestratorScenario.ts"; |
| 90 | import { layerProviderReplay } from "../testkit/ProviderReplayHarness.ts"; |
| 91 | import { |
| 92 | assertBaseProjection, |
| 93 | assertSemanticProjectionIntegrity, |
| 94 | CLAUDE_MODEL_SELECTION, |
| 95 | materializeFixtureInput, |
| 96 | projectionFor, |
| 97 | } from "../testkit/fixtures/shared.ts"; |
| 77 | 98 | |
| 78 | 99 | const DEFAULT_CLAUDE_SETTINGS = Schema.decodeSync(ClaudeSettings)({}); |
| 79 | 100 | const AUTO_COMPACT_CLAUDE_SETTINGS = Schema.decodeSync(ClaudeSettings)({ |
| ... | ... | @@ -1102,17 +1123,20 @@ describe("ClaudeAdapterV2 approval cancellation", () => { |
| 1102 | 1123 | ); |
| 1103 | 1124 | }); |
| 1104 | 1125 | |
| 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. |
| 1107 | 1129 | const captureSdkExecutablePaths = Effect.fn("captureSdkExecutablePaths")(function* ( |
| 1108 | 1130 | binaryPath: string, |
| 1131 | environment: ProviderInstanceEnvironment = [], |
| 1109 | 1132 | ) { |
| 1110 | 1133 | const executablePaths: Array<string | undefined> = []; |
| 1134 | const taskToolEnvironment: Array<string | undefined> = []; |
| 1111 | 1135 | const adapter = yield* ClaudeAdapterV2.createClaudeAdapterV2( |
| 1112 | 1136 | { |
| 1113 | 1137 | instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, |
| 1114 | 1138 | displayName: undefined, |
| 1115 | | environment: [], |
| 1139 | environment, |
| 1116 | 1140 | enabled: true, |
| 1117 | 1141 | config: { ...DEFAULT_CLAUDE_SETTINGS, binaryPath }, |
| 1118 | 1142 | }, |
| ... | ... | @@ -1128,6 +1152,7 @@ const captureSdkExecutablePaths = Effect.fn("captureSdkExecutablePaths")(functio |
| 1128 | 1152 | open: (input) => |
| 1129 | 1153 | Effect.sync(() => { |
| 1130 | 1154 | executablePaths.push(input.options.pathToClaudeCodeExecutable); |
| 1155 | taskToolEnvironment.push(input.options.env?.CLAUDE_CODE_ENABLE_TODO_TOOLS); |
| 1131 | 1156 | return { |
| 1132 | 1157 | messages: Stream.never, |
| 1133 | 1158 | offer: () => Effect.void, |
| ... | ... | @@ -1164,19 +1189,38 @@ const captureSdkExecutablePaths = Effect.fn("captureSdkExecutablePaths")(functio |
| 1164 | 1189 | attachments: [], |
| 1165 | 1190 | }), |
| 1166 | 1191 | ); |
| 1167 | | return executablePaths; |
| 1192 | return { executablePaths, taskToolEnvironment }; |
| 1168 | 1193 | }); |
| 1169 | 1194 | |
| 1170 | 1195 | describe("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) => |
| 1172 | 1211 | Effect.scoped( |
| 1173 | 1212 | Effect.gen(function* () { |
| 1174 | 1213 | 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 | ); |
| 1176 | 1218 | |
| 1177 | 1219 | assert.deepEqual(executablePaths, [path.join(NodeOS.homedir(), "bin", "claude")]); |
| 1220 | assert.deepEqual(taskToolEnvironment, [testCase.expected]); |
| 1178 | 1221 | }), |
| 1179 | 1222 | ).pipe( |
| 1223 | Effect.provideService(HostProcess.Environment, testCase.hostEnvironment), |
| 1180 | 1224 | Effect.provide( |
| 1181 | 1225 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), |
| 1182 | 1226 | ), |
| ... | ... | @@ -1188,7 +1232,7 @@ describe("ClaudeAdapterV2 executable path", () => { |
| 1188 | 1232 | Effect.gen(function* () { |
| 1189 | 1233 | const npmDir = "C:\\Users\\dev\\AppData\\Roaming\\npm"; |
| 1190 | 1234 | 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( |
| 1192 | 1236 | Effect.provideService(HostProcess.Platform, "win32"), |
| 1193 | 1237 | Effect.provideService(SpawnExecutableResolution, () => `${npmDir}\\claude.cmd`), |
| 1194 | 1238 | Effect.provideService(ClaudeExecutableFileCheck, (filePath) => filePath === packageExe), |
| ... | ... | @@ -3722,6 +3766,371 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 3722 | 3766 | ), |
| 3723 | 3767 | ); |
| 3724 | 3768 | |
| 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 | |
| 3725 | 4134 | it.effect("resolves API retries on resumed assistant activity", () => |
| 3726 | 4135 | Effect.scoped( |
| 3727 | 4136 | Effect.gen(function* () { |