diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index fb17c34713fd8..3c98dc0e5dac5 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -5426,14 +5426,19 @@ describe("ClaudeAdapterV2 background wake turns", () => { const providerTurn = lastProviderTurn(harness.events); assert.notEqual(providerTurn.nativeTurnRef?.nativeId, subagentUuid); - const rollbackTo = (providerThreadTurns: ReadonlyArray) => + const rollbackTo = ( + providerThreadTurns: ReadonlyArray, + text = "Background task completed.", + targetTurn = providerTurn, + ) => harness.runtime.rollbackThread({ providerThread: harness.providerThread, target: { type: "provider_turn", checkpointId: CheckpointId.make("checkpoint-claude-cursor-subagent-only"), appRunOrdinal: 1, - providerTurn, + providerTurn: targetTurn, + message: { role: "user", text, attachments: [] }, }, providerThreadTurns, }); @@ -5450,6 +5455,64 @@ describe("ClaudeAdapterV2 background wake turns", () => { ]).pipe(Effect.flip); assert.instanceOf(error, ProviderAdapter.ProviderAdapterRollbackThreadError); assert.include(String(error.cause), "no SDK assistant message cursor"); + const earlierCursor = "00000000-0000-4000-8000-000000000648"; + const turnsAcrossSilent = [ + { + ...providerTurn, + id: ProviderTurnId.make("provider-turn-claude-cursor-earlier"), + ordinal: providerTurn.ordinal - 1, + nativeTurnRef: { + driver: ClaudeAdapterV2.CLAUDE_PROVIDER, + nativeId: earlierCursor, + strength: "weak", + }, + }, + providerTurn, + { + ...providerTurn, + id: ProviderTurnId.make("provider-turn-claude-cursor-next"), + ordinal: providerTurn.ordinal + 1, + }, + ] satisfies ReadonlyArray; + const semanticError = yield* rollbackTo(turnsAcrossSilent).pipe(Effect.flip); + assert.instanceOf(semanticError, ProviderAdapter.ProviderAdapterRollbackThreadError); + const pastSilentTurn = yield* rollbackTo(turnsAcrossSilent, "/compact"); + assert.equal( + pastSilentTurn.providerThread.nativeConversationHeadRef?.nativeId, + earlierCursor, + ); + const interruptedBeforeCompact = yield* rollbackTo( + [ + { + ...providerTurn, + id: ProviderTurnId.make("provider-turn-claude-cursor-earlier"), + ordinal: 1, + nativeTurnRef: { + driver: ClaudeAdapterV2.CLAUDE_PROVIDER, + nativeId: earlierCursor, + strength: "weak", + }, + }, + { + ...providerTurn, + id: ProviderTurnId.make("provider-turn-claude-interrupted-before-compact"), + ordinal: 2, + status: "interrupted", + }, + { ...providerTurn, ordinal: 3 }, + { + ...providerTurn, + id: ProviderTurnId.make("provider-turn-claude-cursor-next"), + ordinal: 4, + }, + ], + "/compact", + { ...providerTurn, ordinal: 3 }, + ).pipe(Effect.flip); + assert.instanceOf( + interruptedBeforeCompact, + ProviderAdapter.ProviderAdapterRollbackThreadError, + ); yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 496a0b2e77d0f..5f966fe05d680 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -1922,6 +1922,16 @@ const getNativeThreadId = Effect.fnUntraced(function* ( const isSyntheticClaudeTurnId = (nativeTurnId: string): boolean => nativeTurnId.startsWith("turn:"); +const claudeTurnCursor = (turn: OrchestrationV2ProviderTurn): string | null => { + const ref = turn.nativeTurnRef; + return ref !== null && + ref.driver === CLAUDE_PROVIDER && + ref.nativeId !== null && + !isSyntheticClaudeTurnId(ref.nativeId) + ? ref.nativeId + : null; +}; + const isTerminalProviderTurn = (turn: OrchestrationV2ProviderTurn): boolean => turn.status === "completed" || turn.status === "interrupted" || @@ -2008,14 +2018,9 @@ const resolveClaudeRollbackResumeSessionAt = Effect.fn( }); } - const nativeTurnRef = target.providerTurn.nativeTurnRef; - if ( - nativeTurnRef !== null && - nativeTurnRef.driver === CLAUDE_PROVIDER && - nativeTurnRef.nativeId !== null && - !isSyntheticClaudeTurnId(nativeTurnRef.nativeId) - ) { - return nativeTurnRef.nativeId; + const cursor = claudeTurnCursor(target.providerTurn); + if (cursor !== null) { + return cursor; } const providerTurnsAfterTarget = input.providerThreadTurns.filter( @@ -2025,6 +2030,18 @@ const resolveClaudeRollbackResumeSessionAt = Effect.fn( return null; } + // Only compaction can be omitted without dropping a retained user prompt. + if (target.message !== undefined && isCompactionCommandMessage(target.message)) { + const earlierTurn = input.providerThreadTurns + .filter((turn) => turn.ordinal < target.providerTurn.ordinal) + .toSorted((left, right) => left.ordinal - right.ordinal) + .at(-1); + const earlierCursor = earlierTurn === undefined ? null : claudeTurnCursor(earlierTurn); + if (earlierCursor != null) { + return earlierCursor; + } + } + return yield* new ProviderAdapter.ProviderAdapterRollbackThreadError({ driver: CLAUDE_PROVIDER, providerThreadId: input.providerThread.id, diff --git a/apps/server/src/orchestration-v2/CheckpointRollbackService.ts b/apps/server/src/orchestration-v2/CheckpointRollbackService.ts index 385de626cfdf8..120efef2eaeec 100644 --- a/apps/server/src/orchestration-v2/CheckpointRollbackService.ts +++ b/apps/server/src/orchestration-v2/CheckpointRollbackService.ts @@ -183,7 +183,11 @@ export const layer: Layer.Layer< const targetTurn = latestProviderTurnForAttempt(context.providerTurns, targetAttempt?.id) ?? context.providerTurns.find((turn) => turn.id === targetAttempt?.providerTurnId); - if (targetTurn === undefined || targetTurn.providerThreadId !== providerThread.id) { + if ( + targetAttempt === undefined || + targetTurn === undefined || + targetTurn.providerThreadId !== providerThread.id + ) { return yield* new CheckpointRollbackExecutionError({ reason: "provider-turn-unavailable", threadId: input.threadId, @@ -191,11 +195,21 @@ export const layer: Layer.Layer< checkpointId: input.checkpointId, }); } + const { attempt } = yield* projections.getProviderControlContext(input.threadId, { + providerThreadId: providerThread.id, + providerTurnId: targetTurn.id, + attemptId: targetAttempt.id, + }); + const message = + attempt === undefined + ? undefined + : yield* projections.getRunMessage(input.threadId, attempt.runId); return { type: "provider_turn" as const, checkpointId: checkpoint.id, appRunOrdinal: targetOrdinal, providerTurn: targetTurn, + ...(message === undefined ? {} : { message }), }; }); diff --git a/packages/provider-core/src/server/ProviderAdapter.ts b/packages/provider-core/src/server/ProviderAdapter.ts index e168512736c7c..adcf5ac0b11e7 100644 --- a/packages/provider-core/src/server/ProviderAdapter.ts +++ b/packages/provider-core/src/server/ProviderAdapter.ts @@ -475,6 +475,7 @@ export type ProviderAdapterV2RollbackTarget = readonly checkpointId: CheckpointId; readonly appRunOrdinal: number; readonly providerTurn: OrchestrationV2ProviderTurn; + readonly message?: Pick; }; export interface ProviderAdapterV2RollbackThreadInput {