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
69 changes: 69 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2806,6 +2806,75 @@ describe("ClaudeAdapterV2 background wake turns", () => {
),
);

it.effect.each([
{ model: "claude-opus-5-5", expected: "assistant_message" },
{ model: "claude-fable-5-1", expected: "assistant_message" },
{ model: "claude-opus-5", expected: "reasoning" },
{ model: "claude-sonnet-4-6", expected: "reasoning" },
] as const)(
"shows $model thinking right before a tool call as $expected",
({ model, expected }) =>
Effect.gen(function* () {
const harness = yield* makeWakeHarness;
yield* harness.runtime.startTurn(
makeClaudeTestTurnInput({
threadId: harness.threadId,
providerThread: harness.providerThread,
now: yield* DateTime.now,
attemptId: RunAttemptId.make("thinking-before-tool-attempt"),
text: "Check the memory file",
attachments: [],
}),
);
const snapshot = (uuid: string, content: ReadonlyArray<unknown>) =>
claudeSdkFrame({
type: "assistant",
uuid,
session_id: WAKE_NATIVE_SESSION,
parent_tool_use_id: null,
message: { id: "reply-message", model, content },
});
const frames = [
snapshot("reasoning-snapshot", [
{ type: "thinking", thinking: "Planning the check", signature: "sig" },
]),
snapshot("reply-snapshot", [
{
type: "thinking",
thinking: "Found it: the memory file is the cause.",
signature: "sig",
},
]),
snapshot("tool-snapshot", [
{ type: "tool_use", id: "tool-1", name: "Bash", input: { command: "ls" } },
]),
makeResultFrame({ uuid: "thinking-before-tool-result", result: "" }),
];
for (const frame of frames) yield* Queue.offer(harness.sdkMessages, frame);
yield* Queue.take(harness.terminalReceipts);
const latest = new Map(
harness.events.flatMap((event) =>
event.type === "turn_item.updated" &&
(event.turnItem.type === "reasoning" || event.turnItem.type === "assistant_message")
? [[event.turnItem.id, event.turnItem] as const]
: [],
),
);
assert.deepEqual(
[...latest.values()].map((item) => [item.type, item.text]),
[
["reasoning", "Planning the check"],
[expected, "Found it: the memory file is the cause."],
],
);
}).pipe(
Effect.scoped,
Effect.provide(
Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
),
),
);

it.effect.each(["cancelled", "denied", "permission_denied", undefined])(
"preserves native tool non-execution metadata %s without inferring a denial from text",
(kind) =>
Expand Down
37 changes: 36 additions & 1 deletion apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1165,6 +1165,15 @@ function textFromClaudeContent(content: SDKAssistantMessage["message"]["content"
return content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("");
}

// Claude 5.1 and later return the text written ahead of a tool call as a
// thinking block, at most one per call, instead of a text block.
function claudeWritesUpdatesAsThinking(model: string): boolean {
const match = /claude-[a-z]+-(\d+)(?:-(\d{1,2}))?(?!\d)/u.exec(model);
if (match === null) return false;
const major = Number(match[1]);
return major > 5 || (major === 5 && Number(match[2] ?? 0) >= 1);
}

// In SDK mode Claude reports `/goal` only through the transcript (its
// `active_goal` event is remote-only): synthetic command output names the
// goal, and each unmet evaluator check returns as Stop hook feedback.
Expand Down Expand Up @@ -2906,6 +2915,12 @@ interface ActiveClaudeTurnContext {
readonly nextBlockIndex: Map<string, number>;
readonly snapshotBlockIndex: Map<string, number>;
readonly snapshots: Set<string>;
// The latest snapshot block, when it was non-empty thinking.
lastThinking: {
readonly messageId: string;
readonly itemId: string;
readonly text: string;
} | null;
readonly blocks: Map<
string,
{ readonly startedAt: DateTime.Utc; readonly ordinal: number; text: string }
Expand Down Expand Up @@ -6752,7 +6767,23 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
// The SDK emits one assistant snapshot per completed content block.
// Count thinking blocks separately so snapshots share the streamed ID.
for (const block of message.message.content) {
if (block.type !== "thinking") continue;
if (block.type !== "thinking") {
const update = context.reasoning.lastThinking;
context.reasoning.lastThinking = null;
if (
block.type === "tool_use" &&
update?.messageId === message.message.id &&
claudeWritesUpdatesAsThinking(message.message.model)
) {
// Same item ID, so the update replaces the Thought in place.
yield* emitAssistantTextArtifacts({
Comment thread
coderabbitai[bot] marked this conversation as resolved.
context,
nativeItemId: update.itemId,
text: update.text,
});
}
continue;
}
const index = context.reasoning.snapshotBlockIndex.get(message.message.id) ?? 0;
context.reasoning.snapshotBlockIndex.set(message.message.id, index + 1);
const itemId = `${message.message.id}:thinking:${index}`;
Expand All @@ -6764,6 +6795,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
...(finalText ? { finalText } : {}),
emitEmpty: false,
});
context.reasoning.lastThinking = finalText
? { messageId: message.message.id, itemId, text: finalText }
: null;
}
}

Expand Down Expand Up @@ -8441,6 +8475,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
streamBlocks: new Map(),
nextBlockIndex: new Map(),
snapshotBlockIndex: new Map(),
lastThinking: null,
snapshots: new Set(),
blocks: new Map(),
},
Expand Down
Loading