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

// Summarized thinking can't tell a progress update from reasoning, so both stay Thoughts.
it.effect.each(["claude-opus-5-5", "claude-fable-5-1", "claude-opus-5", "claude-sonnet-4-6"])(
"keeps %s thinking right before a tool call as reasoning",
(model) =>
// Summarized display can't mark a progress update, so only the second of two
// thinking blocks before a tool call becomes text; a lone block is reasoning.
const plan = "Planning the check";
const update = "Found it: the memory file is the cause.";
it.effect.each([
{
model: "claude-opus-5-5",
texts: [plan, update],
expected: ["reasoning", "assistant_message"],
},
{
model: "claude-fable-5-1",
texts: [plan, update],
expected: ["reasoning", "assistant_message"],
},
{ model: "claude-opus-5", texts: [plan, update], expected: ["reasoning", "reasoning"] },
{ model: "claude-sonnet-4-6", texts: [plan, update], expected: ["reasoning", "reasoning"] },
{ model: "claude-opus-5-5", texts: [plan], expected: ["reasoning"] },
{ model: "claude-opus-5-5", texts: [plan, " \n "], expected: ["reasoning", "reasoning"] },
{ model: "claude-opus-5-5", texts: [" \n ", update], expected: ["reasoning", "reasoning"] },
] as const)(
"shows $model thinking $texts before a tool call as $expected",
({ model, texts, expected }) =>
Effect.gen(function* () {
const harness = yield* makeWakeHarness;
yield* harness.runtime.startTurn(
Expand All @@ -2834,16 +2853,11 @@ describe("ClaudeAdapterV2 background wake turns", () => {
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",
},
]),
...texts.map((thinking, index) =>
snapshot(`thinking-snapshot-${index}`, [
{ type: "thinking", thinking, signature: "sig" },
]),
),
snapshot("tool-snapshot", [
{ type: "tool_use", id: "tool-1", name: "Bash", input: { command: "ls" } },
]),
Expand All @@ -2859,12 +2873,14 @@ describe("ClaudeAdapterV2 background wake turns", () => {
: [],
),
);
const items = [...latest.values()];
assert.deepEqual(
[...latest.values()].map((item) => [item.type, item.text]),
[
["reasoning", "Planning the check"],
["reasoning", "Found it: the memory file is the cause."],
],
items.map((item) => item.type),
[...expected],
);
assert.deepEqual(
items.map((item) => item.text),
[...texts],
);
}).pipe(
Effect.scoped,
Expand Down
50 changes: 48 additions & 2 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1165,6 +1165,17 @@ 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. Summarized
// display makes it look like reasoning, so only the second of two thinking
// blocks right before a tool call is treated as that text.
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 +2917,13 @@ 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;
readonly followsThinking: boolean;
} | null;
readonly blocks: Map<
string,
{ readonly startedAt: DateTime.Utc; readonly ordinal: number; text: string }
Expand Down Expand Up @@ -6847,18 +6865,45 @@ 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 !== null &&
update.messageId === message.message.id &&
update.followsThinking &&
claudeWritesUpdatesAsThinking(message.message.model)
) {
// Same item ID, so the update replaces the Thought in place.
yield* emitAssistantTextArtifacts({
context,
nativeItemId: update.itemId,
text: update.text,
});
}
continue;
}
Comment thread
maria-rcks marked this conversation as resolved.
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}`;
yield* ensureReasoningBlock(context, itemId);
const finalText = block.thinking || context.reasoning.blocks.get(itemId)?.text;
yield* reasoningDeltas.complete({
const completedText = yield* reasoningDeltas.complete({
turnId: context.nativeTurnId,
itemId,
...(finalText ? { finalText } : {}),
emitEmpty: false,
});
const previous = context.reasoning.lastThinking;
context.reasoning.lastThinking = completedText.trim()
? {
messageId: message.message.id,
itemId,
text: completedText,
followsThinking: previous !== null && previous.messageId === message.message.id,
}
: null;
}
}

Expand Down Expand Up @@ -8553,6 +8598,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