From ec887202fc6b228aecd7f86d2a1455d418998e23 Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Fri, 28 Aug 2026 19:15:21 +0800 Subject: [PATCH] feat: return readable Claude reasoning, and render absent reasoning honestly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reasoning expander in the web UI always read "reasoning (1 lines)" and expanded onto nothing. Two independent causes. 1. Claude was never asked for readable reasoning. `thinking.display` defaults to "omitted" on Opus 5 / 4.8 / 4.7, Sonnet 5, and Fable 5, which streams thinking blocks whose text is EMPTY; codeoid then committed them as a placeholder. Measured on one live session: 3,445 of 3,450 thinking records were the placeholder, and 635 of 635 raw SDK thinking blocks across six transcripts had empty text. ClaudeProvider now passes `display: "summarized"`. This is free — display controls visibility only, and thinking is billed identically either way, so the tokens were already being spent with nothing shown for them. (The raw chain of thought is never exposed on any model; this is a summary of it.) 2. The UI promised content it could not have. Rendering a
expander over the placeholder advertised reasoning, counted it as "1 lines", and opened onto the placeholder text — which reads as a counting bug rather than absent data. It now renders a flat, non-interactive marker when there is nothing behind it, and keeps the expander (with a correct, correctly-pluralised count) when there is. Both branches are live and neither is an error state, which is why this is a render-time decision rather than a backend fix: qwen-code and OSS models behind an OpenAI-compatible gateway stream plaintext reasoning today — qwen-ledger has real thinking content in its transcript — while Claude only does so under the setting above. The placeholder moves to a shared `REASONING_UNAVAILABLE` constant in the protocol package: the daemon writes it and the client keys off it, so the two sides cannot drift over a prose string. Its wording changes from "(reasoning elided)" — codeoid elides nothing; the backend returned none. Co-Authored-By: Claude Opus 5 (1M context) --- packages/protocol/src/types.ts | 23 ++++++++++ src/daemon/providers/claude/index.ts | 11 ++++- src/daemon/session.ts | 14 +++++- .../components/transcript/MessageRow.test.tsx | 32 +++++++++++++- web/src/components/transcript/MessageRow.tsx | 43 +++++++++++++++---- 5 files changed, 109 insertions(+), 14 deletions(-) diff --git a/packages/protocol/src/types.ts b/packages/protocol/src/types.ts index 3cbd6c17..eb3641c1 100644 --- a/packages/protocol/src/types.ts +++ b/packages/protocol/src/types.ts @@ -178,6 +178,29 @@ export type SessionStatus = | "waiting_approval" | "error"; +/** + * Placeholder body for a `thinking` message the backend produced no readable + * reasoning for. + * + * A thinking message must be committed with NON-EMPTY content — the TUI's + * "same msgId + content ⇒ committed" rule is what retires the live spinner, so + * an empty commit leaves `Thinking…` stuck. Hence a sentinel rather than "". + * + * Shared (not duplicated) because both sides key off it: the daemon writes it, + * and clients use it to tell "reasoning exists, show an expander" apart from + * "there is nothing behind this" — an expander that opens onto a placeholder is + * worse than no expander at all. + * + * Whether reasoning arrives at all is a BACKEND property, not a codeoid one. + * Claude returns readable text only when `thinking.display` is `"summarized"` + * (the API default is `"omitted"`, which streams thinking blocks with empty + * text); the raw chain of thought is never exposed on any model. Other + * backends — qwen-code, and OSS models behind an OpenAI-compatible gateway — + * stream plaintext reasoning directly. So both branches are live at once, and + * neither is an error state. + */ +export const REASONING_UNAVAILABLE = "(no reasoning returned by this model)"; + /** True when the session is mid-turn (either reasoning or running a tool). */ export function isActiveStatus(s: SessionStatus): boolean { return s === "thinking" || s === "tool_running"; diff --git a/src/daemon/providers/claude/index.ts b/src/daemon/providers/claude/index.ts index 143205d4..abcf254d 100644 --- a/src/daemon/providers/claude/index.ts +++ b/src/daemon/providers/claude/index.ts @@ -543,7 +543,16 @@ export class ClaudeProvider implements SessionProvider { permissionMode: "default", includePartialMessages: true, persistSession: true, - thinking: { type: "adaptive" as const }, + // `display` defaults to "omitted" on Opus 5 / 4.8 / 4.7, Sonnet 5, and + // Fable 5 — which streams thinking blocks whose text is EMPTY. Codeoid + // then committed them as a placeholder, so every reasoning block in the + // UI expanded onto nothing (measured: 3,445 of 3,450 on one session). + // "summarized" returns readable reasoning instead. It is free: display + // controls visibility only, and thinking is billed identically either + // way — the tokens were already spent, we just weren't being shown + // anything for them. (The raw chain of thought is never exposed on any + // model; this is a summary of it.) + thinking: { type: "adaptive" as const, display: "summarized" as const }, ...(opts.model ? { model: opts.model } : {}), ...(opts.fallbackModel ? { fallbackModel: opts.fallbackModel } : {}), stderr: (data: string) => { diff --git a/src/daemon/session.ts b/src/daemon/session.ts index de65a6a8..8415603f 100644 --- a/src/daemon/session.ts +++ b/src/daemon/session.ts @@ -61,7 +61,13 @@ import { promisify } from "node:util"; const execFileP = promisify(execFile); /** Max wall-clock for a fork.setup command (deps install can be slow). */ const FORK_SETUP_TIMEOUT_MS = 600_000; -import { authToIdentity, CAPABILITIES, isActiveStatus, SYSTEM_IDENTITY } from "../protocol/types.js"; +import { + authToIdentity, + CAPABILITIES, + isActiveStatus, + REASONING_UNAVAILABLE, + SYSTEM_IDENTITY, +} from "../protocol/types.js"; import type { Store } from "./store.js"; import type { AgentIdentityManager } from "./agent-identity.js"; import { ScrollbackBuffer } from "./scrollback.js"; @@ -4290,7 +4296,11 @@ export class Session { this.#activeThinkingMsg = null; this.#activeThinkingIndex = null; if (!m.content || m.content.length === 0) { - m.content = "(reasoning elided)"; + // Not an elision by codeoid — the backend returned no readable reasoning. + // Claude does this whenever `thinking.display` is `"omitted"`; backends + // that stream plaintext reasoning (qwen-code, OSS models over an + // OpenAI-compatible gateway) land in the branch above with real content. + m.content = REASONING_UNAVAILABLE; } this.#commitStreamed(m); this.#broadcastRaw(m); diff --git a/web/src/components/transcript/MessageRow.test.tsx b/web/src/components/transcript/MessageRow.test.tsx index f30fe954..420b003a 100644 --- a/web/src/components/transcript/MessageRow.test.tsx +++ b/web/src/components/transcript/MessageRow.test.tsx @@ -4,6 +4,7 @@ import { render, cleanup } from "@solidjs/testing-library"; import { createSignal } from "solid-js"; import MessageRow from "./MessageRow"; +import { REASONING_UNAVAILABLE } from "../../protocol/types"; import type { SessionMessage } from "../../protocol/types"; function thinkingMsg(content: string): SessionMessage { @@ -29,7 +30,34 @@ describe("ThinkingBlock", () => { expect(three.container.textContent).toContain("reasoning (3 lines)"); cleanup(); const one = render(() => ); - expect(one.container.textContent).toContain("reasoning (1 lines)"); + expect(one.container.textContent).toContain("reasoning (1 line)"); + }); + + it("renders a flat marker — not an expander — when no reasoning was returned", () => { + // Backends differ: qwen-code and OSS models stream plaintext reasoning, + // while Claude returns text only under thinking.display "summarized". + // With nothing behind it, an expander advertises content it can't show. + const { container } = render(() => ); + expect(container.querySelector("details")).toBeNull(); + expect(container.textContent).toContain(REASONING_UNAVAILABLE); + // No line count either — "(1 line)" over a placeholder is what made the + // original report look like a counting bug rather than absent data. + expect(container.textContent).not.toContain("line)"); + }); + + it("renders an expander when the backend DID return reasoning", () => { + const { container } = render(() => + , + ); + const details = container.querySelector("details"); + expect(details).not.toBeNull(); + expect(container.textContent).toContain("reasoning (2 lines)"); + expect(container.textContent).toContain("Considering the tradeoffs"); + }); + + it("treats whitespace-only reasoning as absent", () => { + const { container } = render(() => ); + expect(container.querySelector("details")).toBeNull(); }); it("coalesces streaming deltas to one recount per animation frame", () => { @@ -62,7 +90,7 @@ describe("ThinkingBlock", () => { it("updates synchronously when not streaming (throttle passthrough)", () => { const [msg, setMsg] = createSignal(thinkingMsg("x")); const { container } = render(() => ); - expect(container.textContent).toContain("reasoning (1 lines)"); + expect(container.textContent).toContain("reasoning (1 line)"); setMsg(thinkingMsg("x\ny\nz")); expect(container.textContent).toContain("reasoning (3 lines)"); expect(container.textContent).toContain("x\ny\nz"); diff --git a/web/src/components/transcript/MessageRow.tsx b/web/src/components/transcript/MessageRow.tsx index e531508b..a96ce73c 100644 --- a/web/src/components/transcript/MessageRow.tsx +++ b/web/src/components/transcript/MessageRow.tsx @@ -19,6 +19,7 @@ import { createFrameThrottled, splitStreamingBlocks, } from "../../lib/streaming-markdown"; +import { REASONING_UNAVAILABLE } from "../../protocol/types"; import type { MessageRole, SessionMessage, @@ -208,17 +209,41 @@ const ThinkingBlock: Component<{ text: string; streaming?: boolean }> = (props) () => props.text, () => props.streaming === true, ); + /** + * Whether there is real reasoning behind this block. + * + * Backends differ and both branches are normal: qwen-code and OSS models over + * an OpenAI-compatible gateway stream plaintext reasoning, while Claude + * returns readable text only under `thinking.display: "summarized"`. When + * there is nothing, an expander is actively misleading — it advertises + * content, counts "1 lines", and opens onto a placeholder. Render a flat + * marker instead, so the thinking→answer rhythm still reads without + * promising something that can't be shown. + */ + const hasReasoning = createMemo(() => { + const t = throttled().trim(); + return t.length > 0 && t !== REASONING_UNAVAILABLE; + }); const lineCount = createMemo(() => countLines(throttled())); return ( -
- - reasoning ({lineCount()} lines) - - - - -
{throttled()}
-
+ + {REASONING_UNAVAILABLE} + + } + > +
+ + reasoning ({lineCount()} {lineCount() === 1 ? "line" : "lines"}) + + + + +
{throttled()}
+
+
); };