diff --git a/packages/protocol/src/types.ts b/packages/protocol/src/types.ts index 3cbd6c1..eb3641c 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 143205d..abcf254 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 de65a6a..8415603 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 f30fe95..420b003 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 e531508..a96ce73 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()}
+
+
); };