| author | |
| committer | |
| log | aad732901e4b7d485574eaef5a9c1fb388c4291a |
| tree | 9548ed0ea2bd2ddcb4d8a0788d1bf12afd1ad43a |
| parent | 0080e80c00a3e333e3dcafd85b2615d56f84e3b8 |
| signature | Signed by PGP key B5690EEEBB952194 |
3 files changed, 131 insertions(+), 25 deletions(-)
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts+36| ... | ... | @@ -947,6 +947,7 @@ describe("ClaudeAdapterV2 Auto-accept edits", () => { |
| 947 | 947 | messages: Stream.never, |
| 948 | 948 | offer: () => Effect.void, |
| 949 | 949 | setModel: () => Effect.void, |
| 950 | setPermissionMode: () => Effect.void, | |
| 950 | 951 | interrupt: Effect.void, |
| 951 | 952 | close: Effect.void, |
| 952 | 953 | }; |
| ... | ... | @@ -1097,6 +1098,7 @@ const captureSdkExecutablePaths = Effect.fn("captureSdkExecutablePaths")(functio |
| 1097 | 1098 | messages: Stream.never, |
| 1098 | 1099 | offer: () => Effect.void, |
| 1099 | 1100 | setModel: () => Effect.void, |
| 1101 | setPermissionMode: () => Effect.void, | |
| 1100 | 1102 | interrupt: Effect.void, |
| 1101 | 1103 | close: Effect.void, |
| 1102 | 1104 | }; |
| ... | ... | @@ -1187,6 +1189,7 @@ describe("ClaudeAdapterV2 resume compaction", () => { |
| 1187 | 1189 | messages: Stream.never, |
| 1188 | 1190 | offer: () => Effect.void, |
| 1189 | 1191 | setModel: () => Effect.void, |
| 1192 | setPermissionMode: () => Effect.void, | |
| 1190 | 1193 | interrupt: Effect.void, |
| 1191 | 1194 | close: Effect.void, |
| 1192 | 1195 | }; |
| ... | ... | @@ -1406,6 +1409,7 @@ describe("ClaudeAdapterV2 attachments", () => { |
| 1406 | 1409 | offeredMessages.push(message); |
| 1407 | 1410 | }), |
| 1408 | 1411 | setModel: () => Effect.void, |
| 1412 | setPermissionMode: () => Effect.void, | |
| 1409 | 1413 | interrupt: Effect.void, |
| 1410 | 1414 | close: Effect.void, |
| 1411 | 1415 | }), |
| ... | ... | @@ -1544,6 +1548,7 @@ describe("ClaudeAdapterV2 attachments", () => { |
| 1544 | 1548 | messages: Stream.never, |
| 1545 | 1549 | offer: () => Effect.void, |
| 1546 | 1550 | setModel: () => Effect.void, |
| 1551 | setPermissionMode: () => Effect.void, | |
| 1547 | 1552 | interrupt: Effect.void, |
| 1548 | 1553 | close: Effect.void, |
| 1549 | 1554 | }; |
| ... | ... | @@ -1632,6 +1637,7 @@ describe("ClaudeAdapterV2 native fork", () => { |
| 1632 | 1637 | messages: Stream.empty, |
| 1633 | 1638 | offer: () => Effect.void, |
| 1634 | 1639 | setModel: () => Effect.void, |
| 1640 | setPermissionMode: () => Effect.void, | |
| 1635 | 1641 | interrupt: Effect.void, |
| 1636 | 1642 | close: Effect.void, |
| 1637 | 1643 | }; |
| ... | ... | @@ -1803,6 +1809,7 @@ describe("ClaudeAdapterV2 native session identity", () => { |
| 1803 | 1809 | messages: Stream.empty, |
| 1804 | 1810 | offer: () => Effect.void, |
| 1805 | 1811 | setModel: () => Effect.void, |
| 1812 | setPermissionMode: () => Effect.void, | |
| 1806 | 1813 | interrupt: Effect.void, |
| 1807 | 1814 | close: Effect.void, |
| 1808 | 1815 | }; |
| ... | ... | @@ -2058,6 +2065,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 2058 | 2065 | yield* Deferred.await(processed); |
| 2059 | 2066 | }); |
| 2060 | 2067 | const offeredMessages: Array<SDKUserMessage> = []; |
| 2068 | const permissionModeChanges: Array<string> = []; | |
| 2061 | 2069 | const continuationRequests: Array<ProviderContinuationRequest> = []; |
| 2062 | 2070 | const terminalReceipts = |
| 2063 | 2071 | yield* Queue.unbounded<Extract<ProviderAdapterV2Event, { type: "turn.terminal" }>>(); |
| ... | ... | @@ -2106,6 +2114,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 2106 | 2114 | offeredMessages.push(message); |
| 2107 | 2115 | }), |
| 2108 | 2116 | setModel: () => Effect.void, |
| 2117 | setPermissionMode: (mode) => | |
| 2118 | Effect.sync(() => { | |
| 2119 | permissionModeChanges.push(mode); | |
| 2120 | }), | |
| 2109 | 2121 | interrupt: options?.interrupt ?? Effect.void, |
| 2110 | 2122 | close: options?.close?.(sdkMessages) ?? Effect.void, |
| 2111 | 2123 | }; |
| ... | ... | @@ -2158,6 +2170,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 2158 | 2170 | sdkMessages, |
| 2159 | 2171 | offerAndWait, |
| 2160 | 2172 | offeredMessages, |
| 2173 | permissionModeChanges, | |
| 2161 | 2174 | continuationRequests, |
| 2162 | 2175 | events, |
| 2163 | 2176 | terminalReceipts, |
| ... | ... | @@ -2985,6 +2998,18 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 2985 | 2998 | harness.sdkMessages, |
| 2986 | 2999 | toolResults("00000000-0000-4000-8000-000000000502", ["tool-todo-1"]), |
| 2987 | 3000 | ); |
| 3001 | // Claude entered plan mode on its own (EnterPlanMode). | |
| 3002 | yield* Queue.offer( | |
| 3003 | harness.sdkMessages, | |
| 3004 | claudeSdkFrame({ | |
| 3005 | type: "system", | |
| 3006 | subtype: "status", | |
| 3007 | status: null, | |
| 3008 | permissionMode: "plan", | |
| 3009 | uuid: "00000000-0000-4000-8000-000000000508", | |
| 3010 | session_id: WAKE_NATIVE_SESSION, | |
| 3011 | }), | |
| 3012 | ); | |
| 2988 | 3013 | yield* Queue.offer( |
| 2989 | 3014 | harness.sdkMessages, |
| 2990 | 3015 | makeResultFrame({ |
| ... | ... | @@ -3095,6 +3120,9 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 3095 | 3120 | ); |
| 3096 | 3121 | const proposedPlan = [...plans.values()].find((plan) => plan.kind === "proposed_plan"); |
| 3097 | 3122 | assert.equal(proposedPlan?.status, "active"); |
| 3123 | // The second prompt reuses the live process, which is still in the | |
| 3124 | // plan mode Claude entered, so it is put back in the thread's mode. | |
| 3125 | assert.deepEqual(harness.permissionModeChanges, ["bypassPermissions"]); | |
| 3098 | 3126 | }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), |
| 3099 | 3127 | ), |
| 3100 | 3128 | ); |
| ... | ... | @@ -3615,6 +3643,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 3615 | 3643 | messages: Stream.fromQueue(sdkMessages), |
| 3616 | 3644 | offer: () => Effect.void, |
| 3617 | 3645 | setModel: () => Effect.void, |
| 3646 | setPermissionMode: () => Effect.void, | |
| 3618 | 3647 | interrupt: Effect.void, |
| 3619 | 3648 | // The first CLI process keeps streaming until the test ends |
| 3620 | 3649 | // it, so Stop stays parked waiting for it to exit. |
| ... | ... | @@ -3864,6 +3893,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 3864 | 3893 | messages: Stream.fromQueue(queue), |
| 3865 | 3894 | offer: () => Effect.void, |
| 3866 | 3895 | setModel: () => Effect.void, |
| 3896 | setPermissionMode: () => Effect.void, | |
| 3867 | 3897 | interrupt: Effect.void, |
| 3868 | 3898 | close: Queue.shutdown(queue), |
| 3869 | 3899 | }; |
| ... | ... | @@ -6655,6 +6685,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 6655 | 6685 | messages: Stream.fromQueue(sdkMessages), |
| 6656 | 6686 | offer: () => Effect.void, |
| 6657 | 6687 | setModel: () => Effect.void, |
| 6688 | setPermissionMode: () => Effect.void, | |
| 6658 | 6689 | interrupt: Effect.void, |
| 6659 | 6690 | // End this process stream so openQuery can replace it. |
| 6660 | 6691 | close: Queue.shutdown(sdkMessages), |
| ... | ... | @@ -6837,6 +6868,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 6837 | 6868 | messages: Stream.fromQueue(sdkMessages), |
| 6838 | 6869 | offer: () => Effect.void, |
| 6839 | 6870 | setModel: () => Effect.void, |
| 6871 | setPermissionMode: () => Effect.void, | |
| 6840 | 6872 | interrupt: Effect.void, |
| 6841 | 6873 | close: Queue.shutdown(sdkMessages), |
| 6842 | 6874 | }; |
| ... | ... | @@ -7071,6 +7103,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 7071 | 7103 | messages: Stream.fromQueue(sdkMessages), |
| 7072 | 7104 | offer: () => Effect.void, |
| 7073 | 7105 | setModel: () => Effect.void, |
| 7106 | setPermissionMode: () => Effect.void, | |
| 7074 | 7107 | interrupt: Effect.void, |
| 7075 | 7108 | close: Queue.shutdown(sdkMessages), |
| 7076 | 7109 | }; |
| ... | ... | @@ -7257,6 +7290,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 7257 | 7290 | messages: Stream.fromQueue(sdkMessages), |
| 7258 | 7291 | offer: () => Effect.void, |
| 7259 | 7292 | setModel: () => Effect.void, |
| 7293 | setPermissionMode: () => Effect.void, | |
| 7260 | 7294 | interrupt: Effect.void, |
| 7261 | 7295 | close: Queue.shutdown(sdkMessages), |
| 7262 | 7296 | }; |
| ... | ... | @@ -7433,6 +7467,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 7433 | 7467 | messages: Stream.fromQueue(sdkMessages), |
| 7434 | 7468 | offer: () => Effect.void, |
| 7435 | 7469 | setModel: () => Effect.void, |
| 7470 | setPermissionMode: () => Effect.void, | |
| 7436 | 7471 | interrupt: Effect.void, |
| 7437 | 7472 | close: Queue.shutdown(sdkMessages), |
| 7438 | 7473 | }; |
| ... | ... | @@ -7564,6 +7599,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 7564 | 7599 | messages: Stream.fromQueue(sdkMessages), |
| 7565 | 7600 | offer: () => Effect.void, |
| 7566 | 7601 | setModel: () => Effect.void, |
| 7602 | setPermissionMode: () => Effect.void, | |
| 7567 | 7603 | interrupt: Effect.void, |
| 7568 | 7604 | close: Queue.shutdown(sdkMessages), |
| 7569 | 7605 | }; |
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts+13| ... | ... | @@ -184,6 +184,11 @@ interface ClaudeQuerySetModelFrame { |
| 184 | 184 | readonly model: string; |
| 185 | 185 | } |
| 186 | 186 | |
| 187 | interface ClaudeQuerySetPermissionModeFrame { | |
| 188 | readonly type: "query.set_permission_mode"; | |
| 189 | readonly mode: string; | |
| 190 | } | |
| 191 | ||
| 187 | 192 | interface ClaudeQueryInterruptFrame { |
| 188 | 193 | readonly type: "query.interrupt"; |
| 189 | 194 | } |
| ... | ... | @@ -239,6 +244,7 @@ type ClaudeOutboundFrame = |
| 239 | 244 | | ClaudeQueryOpenFrame |
| 240 | 245 | | ClaudePromptOfferFrame |
| 241 | 246 | | ClaudeQuerySetModelFrame |
| 247 | | ClaudeQuerySetPermissionModeFrame | |
| 242 | 248 | | ClaudeQueryInterruptFrame |
| 243 | 249 | | ClaudePermissionResponseFrame |
| 244 | 250 | | ClaudeSessionForkFrame |
| ... | ... | @@ -811,6 +817,13 @@ function makeReplayQueryRunner( |
| 811 | 817 | model, |
| 812 | 818 | }); |
| 813 | 819 | }), |
| 820 | setPermissionMode: (mode) => | |
| 821 | replayEffect(() => { | |
| 822 | assertNextOutboundFrame({ | |
| 823 | type: "query.set_permission_mode", | |
| 824 | mode, | |
| 825 | }); | |
| 826 | }), | |
| 814 | 827 | interrupt: replayEffect(() => { |
| 815 | 828 | assertNextOutboundFrame({ type: "query.interrupt" }); |
| 816 | 829 | }), |
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts+82-25| ... | ... | @@ -327,6 +327,9 @@ export interface ClaudeAgentSdkQuerySession { |
| 327 | 327 | readonly messages: Stream.Stream<SDKMessage, ClaudeAgentSdkQueryRunnerError>; |
| 328 | 328 | readonly offer: (message: SDKUserMessage) => Effect.Effect<void, ClaudeAgentSdkQueryRunnerError>; |
| 329 | 329 | readonly setModel: (model: string) => Effect.Effect<void, ClaudeAgentSdkQueryRunnerError>; |
| 330 | readonly setPermissionMode: ( | |
| 331 | mode: PermissionMode, | |
| 332 | ) => Effect.Effect<void, ClaudeAgentSdkQueryRunnerError>; | |
| 330 | 333 | readonly interrupt: Effect.Effect<void, ClaudeAgentSdkQueryRunnerError>; |
| 331 | 334 | readonly close: Effect.Effect<void, ClaudeAgentSdkQueryRunnerError>; |
| 332 | 335 | } |
| ... | ... | @@ -466,6 +469,14 @@ export type ClaudeAgentSdkProtocolLogEvent = |
| 466 | 469 | readonly model: string; |
| 467 | 470 | }; |
| 468 | 471 | } |
| 472 | | { | |
| 473 | readonly direction: "outgoing"; | |
| 474 | readonly stage: "decoded"; | |
| 475 | readonly payload: { | |
| 476 | readonly type: "query.set_permission_mode"; | |
| 477 | readonly mode: PermissionMode; | |
| 478 | }; | |
| 479 | } | |
| 469 | 480 | | { |
| 470 | 481 | readonly direction: "outgoing"; |
| 471 | 482 | readonly stage: "decoded"; |
| ... | ... | @@ -670,6 +681,22 @@ export const claudeAgentSdkQueryRunnerLiveLayer: Layer.Layer< |
| 670 | 681 | }), |
| 671 | 682 | ), |
| 672 | 683 | ), |
| 684 | setPermissionMode: (mode) => | |
| 685 | Effect.tryPromise({ | |
| 686 | try: () => queryRuntime.setPermissionMode(mode), | |
| 687 | catch: (cause) => queryRunnerError(cause, "setPermissionMode"), | |
| 688 | }).pipe( | |
| 689 | Effect.tap(() => | |
| 690 | logProtocolEvent({ | |
| 691 | direction: "outgoing", | |
| 692 | stage: "decoded", | |
| 693 | payload: { | |
| 694 | type: "query.set_permission_mode", | |
| 695 | mode, | |
| 696 | }, | |
| 697 | }), | |
| 698 | ), | |
| 699 | ), | |
| 673 | 700 | interrupt: Effect.tryPromise({ |
| 674 | 701 | try: () => queryRuntime.interrupt(), |
| 675 | 702 | catch: (cause) => queryRunnerError(cause, "interrupt"), |
| ... | ... | @@ -2688,6 +2715,11 @@ interface ClaudeLiveQueryContext { |
| 2688 | 2715 | // uuid before any echo, so it echoes, but a resume's own turns can still |
| 2689 | 2716 | // run ahead of that prompt. |
| 2690 | 2717 | promptEchoMode: "unknown" | "acknowledged" | "early" | "result_only"; |
| 2718 | // The mode this process was opened in, and the mode the CLI last reported | |
| 2719 | // (init and status frames). Claude changes the latter itself through | |
| 2720 | // EnterPlanMode. | |
| 2721 | readonly openedPermissionMode: PermissionMode; | |
| 2722 | permissionMode: PermissionMode; | |
| 2691 | 2723 | // Stop, rollback or fork is closing this process; its work is ending. |
| 2692 | 2724 | stopping: boolean; |
| 2693 | 2725 | // Registry entries still running when this process opened. Their process |
| ... | ... | @@ -6843,10 +6875,23 @@ export function makeClaudeAdapterV2( |
| 6843 | 6875 | if ( |
| 6844 | 6876 | existing !== null && |
| 6845 | 6877 | existing.nativeThreadId === nativeThreadId && |
| 6846 | (isClaudeProviderContinuationTurn(turnInput) || | |
| 6847 | (existing.queryPolicyKey === queryPolicyKey && | |
| 6848 | existing.selectionKey === compiledSelection.queryIdentity)) | |
| 6878 | isClaudeProviderContinuationTurn(turnInput) | |
| 6879 | ) { | |
| 6880 | return existing; | |
| 6881 | } | |
| 6882 | if ( | |
| 6883 | existing !== null && | |
| 6884 | existing.nativeThreadId === nativeThreadId && | |
| 6885 | existing.queryPolicyKey === queryPolicyKey && | |
| 6886 | existing.selectionKey === compiledSelection.queryIdentity | |
| 6849 | 6887 | ) { |
| 6888 | // Claude can switch its own mode mid-session (EnterPlanMode), and | |
| 6889 | // a denied ExitPlanMode leaves it there. Put the live process back | |
| 6890 | // in the thread's mode before the next prompt. | |
| 6891 | if (existing.permissionMode !== existing.openedPermissionMode) { | |
| 6892 | yield* existing.query.setPermissionMode(existing.openedPermissionMode); | |
| 6893 | existing.permissionMode = existing.openedPermissionMode; | |
| 6894 | } | |
| 6850 | 6895 | return existing; |
| 6851 | 6896 | } |
| 6852 | 6897 | |
| ... | ... | @@ -6889,31 +6934,32 @@ export function makeClaudeAdapterV2( |
| 6889 | 6934 | const hasPersistedProviderTurn = turnInput.providerTurnOrdinal > 1; |
| 6890 | 6935 | const shouldResume = |
| 6891 | 6936 | resumeSessionAt !== undefined || openedWithResume || hasPersistedProviderTurn; |
| 6937 | const queryOptions = makeClaudeQueryOptions({ | |
| 6938 | modelSelection: turnInput.modelSelection, | |
| 6939 | nativeThreadId, | |
| 6940 | resume: shouldResume, | |
| 6941 | ...(resumeSessionAt === undefined ? {} : { resumeSessionAt }), | |
| 6942 | cwd: turnInput.runtimePolicy.cwd, | |
| 6943 | attachmentsDir, | |
| 6944 | settings: adapterOptions.settings, | |
| 6945 | environment: adapterOptions.environment, | |
| 6946 | tools: queryPolicy.tools ?? CLAUDE_CODE_PRESET_TOOLS, | |
| 6947 | ...mcpOverrides, | |
| 6948 | permissionMode: queryPolicy.permissionMode, | |
| 6949 | ...(queryPolicy.allowDangerouslySkipPermissions === undefined | |
| 6950 | ? {} | |
| 6951 | : { | |
| 6952 | allowDangerouslySkipPermissions: queryPolicy.allowDangerouslySkipPermissions, | |
| 6953 | }), | |
| 6954 | canUseTool, | |
| 6955 | onUserDialog, | |
| 6956 | supportedDialogKinds: ["resume_return"], | |
| 6957 | }); | |
| 6892 | 6958 | const querySession = yield* queryRunner |
| 6893 | 6959 | .open({ |
| 6894 | 6960 | threadId: turnInput.threadId, |
| 6895 | 6961 | providerSessionId: input.providerSessionId, |
| 6896 | options: makeClaudeQueryOptions({ | |
| 6897 | modelSelection: turnInput.modelSelection, | |
| 6898 | nativeThreadId, | |
| 6899 | resume: shouldResume, | |
| 6900 | ...(resumeSessionAt === undefined ? {} : { resumeSessionAt }), | |
| 6901 | cwd: turnInput.runtimePolicy.cwd, | |
| 6902 | attachmentsDir, | |
| 6903 | settings: adapterOptions.settings, | |
| 6904 | environment: adapterOptions.environment, | |
| 6905 | tools: queryPolicy.tools ?? CLAUDE_CODE_PRESET_TOOLS, | |
| 6906 | ...mcpOverrides, | |
| 6907 | permissionMode: queryPolicy.permissionMode, | |
| 6908 | ...(queryPolicy.allowDangerouslySkipPermissions === undefined | |
| 6909 | ? {} | |
| 6910 | : { | |
| 6911 | allowDangerouslySkipPermissions: queryPolicy.allowDangerouslySkipPermissions, | |
| 6912 | }), | |
| 6913 | canUseTool, | |
| 6914 | onUserDialog, | |
| 6915 | supportedDialogKinds: ["resume_return"], | |
| 6916 | }), | |
| 6962 | options: queryOptions, | |
| 6917 | 6963 | }) |
| 6918 | 6964 | .pipe( |
| 6919 | 6965 | Effect.tapError(() => |
| ... | ... | @@ -6960,6 +7006,8 @@ export function makeClaudeAdapterV2( |
| 6960 | 7006 | selectionKey: compiledSelection.queryIdentity, |
| 6961 | 7007 | closed, |
| 6962 | 7008 | promptEchoMode: "unknown", |
| 7009 | openedPermissionMode: queryOptions.permissionMode, | |
| 7010 | permissionMode: queryOptions.permissionMode, | |
| 6963 | 7011 | stopping: false, |
| 6964 | 7012 | subagentsFromEarlierProcesses: new Set( |
| 6965 | 7013 | [...(yield* Ref.get(sessionSubagentsByTaskId)).values()].filter( |
| ... | ... | @@ -6969,7 +7017,16 @@ export function makeClaudeAdapterV2( |
| 6969 | 7017 | }; |
| 6970 | 7018 | yield* Ref.set(queryContext, context); |
| 6971 | 7019 | yield* querySession.messages.pipe( |
| 6972 | Stream.runForEach((message) => handleSdkMessage({ query: querySession, message })), | |
| 7020 | Stream.runForEach((message) => { | |
| 7021 | if ( | |
| 7022 | message.type === "system" && | |
| 7023 | (message.subtype === "init" || message.subtype === "status") && | |
| 7024 | message.permissionMode !== undefined | |
| 7025 | ) { | |
| 7026 | context.permissionMode = message.permissionMode; | |
| 7027 | } | |
| 7028 | return handleSdkMessage({ query: querySession, message }); | |
| 7029 | }), | |
| 6973 | 7030 | Effect.exit, |
| 6974 | 7031 | Effect.flatMap( |
| 6975 | 7032 | Effect.fnUntraced(function* (exit: ClaudeQueryStreamExit) { |