diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 1f971a6126e..2da85f7f767 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -4159,6 +4159,449 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("prefers result text over assistant text when ExitPlanMode is skipped", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + yield* Stream.take(adapter.streamEvents, 3).pipe(Stream.runDrain); + + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "plan this", + interactionMode: "plan", + attachments: [], + }); + yield* Stream.take(adapter.streamEvents, 1).pipe(Stream.runDrain); + + const completedFiber = yield* adapter.streamEvents.pipe( + Stream.takeUntil((event) => event.type === "turn.completed"), + Stream.runCollect, + Effect.forkChild, + ); + + harness.query.emit({ + type: "assistant", + session_id: "sdk-session-plan-text", + uuid: "assistant-plan-text", + parent_tool_use_id: null, + message: { + model: "claude-opus-4-6", + id: "msg-plan-text", + type: "message", + role: "assistant", + content: [ + { + type: "text", + text: "# Draft notes", + }, + ], + stop_reason: null, + stop_sequence: null, + usage: {}, + }, + } as unknown as SDKMessage); + + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + result: "# Ship it\n\n- one\n- two", + session_id: "sdk-session-plan-text", + uuid: "result-plan-text", + } as unknown as SDKMessage); + + const runtimeEvents = Array.from(yield* Fiber.join(completedFiber)); + const proposedEvent = runtimeEvents.find((event) => event.type === "turn.proposed.completed"); + assert.equal(proposedEvent?.type, "turn.proposed.completed"); + if (proposedEvent?.type !== "turn.proposed.completed") { + return; + } + assert.equal(proposedEvent.payload.planMarkdown, "# Ship it\n\n- one\n- two"); + assert.equal(proposedEvent.raw?.source, "claude.sdk.message"); + assert.equal(proposedEvent.raw?.method, "claude/result/plan-text"); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect.each<{ + title: string; + interactionMode?: "plan" | "default"; + assistantText?: string; + resultExtras?: Record; + }>([ + { + title: "does not emit proposed plan outside plan mode", + assistantText: "# Ship it\n\n- one\n- two", + resultExtras: { result: "# Ship it\n\n- one\n- two" }, + }, + { + title: "does not emit proposed plan when result and assistant text are empty", + interactionMode: "plan", + }, + { + title: "does not emit proposed plan when the result is a refusal", + interactionMode: "plan", + assistantText: "# Ship it\n\n- one\n- two", + resultExtras: { result: "# Ship it\n\n- one\n- two", stop_reason: "refusal" }, + }, + ])("$title", ({ interactionMode, assistantText, resultExtras }) => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + yield* Stream.take(adapter.streamEvents, 3).pipe(Stream.runDrain); + + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "plan this", + ...(interactionMode ? { interactionMode } : {}), + attachments: [], + }); + yield* Stream.take(adapter.streamEvents, 1).pipe(Stream.runDrain); + + const completedFiber = yield* adapter.streamEvents.pipe( + Stream.takeUntil((event) => event.type === "turn.completed"), + Stream.runCollect, + Effect.forkChild, + ); + + if (assistantText !== undefined) { + harness.query.emit({ + type: "assistant", + session_id: "sdk-session-plan-text-skip", + uuid: "assistant-plan-text-skip", + parent_tool_use_id: null, + message: { + model: "claude-opus-4-6", + id: "msg-plan-text-skip", + type: "message", + role: "assistant", + content: [{ type: "text", text: assistantText }], + stop_reason: null, + stop_sequence: null, + usage: {}, + }, + } as unknown as SDKMessage); + } + + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: "sdk-session-plan-text-skip", + uuid: "result-plan-text-skip", + ...resultExtras, + } as unknown as SDKMessage); + + const runtimeEvents = Array.from(yield* Fiber.join(completedFiber)); + assert.equal( + runtimeEvents.some((event) => event.type === "turn.proposed.completed"), + false, + ); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("does not emit a second proposed plan after ExitPlanMode", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + yield* Stream.take(adapter.streamEvents, 3).pipe(Stream.runDrain); + + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "plan this", + interactionMode: "plan", + attachments: [], + }); + yield* Stream.take(adapter.streamEvents, 1).pipe(Stream.runDrain); + + const completedFiber = yield* adapter.streamEvents.pipe( + Stream.takeUntil((event) => event.type === "turn.completed"), + Stream.runCollect, + Effect.forkChild, + ); + + harness.query.emit({ + type: "assistant", + session_id: "sdk-session-exit-then-result", + uuid: "assistant-exit-then-result", + parent_tool_use_id: null, + message: { + model: "claude-opus-4-6", + id: "msg-exit-then-result", + type: "message", + role: "assistant", + content: [ + { + type: "text", + text: "# Final plan\n\n- capture it", + }, + { + type: "tool_use", + id: "tool-exit-dedupe", + name: "ExitPlanMode", + input: { + plan: "# Final plan\n\n- capture it", + }, + }, + ], + stop_reason: null, + stop_sequence: null, + usage: {}, + }, + } as unknown as SDKMessage); + + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + result: "# Final plan\n\n- capture it", + session_id: "sdk-session-exit-then-result", + uuid: "result-exit-then-result", + } as unknown as SDKMessage); + + const runtimeEvents = Array.from(yield* Fiber.join(completedFiber)); + const proposedEvents = runtimeEvents.filter( + (event) => event.type === "turn.proposed.completed", + ); + assert.equal(proposedEvents.length, 1); + assert.equal(proposedEvents[0]?.raw?.method, "claude/assistant"); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("does not emit proposed plan from a later synthetic turn", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + yield* Stream.take(adapter.streamEvents, 3).pipe(Stream.runDrain); + + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "plan this", + interactionMode: "plan", + attachments: [], + }); + yield* Stream.take(adapter.streamEvents, 1).pipe(Stream.runDrain); + + const firstCompletedFiber = yield* adapter.streamEvents.pipe( + Stream.takeUntil((event) => event.type === "turn.completed"), + Stream.runCollect, + Effect.forkChild, + ); + + harness.query.emit({ + type: "assistant", + session_id: "sdk-session-plan-then-synthetic", + uuid: "assistant-plan-then-synthetic", + parent_tool_use_id: null, + message: { + model: "claude-opus-4-6", + id: "msg-plan-then-synthetic", + type: "message", + role: "assistant", + content: [{ type: "text", text: "# Ship it\n\n- one\n- two" }], + stop_reason: null, + stop_sequence: null, + usage: {}, + }, + } as unknown as SDKMessage); + + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + result: "# Ship it\n\n- one\n- two", + session_id: "sdk-session-plan-then-synthetic", + uuid: "result-plan-then-synthetic", + } as unknown as SDKMessage); + + const firstEvents = Array.from(yield* Fiber.join(firstCompletedFiber)); + assert.equal( + firstEvents.some((event) => event.type === "turn.proposed.completed"), + true, + ); + + const secondCompletedFiber = yield* adapter.streamEvents.pipe( + Stream.takeUntil((event) => event.type === "turn.completed"), + Stream.runCollect, + Effect.forkChild, + ); + + harness.query.emit({ + type: "assistant", + session_id: "sdk-session-plan-then-synthetic", + uuid: "assistant-synthetic-followup", + parent_tool_use_id: null, + message: { + model: "claude-opus-4-6", + id: "msg-synthetic-followup", + type: "message", + role: "assistant", + content: [{ type: "text", text: "background agent notes" }], + stop_reason: null, + stop_sequence: null, + usage: {}, + }, + } as unknown as SDKMessage); + + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + result: "background agent notes", + session_id: "sdk-session-plan-then-synthetic", + uuid: "result-synthetic-followup", + } as unknown as SDKMessage); + + const secondEvents = Array.from(yield* Fiber.join(secondCompletedFiber)); + assert.equal( + secondEvents.some((event) => event.type === "turn.proposed.completed"), + false, + ); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("does not emit proposed plan while user input is pending", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + yield* Stream.take(adapter.streamEvents, 3).pipe(Stream.runDrain); + + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "plan this", + interactionMode: "plan", + attachments: [], + }); + yield* Stream.take(adapter.streamEvents, 1).pipe(Stream.runDrain); + + const canUseTool = harness.getLastCreateQueryInput()?.options.canUseTool; + assert.equal(typeof canUseTool, "function"); + if (!canUseTool) { + return; + } + + const permissionPromise = canUseTool( + "AskUserQuestion", + { + questions: [ + { + question: "Which framework?", + header: "Framework", + options: [{ label: "React", description: "React.js" }], + multiSelect: false, + }, + ], + }, + { + signal: new AbortController().signal, + toolUseID: "tool-ask-pending-plan", + }, + ); + + const requestedEvent = yield* Stream.runHead(adapter.streamEvents); + assert.equal(requestedEvent._tag, "Some"); + if (requestedEvent._tag !== "Some" || requestedEvent.value.type !== "user-input.requested") { + return; + } + + const completedFiber = yield* adapter.streamEvents.pipe( + Stream.takeUntil((event) => event.type === "turn.completed"), + Stream.runCollect, + Effect.forkChild, + ); + + harness.query.emit({ + type: "assistant", + session_id: "sdk-session-pending-input", + uuid: "assistant-pending-input", + parent_tool_use_id: null, + message: { + model: "claude-opus-4-6", + id: "msg-pending-input", + type: "message", + role: "assistant", + content: [{ type: "text", text: "# Ship it\n\n- one\n- two" }], + stop_reason: null, + stop_sequence: null, + usage: {}, + }, + } as unknown as SDKMessage); + + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + result: "# Ship it\n\n- one\n- two", + session_id: "sdk-session-pending-input", + uuid: "result-pending-input", + } as unknown as SDKMessage); + + const runtimeEvents = Array.from(yield* Fiber.join(completedFiber)); + assert.equal( + runtimeEvents.some((event) => event.type === "turn.proposed.completed"), + false, + ); + + yield* adapter.stopSession(session.threadId); + yield* Effect.promise(() => permissionPromise); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("handles AskUserQuestion via user-input.requested/resolved lifecycle", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 5715b68a1e4..70fc8e1b36e 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -279,6 +279,11 @@ interface ClaudeSessionContext { lastAssistantUuid: string | undefined; lastThreadStartedId: string | undefined; stopped: boolean; + /** + * Set only from sendTurn's interactionMode plan/default branches. + * Absent interactionMode leaves the previous value unchanged. + */ + inPlanMode: boolean; } interface ClaudeQueryRuntime extends AsyncIterable { @@ -1422,6 +1427,20 @@ function extractExitPlanModePlan(value: unknown): string | undefined { : undefined; } +function planMarkdownFromSkippedExit(turnState: ClaudeTurnState, result: SDKResultMessage): string { + if (result.subtype === "success" && typeof result.result === "string") { + const fromResult = result.result.trim(); + if (fromResult.length > 0) { + return fromResult; + } + } + + return turnState.assistantTextBlockOrder + .map((block) => block.fallbackText) + .join("") + .trim(); +} + function exitPlanCaptureKey(input: { readonly toolUseId?: string | undefined; readonly planMarkdown: string; @@ -2345,6 +2364,33 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }); } + // Plan-mode turns that skip ExitPlanMode still captured the plan on + // the success result (assistant text is the fallback). Arm Implement + // through the existing emit helper. + // capturedProposedPlanKeys.size (not the helper's capture key) is the + // double-emit gate: ExitPlanMode stores tool:${id}, this path would + // store plan:${markdown}. Skip synthetic auto-started turns — they + // inherit session inPlanMode after a real plan turn. + if ( + result !== undefined && + status === "completed" && + result.stop_reason !== "refusal" && + context.inPlanMode && + turnState.synthetic !== true && + turnState.capturedProposedPlanKeys.size === 0 && + context.pendingUserInputs.size === 0 + ) { + const planMarkdown = planMarkdownFromSkippedExit(turnState, result); + if (planMarkdown.length > 0) { + yield* emitProposedPlanCompleted(context, { + planMarkdown, + rawSource: "claude.sdk.message", + rawMethod: "claude/result/plan-text", + rawPayload: result, + }); + } + } + context.turns.push({ id: turnState.turnId, items: [...turnState.items], @@ -4275,6 +4321,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( lastAssistantUuid: resumeState?.resumeSessionAt, lastThreadStartedId: undefined, stopped: false, + inPlanMode: false, }; yield* Ref.set(contextRef, context); sessions.set(threadId, context); @@ -4402,11 +4449,13 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( try: () => context.query.setPermissionMode("plan"), catch: (cause) => toRequestError(input.threadId, "turn/setPermissionMode", cause), }); + context.inPlanMode = true; } else if (input.interactionMode === "default") { yield* Effect.tryPromise({ try: () => context.query.setPermissionMode(context.basePermissionMode ?? "default"), catch: (cause) => toRequestError(input.threadId, "turn/setPermissionMode", cause), }); + context.inPlanMode = false; } const turnId = steeringTurnState?.turnId ?? TurnId.make(yield* randomUUIDv4);