Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 156 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
ChatAttachmentId,
ChatFileAttachment,
ChatImageAttachment,
CheckpointId,
ClaudeSettings,
EnvironmentId,
MessageId,
Expand Down Expand Up @@ -3326,6 +3327,161 @@ describe("ClaudeAdapterV2 background wake turns", () => {
),
);

const lastProviderTurn = (events: ReadonlyArray<ProviderAdapter.ProviderAdapterV2Event>) => {
const event = events.findLast((candidate) => candidate.type === "provider_turn.updated");
if (event?.type !== "provider_turn.updated") {
throw new Error("Expected a provider turn update.");
}
return event.providerTurn;
};
// Rollback closes the live query and waits for its message stream to end.
const makeRollbackHarness = makeWakeHarnessWithOptions({
close: (sdkMessages) => Queue.shutdown(sdkMessages),
});

it.effect(
"rolls back to the main-thread assistant message when a subagent frame comes last",
() =>
Effect.gen(function* () {
const harness = yield* makeRollbackHarness;
const now = yield* DateTime.now;
const mainUuid = "00000000-0000-4000-8000-000000000641";
yield* harness.runtime.startTurn(
makeClaudeTestTurnInput({
threadId: harness.threadId,
providerThread: harness.providerThread,
now,
attemptId: RunAttemptId.make("attempt-claude-cursor-main"),
text: "Continue.",
attachments: [],
}),
);
yield* Queue.offerAll(harness.sdkMessages, [
makeAssistantTextFrame({ uuid: mainUuid, text: "Delegating." }),
makeAssistantErrorFrame({
uuid: "00000000-0000-4000-8000-000000000642",
error: undefined,
parentToolUseId: "toolu_subagent",
}),
makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000643", result: "Done." }),
]);
yield* Queue.take(harness.terminalReceipts);

const providerTurn = lastProviderTurn(harness.events);
assert.equal(providerTurn.nativeTurnRef?.nativeId, mainUuid);
const rolledBack = yield* harness.runtime.rollbackThread({
providerThread: harness.providerThread,
target: {
type: "provider_turn",
checkpointId: CheckpointId.make("checkpoint-claude-cursor-main"),
appRunOrdinal: 1,
providerTurn,
},
providerThreadTurns: [
providerTurn,
{
...providerTurn,
id: ProviderTurnId.make("provider-turn-claude-cursor-next"),
ordinal: 2,
},
],
});
assert.equal(rolledBack.providerThread.nativeConversationHeadRef?.nativeId, mainUuid);
}).pipe(
Effect.provide(
Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
),
),
);

it.effect("never records a subagent uuid for a turn with no main-thread assistant message", () =>
Effect.gen(function* () {
// The continuation turn reopens the query, and rollback shut the first queue down.
const harness = yield* makeWakeHarnessWithOptions({
close: (sdkMessages) => Queue.shutdown(sdkMessages),
freshQueueOnReopen: true,
});
const now = yield* DateTime.now;
const subagentUuid = "00000000-0000-4000-8000-000000000644";
yield* harness.runtime.startTurn(
makeClaudeTestTurnInput({
threadId: harness.threadId,
providerThread: harness.providerThread,
now,
attemptId: RunAttemptId.make("attempt-claude-cursor-subagent-only"),
text: "Background task completed.",
attachments: [],
}),
);
yield* Queue.offerAll(harness.sdkMessages, [
makeAssistantErrorFrame({
uuid: subagentUuid,
error: undefined,
parentToolUseId: "toolu_subagent",
}),
makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000645", result: "Done." }),
]);
yield* Queue.take(harness.terminalReceipts);

const providerTurn = lastProviderTurn(harness.events);
assert.notEqual(providerTurn.nativeTurnRef?.nativeId, subagentUuid);
const rollbackTo = (providerThreadTurns: ReadonlyArray<typeof providerTurn>) =>
harness.runtime.rollbackThread({
providerThread: harness.providerThread,
target: {
type: "provider_turn",
checkpointId: CheckpointId.make("checkpoint-claude-cursor-subagent-only"),
appRunOrdinal: 1,
providerTurn,
},
providerThreadTurns,
});

const latest = yield* rollbackTo([providerTurn]);
assert.isNull(latest.providerThread.nativeConversationHeadRef);
const error = yield* rollbackTo([
providerTurn,
{
...providerTurn,
id: ProviderTurnId.make("provider-turn-claude-cursor-next"),
ordinal: 2,
},
]).pipe(Effect.flip);
assert.instanceOf(error, ProviderAdapter.ProviderAdapterRollbackThreadError);
assert.include(String(error.cause), "no SDK assistant message cursor");

yield* harness.runtime.startTurn(
makeClaudeTestTurnInput({
threadId: harness.threadId,
providerThread: latest.providerThread,
now,
attemptId: RunAttemptId.make("attempt-claude-cursor-subagent-only-next"),
text: "Continue.",
attachments: [],
providerTurnOrdinal: 2,
}),
);
const reopenedQueue = harness.processQueues[1];
assert.isDefined(reopenedQueue);
if (reopenedQueue === undefined) return;
yield* Queue.offerAll(reopenedQueue, [
makeAssistantTextFrame({
uuid: "00000000-0000-4000-8000-000000000646",
text: "Continuing.",
}),
makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000647", result: "Done." }),
]);
const next = yield* Queue.take(harness.terminalReceipts);
assert.equal(next.status, "completed");
// The reopened query must resume from the whole transcript, not pin a subagent uuid.
assert.isUndefined(harness.getOpenedOptions()?.resumeSessionAt);
}).pipe(
Effect.provide(
Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
),
),
);

it.effect.each([
{ recovered: false, expected: "Claude usage limit reached" },
{ recovered: true, expected: "Claude gave up after repeated API errors" },
Expand Down
3 changes: 2 additions & 1 deletion apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6687,8 +6687,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
}

if (message.type === "assistant" && input.replayed !== true) {
context.nativeMessageCursor = message.uuid;
if (message.parent_tool_use_id === null) {
// Subagent uuids exist only in the sidechain transcript, so resume and fork cannot target them.
context.nativeMessageCursor = message.uuid;
context.latestAssistantRateLimited = message.error === "rate_limit";
if (message.error === "authentication_failed") {
context.authenticationFailureMessage = claudeSignedOutMessage({
Expand Down
Loading