Skip to content

Commit bc8d02f

Browse files
fix(server): preserve interrupted Claude context after revert (#25695)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent b333c17 commit bc8d02f

2 files changed

Lines changed: 134 additions & 1 deletion

File tree

‎apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts‎

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5077,6 +5077,138 @@ describe("ClaudeAdapterV2 background wake turns", () => {
50775077
),
50785078
);
50795079

5080+
it.effect("an interrupted turn after a rollback stops resuming at the rolled-back head", () =>
5081+
Effect.gen(function* () {
5082+
const harness = yield* makeWakeHarnessWithOptions({
5083+
close: (sdkMessages) => Queue.shutdown(sdkMessages),
5084+
freshQueueOnReopen: true,
5085+
});
5086+
const now = yield* DateTime.now;
5087+
const pinnedHead = "00000000-0000-4000-8000-000000025510";
5088+
const pinnedThread = {
5089+
...harness.providerThread,
5090+
nativeConversationHeadRef: {
5091+
driver: ClaudeAdapterV2.CLAUDE_PROVIDER,
5092+
nativeId: pinnedHead,
5093+
strength: "weak" as const,
5094+
},
5095+
};
5096+
const interruptedInput = makeClaudeTestTurnInput({
5097+
threadId: harness.threadId,
5098+
providerThread: pinnedThread,
5099+
now,
5100+
attemptId: RunAttemptId.make("attempt-claude-interrupt-after-rollback"),
5101+
text: "The secret word is apricot-25510. Then run sleep 60.",
5102+
attachments: [],
5103+
providerTurnOrdinal: 2,
5104+
nativeThreadHasTurns: true,
5105+
});
5106+
yield* harness.runtime.startTurn(interruptedInput);
5107+
assert.equal(harness.getOpenedOptions()?.resumeSessionAt, pinnedHead);
5108+
yield* harness.offerAndWait(
5109+
makeAssistantTextFrame({
5110+
uuid: "00000000-0000-4000-8000-000000025511",
5111+
text: "Noted apricot-25510. Running sleep.",
5112+
}),
5113+
);
5114+
yield* harness.runtime.interruptTurn({
5115+
providerThread: pinnedThread,
5116+
providerTurnId: (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({
5117+
driver: ClaudeAdapterV2.CLAUDE_PROVIDER,
5118+
nativeTurnId: `turn:${interruptedInput.attemptId}`,
5119+
}),
5120+
});
5121+
assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "interrupted");
5122+
const afterInterrupt = harness.events.findLast(
5123+
(event) => event.type === "provider_thread.updated",
5124+
)?.providerThread;
5125+
assert.isDefined(afterInterrupt);
5126+
assert.isNull(afterInterrupt.nativeConversationHeadRef);
5127+
5128+
yield* harness.runtime.startTurn(
5129+
makeClaudeTestTurnInput({
5130+
threadId: harness.threadId,
5131+
providerThread: afterInterrupt,
5132+
now,
5133+
attemptId: RunAttemptId.make("attempt-claude-after-interrupt"),
5134+
text: "What is the secret word?",
5135+
attachments: [],
5136+
providerTurnOrdinal: 3,
5137+
nativeThreadHasTurns: true,
5138+
}),
5139+
);
5140+
assert.equal(harness.processQueues.length, 2);
5141+
assert.isUndefined(harness.getOpenedOptions()?.resumeSessionAt);
5142+
}).pipe(
5143+
Effect.provide(
5144+
Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
5145+
),
5146+
),
5147+
);
5148+
5149+
it.effect("an interrupted turn with no assistant output keeps the rolled-back head", () =>
5150+
Effect.gen(function* () {
5151+
const harness = yield* makeWakeHarnessWithOptions({
5152+
close: (sdkMessages) => Queue.shutdown(sdkMessages),
5153+
freshQueueOnReopen: true,
5154+
});
5155+
const now = yield* DateTime.now;
5156+
const pinnedHead = "00000000-0000-4000-8000-000000025510";
5157+
const pinnedThread = {
5158+
...harness.providerThread,
5159+
nativeConversationHeadRef: {
5160+
driver: ClaudeAdapterV2.CLAUDE_PROVIDER,
5161+
nativeId: pinnedHead,
5162+
strength: "weak" as const,
5163+
},
5164+
};
5165+
const interruptedInput = makeClaudeTestTurnInput({
5166+
threadId: harness.threadId,
5167+
providerThread: pinnedThread,
5168+
now,
5169+
attemptId: RunAttemptId.make("attempt-claude-silent-interrupt-after-rollback"),
5170+
text: "The secret word is apricot-25510. Then run sleep 60.",
5171+
attachments: [],
5172+
providerTurnOrdinal: 2,
5173+
nativeThreadHasTurns: true,
5174+
});
5175+
yield* harness.runtime.startTurn(interruptedInput);
5176+
assert.equal(harness.getOpenedOptions()?.resumeSessionAt, pinnedHead);
5177+
yield* harness.runtime.interruptTurn({
5178+
providerThread: pinnedThread,
5179+
providerTurnId: (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({
5180+
driver: ClaudeAdapterV2.CLAUDE_PROVIDER,
5181+
nativeTurnId: `turn:${interruptedInput.attemptId}`,
5182+
}),
5183+
});
5184+
assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "interrupted");
5185+
const afterInterrupt = harness.events.findLast(
5186+
(event) => event.type === "provider_thread.updated",
5187+
)?.providerThread;
5188+
assert.isDefined(afterInterrupt);
5189+
assert.equal(afterInterrupt.nativeConversationHeadRef?.nativeId, pinnedHead);
5190+
5191+
yield* harness.runtime.startTurn(
5192+
makeClaudeTestTurnInput({
5193+
threadId: harness.threadId,
5194+
providerThread: afterInterrupt,
5195+
now,
5196+
attemptId: RunAttemptId.make("attempt-claude-after-silent-interrupt"),
5197+
text: "What is the secret word?",
5198+
attachments: [],
5199+
providerTurnOrdinal: 3,
5200+
nativeThreadHasTurns: true,
5201+
}),
5202+
);
5203+
assert.equal(harness.processQueues.length, 2);
5204+
assert.equal(harness.getOpenedOptions()?.resumeSessionAt, pinnedHead);
5205+
}).pipe(
5206+
Effect.provide(
5207+
Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
5208+
),
5209+
),
5210+
);
5211+
50805212
it.effect("never records a subagent uuid for a turn with no main-thread assistant message", () =>
50815213
Effect.gen(function* () {
50825214
const harness = yield* makeWakeHarnessWithOptions({

‎apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7257,8 +7257,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
72577257
yield* Ref.get(awaitedBackgroundTasksByNativeThread),
72587258
nativeThreadId,
72597259
);
7260+
// A stopped turn that wrote to the transcript moved past the pinned head.
72607261
const clearConversationHead =
7261-
input.status === "completed" &&
7262+
(input.status === "completed" || input.context.nativeMessageCursor !== null) &&
72627263
input.context.input.providerThread.nativeConversationHeadRef !== null;
72637264
const goal =
72647265
nativeThreadId === null ? undefined : goalsByNativeThread.get(nativeThreadId);

0 commit comments

Comments
 (0)