diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 7917b8360946f0536d44d7725610e139bd54163d..f00c58d9635341b30be7eb656121734b11bfd415 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -1557,6 +1557,73 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("keeps a turn open past the result of a Claude-initiated turn", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.takeUntil((event) => event.type === "session.exited"), + Stream.runCollect, + Effect.forkChild, + ); + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + const turn = yield* adapter.sendTurn({ + threadId: session.threadId, + input: "/compact", + attachments: [], + }); + + // Recorded order after a resume: Claude first reports a background task + // the previous process left behind, then runs the queued `/compact`. + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + num_turns: 0, + origin: { kind: "task-notification" }, + session_id: "sdk-session-1", + uuid: "result-task-notification", + } as unknown as SDKMessage); + harness.query.emit({ + type: "system", + subtype: "compact_boundary", + compact_metadata: { trigger: "manual", pre_tokens: 959489, post_tokens: 10107 }, + session_id: "sdk-session-1", + uuid: "compact-boundary", + } as unknown as SDKMessage); + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + num_turns: 0, + user_message_uuid: turn.turnId, + user_message_uuids: [turn.turnId], + local_command: "compact", + session_id: "sdk-session-1", + uuid: "result-compact", + } as unknown as SDKMessage); + harness.query.finish(); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + const compactedIndex = runtimeEvents.findIndex( + (event) => event.type === "thread.state.changed" && event.payload.state === "compacted", + ); + const completedIndex = runtimeEvents.findIndex((event) => event.type === "turn.completed"); + assert.equal(runtimeEvents.filter((event) => event.type === "turn.completed").length, 1); + assert.equal(String(runtimeEvents[completedIndex]?.turnId), String(turn.turnId)); + assert.equal(String(runtimeEvents[compactedIndex]?.turnId), String(turn.turnId)); + assert.isAbove(completedIndex, compactedIndex); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("steers a running turn instead of opening a new one on mid-turn sendTurn", () => { 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 d50b3cbb9248da46ecf45d0af3ba12f6e8ac58c3..d971566217e60c677b49648b4f1fab2db79d0388 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -1692,6 +1692,27 @@ function isOverloadedResult(result: SDKResultMessage): boolean { return result.subtype === "success" && result.api_error_status === 529; } +/** + * True when a result answers a different Claude turn than the active real + * turn. Claude runs turns of its own between user prompts (a resumed session + * first reports background tasks the previous process left behind; peer + * messages wake the agent), and a queued prompt waits behind them. Real turns + * send their turn id as the prompt uuid, which newer CLIs echo in + * `user_message_uuids`. Claude-initiated turns echo nothing and carry a + * non-human `origin`. Results with neither field (older CLIs) still complete + * the active turn. + */ +function isResultForOtherTurn(result: SDKResultMessage, turn: ClaudeTurnState): boolean { + // A synthetic turn mirrors a Claude-initiated turn, so any result is its own. + if (turn.synthetic) return false; + const echoed = [ + ...(result.user_message_uuids ?? []), + ...(result.user_message_uuid ? [result.user_message_uuid] : []), + ]; + if (echoed.length > 0) return !echoed.includes(turn.turnId); + return result.origin !== undefined && result.origin.kind !== "human"; +} + /** Derives turn status and its error from the same provider result. */ function resultOutcome( result: SDKResultMessage, @@ -3486,6 +3507,18 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( } const turn = context.turnState; + if (turn && isResultForOtherTurn(message, turn)) { + // Completing here would end the user's turn before its prompt runs. A + // `/compact` would then compact with no turn open and leave the thread + // looking busy. + yield* Effect.logInfo("claude.turn.result-for-other-turn", { + threadId: context.session.threadId, + turnId: turn.turnId, + origin: message.origin?.kind, + numTurns: message.num_turns, + }); + return; + } const failureHint = turn?.authenticationFailureMessage ?? (turn && (turn.rejectedRateLimitTypes.size > 0 || turn.latestAssistantRateLimited)