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
23 changes: 23 additions & 0 deletions packages/protocol/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
11 changes: 10 additions & 1 deletion src/daemon/providers/claude/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
14 changes: 12 additions & 2 deletions src/daemon/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
32 changes: 30 additions & 2 deletions web/src/components/transcript/MessageRow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -29,7 +30,34 @@ describe("ThinkingBlock", () => {
expect(three.container.textContent).toContain("reasoning (3 lines)");
cleanup();
const one = render(() => <MessageRow msg={thinkingMsg("no newline here")} />);
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(() => <MessageRow msg={thinkingMsg(REASONING_UNAVAILABLE)} />);
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(() =>
<MessageRow msg={thinkingMsg("Considering the tradeoffs\nand the constraints")} />,
);
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(() => <MessageRow msg={thinkingMsg(" \n ")} />);
expect(container.querySelector("details")).toBeNull();
});

it("coalesces streaming deltas to one recount per animation frame", () => {
Expand Down Expand Up @@ -62,7 +90,7 @@ describe("ThinkingBlock", () => {
it("updates synchronously when not streaming (throttle passthrough)", () => {
const [msg, setMsg] = createSignal(thinkingMsg("x"));
const { container } = render(() => <MessageRow msg={msg()} />);
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");
Expand Down
43 changes: 34 additions & 9 deletions web/src/components/transcript/MessageRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
createFrameThrottled,
splitStreamingBlocks,
} from "../../lib/streaming-markdown";
import { REASONING_UNAVAILABLE } from "../../protocol/types";
import type {
MessageRole,
SessionMessage,
Expand Down Expand Up @@ -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 (
<details class="text-[12px] italic text-role-thinking" open={props.streaming}>
<summary class="cursor-pointer select-none text-fg-faint hover:text-fg-muted">
reasoning ({lineCount()} lines)
<Show when={props.streaming}>
<span class="md-streaming-caret ml-1" aria-label="streaming" />
</Show>
</summary>
<div class="mt-1 whitespace-pre-wrap pl-3">{throttled()}</div>
</details>
<Show
when={hasReasoning()}
fallback={
<div class="text-[12px] italic text-fg-faint" aria-label="reasoning unavailable">
{REASONING_UNAVAILABLE}
</div>
}
>
<details class="text-[12px] italic text-role-thinking" open={props.streaming}>
<summary class="cursor-pointer select-none text-fg-faint hover:text-fg-muted">
reasoning ({lineCount()} {lineCount() === 1 ? "line" : "lines"})
<Show when={props.streaming}>
<span class="md-streaming-caret ml-1" aria-label="streaming" />
</Show>
</summary>
<div class="mt-1 whitespace-pre-wrap pl-3">{throttled()}</div>
</details>
</Show>
);
};

Expand Down
Loading