| ... | @@ -11,7 +11,14 @@ import type { | ... | @@ -11,7 +11,14 @@ import type { |
| 11 | SpawnedProcess, | 11 | SpawnedProcess, |
| 12 | SpawnOptions, | 12 | SpawnOptions, |
| 13 | } from "@anthropic-ai/claude-agent-sdk"; | 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 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 22 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 16 | import { | 23 | import { |
| 17 | ChatAttachmentId, | 24 | ChatAttachmentId, |
| ... | @@ -26,6 +33,9 @@ import { | ... | @@ -26,6 +33,9 @@ import { |
| 26 | type OrchestrationV2ProviderThread, | 33 | type OrchestrationV2ProviderThread, |
| 27 | ProjectId, | 34 | ProjectId, |
| 28 | ProviderInstanceId, | 35 | ProviderInstanceId, |
| | 36 | type ProviderInstanceEnvironment, |
| | 37 | ProviderDriverKind, |
| | 38 | type ProviderReplayTranscript, |
| 29 | type ProviderApprovalDecision, | 39 | type ProviderApprovalDecision, |
| 30 | ProviderSessionId, | 40 | ProviderSessionId, |
| 31 | ProviderTurnId, | 41 | ProviderTurnId, |
| ... | @@ -74,6 +84,17 @@ import type * as ProviderContinuationRequests from "@t3tools/provider-core/serve | ... | @@ -74,6 +84,17 @@ import type * as ProviderContinuationRequests from "@t3tools/provider-core/serve |
| 74 | import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; | 84 | import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; |
| 75 | import * as ClaudeAdapterV2 from "./ClaudeAdapterV2.ts"; | 85 | import * as ClaudeAdapterV2 from "./ClaudeAdapterV2.ts"; |
| 76 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; | 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 | const DEFAULT_CLAUDE_SETTINGS = Schema.decodeSync(ClaudeSettings)({}); | 99 | const DEFAULT_CLAUDE_SETTINGS = Schema.decodeSync(ClaudeSettings)({}); |
| 79 | const AUTO_COMPACT_CLAUDE_SETTINGS = Schema.decodeSync(ClaudeSettings)({ | 100 | const AUTO_COMPACT_CLAUDE_SETTINGS = Schema.decodeSync(ClaudeSettings)({ |
| ... | @@ -1102,17 +1123,20 @@ describe("ClaudeAdapterV2 approval cancellation", () => { | ... | @@ -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 | 1126 | // Opens a session with the given configured binary path and environment, runs |
| 1106 | // returns the executable paths the SDK was asked to spawn. | 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 | const captureSdkExecutablePaths = Effect.fn("captureSdkExecutablePaths")(function* ( | 1129 | const captureSdkExecutablePaths = Effect.fn("captureSdkExecutablePaths")(function* ( |
| 1108 | binaryPath: string, | 1130 | binaryPath: string, |
| | 1131 | environment: ProviderInstanceEnvironment = [], |
| 1109 | ) { | 1132 | ) { |
| 1110 | const executablePaths: Array<string | undefined> = []; | 1133 | const executablePaths: Array<string | undefined> = []; |
| | 1134 | const taskToolEnvironment: Array<string | undefined> = []; |
| 1111 | const adapter = yield* ClaudeAdapterV2.createClaudeAdapterV2( | 1135 | const adapter = yield* ClaudeAdapterV2.createClaudeAdapterV2( |
| 1112 | { | 1136 | { |
| 1113 | instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, | 1137 | instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, |
| 1114 | displayName: undefined, | 1138 | displayName: undefined, |
| 1115 | environment: [], | 1139 | environment, |
| 1116 | enabled: true, | 1140 | enabled: true, |
| 1117 | config: { ...DEFAULT_CLAUDE_SETTINGS, binaryPath }, | 1141 | config: { ...DEFAULT_CLAUDE_SETTINGS, binaryPath }, |
| 1118 | }, | 1142 | }, |
| ... | @@ -1128,6 +1152,7 @@ const captureSdkExecutablePaths = Effect.fn("captureSdkExecutablePaths")(functio | ... | @@ -1128,6 +1152,7 @@ const captureSdkExecutablePaths = Effect.fn("captureSdkExecutablePaths")(functio |
| 1128 | open: (input) => | 1152 | open: (input) => |
| 1129 | Effect.sync(() => { | 1153 | Effect.sync(() => { |
| 1130 | executablePaths.push(input.options.pathToClaudeCodeExecutable); | 1154 | executablePaths.push(input.options.pathToClaudeCodeExecutable); |
| | 1155 | taskToolEnvironment.push(input.options.env?.CLAUDE_CODE_ENABLE_TODO_TOOLS); |
| 1131 | return { | 1156 | return { |
| 1132 | messages: Stream.never, | 1157 | messages: Stream.never, |
| 1133 | offer: () => Effect.void, | 1158 | offer: () => Effect.void, |
| ... | @@ -1164,19 +1189,38 @@ const captureSdkExecutablePaths = Effect.fn("captureSdkExecutablePaths")(functio | ... | @@ -1164,19 +1189,38 @@ const captureSdkExecutablePaths = Effect.fn("captureSdkExecutablePaths")(functio |
| 1164 | attachments: [], | 1189 | attachments: [], |
| 1165 | }), | 1190 | }), |
| 1166 | ); | 1191 | ); |
| 1167 | return executablePaths; | 1192 | return { executablePaths, taskToolEnvironment }; |
| 1168 | }); | 1193 | }); |
| 1169 | | 1194 | |
| 1170 | describe("ClaudeAdapterV2 executable path", () => { | 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 | Effect.scoped( | 1211 | Effect.scoped( |
| 1173 | Effect.gen(function* () { | 1212 | Effect.gen(function* () { |
| 1174 | const path = yield* Path.Path; | 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 | assert.deepEqual(executablePaths, [path.join(NodeOS.homedir(), "bin", "claude")]); | 1219 | assert.deepEqual(executablePaths, [path.join(NodeOS.homedir(), "bin", "claude")]); |
| | 1220 | assert.deepEqual(taskToolEnvironment, [testCase.expected]); |
| 1178 | }), | 1221 | }), |
| 1179 | ).pipe( | 1222 | ).pipe( |
| | 1223 | Effect.provideService(HostProcess.Environment, testCase.hostEnvironment), |
| 1180 | Effect.provide( | 1224 | Effect.provide( |
| 1181 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | 1225 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), |
| 1182 | ), | 1226 | ), |
| ... | @@ -1188,7 +1232,7 @@ describe("ClaudeAdapterV2 executable path", () => { | ... | @@ -1188,7 +1232,7 @@ describe("ClaudeAdapterV2 executable path", () => { |
| 1188 | Effect.gen(function* () { | 1232 | Effect.gen(function* () { |
| 1189 | const npmDir = "C:\\Users\\dev\\AppData\\Roaming\\npm"; | 1233 | const npmDir = "C:\\Users\\dev\\AppData\\Roaming\\npm"; |
| 1190 | const packageExe = `${npmDir}\\node_modules\\@anthropic-ai\\claude-code\\bin\\claude.exe`; | 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 | Effect.provideService(HostProcess.Platform, "win32"), | 1236 | Effect.provideService(HostProcess.Platform, "win32"), |
| 1193 | Effect.provideService(SpawnExecutableResolution, () => `${npmDir}\\claude.cmd`), | 1237 | Effect.provideService(SpawnExecutableResolution, () => `${npmDir}\\claude.cmd`), |
| 1194 | Effect.provideService(ClaudeExecutableFileCheck, (filePath) => filePath === packageExe), | 1238 | Effect.provideService(ClaudeExecutableFileCheck, (filePath) => filePath === packageExe), |
| ... | @@ -3722,6 +3766,371 @@ describe("ClaudeAdapterV2 background wake turns", () => { | ... | @@ -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 | it.effect("resolves API retries on resumed assistant activity", () => | 4134 | it.effect("resolves API retries on resumed assistant activity", () => |
| 3726 | Effect.scoped( | 4135 | Effect.scoped( |
| 3727 | Effect.gen(function* () { | 4136 | Effect.gen(function* () { |