diff --git a/CHANGESET.md b/CHANGESET.md index cd758578e617..9104b499d117 100644 --- a/CHANGESET.md +++ b/CHANGESET.md @@ -154,6 +154,7 @@ Newest first. Each entry names the upstream range brought in. ### Features +- **#161** The TUI collapses each run of consecutive encrypted reasoning parts from OpenAI Responses models, such as GPT-6 Luna and Sol through `swx-azure`, into one `Thought · ` line that updates as each part arrives, in the live stream and in loaded history. A tool call, visible text, or a message error starts a new line; non-encrypted reasoning renders as before (swxtchio/swx-opencode#53). _Upstreamable._ - **#159** Add `skills.exclude`, glob patterns for `SKILL.md` files that skip copies in every skill root, such as hidden per-host installs and `.trash` folders. Patterns match relative to each root, and excluded skills are not loaded, so they cannot shadow the canonical skill of the same name (swxtchio/swx-opencode#59). _Upstreamable._ - **#73** V1 server prompt queue core (swxtchio/swx-opencode#68): every V1 prompt except `noReply` is admitted to the durable `session_prompt_queue` table before it becomes a user message. `prompt`, `prompt_async` and `command` take `delivery: "steer" | "queue"`; the default steer reaches the next step as before, while `queue` waits until the run would go idle and then runs as its own turn, one per boundary in admission order. Steers wait behind a pending compaction; an abort or error parks pending prompts until the next admission or wake; `/session/:id/queue` lists, withdraws (404 `QueueItemNotPending` once delivered), restores and re-delivers them, with `session.queue.updated` carrying the full list. A sync prompt answers with the final reply of the turn its prompt joined, recorded for it where the loop ends that turn: steers share the reply of the turn they steered, and a queued prompt gets its own turn's; an editor's withdraw that wins answers it with 409 `PromptWithdrawn`. Deliberately, a compaction whose summary turn stopped on an abort or error is not retried, where upstream V1 retried it on the next prompt with that prompt as its parent (summarising it instead of answering it); if the context is still too large, the next overflow check starts a new compaction. Also closes the V1 lost wakeup where a prompt admitted as a run finished joined it and was never answered. Known ordering-only residuals: a `noReply` prompt and the synthetic message a subtask writes to have its output summarised are stored directly rather than through the queue, so they can land out of admission order relative to pending items. _Fork-only; the lost-wakeup fix is upstreamable._ - **#64** Show each configured router member's request count and share so far in `llmrouter/auto` labels in the TUI and run CLI. (swxtchio/swx-opencode#60). _Fork-only._ diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index dba2cd71bfc0..1e6ad8dfa762 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -70,6 +70,7 @@ import { QuestionPrompt } from "./question" import { DialogExportOptions } from "../../ui/dialog-export-options" import * as Model from "../../util/model" import { AssistantModelLabel } from "./message-model" +import { groupOpaqueReasoning, reasoningText } from "./opaque-reasoning" import type { SessionStepMessage } from "../../util/model" import { formatTranscript } from "../../util/transcript" import { sessionEpilogue } from "../../util/presentation" @@ -1386,7 +1387,7 @@ export function Session() { /> - groupOpaqueReasoning(props.parts)) + const childShortcut = useCommandShortcut("session.child.first") const backgroundShortcut = useCommandShortcut("session.background") return ( <> - + {(part, index) => { const component = createMemo(() => PART_MAPPING[part.type as keyof typeof PART_MAPPING]) return ( - - + + + + } + > + grouped().runs.get(part.id) ?? []} message={props.message} /> ) }} @@ -1714,42 +1725,20 @@ export function ReasoningPartView(props: { last: boolean; part: ReasoningPart; m // layout never shifts. Click to open the full markdown block, click to close. const [expanded, setExpanded] = createSignal(false) - const content = createMemo(() => { - // OpenRouter encrypts some reasoning blocks; drop the placeholder. - return props.part.text.replace("[REDACTED]", "").trim() - }) - const opaque = createMemo(() => !content() && Boolean(props.part.metadata)) + const content = createMemo(() => reasoningText(props.part)) const status = createMemo(() => assistantStatus(sync, props.message)) - const unresolved = createMemo( - () => - props.part.time.end === undefined && - props.message.time.completed === undefined && - props.message.error === undefined && - status() === "unknown", - ) - const isDone = createMemo( - () => - props.part.time.end !== undefined || - props.message.time.completed !== undefined || - props.message.error !== undefined || - status() === "failed" || - unresolved(), - ) + const state = createMemo(() => reasoningState(props.part, props.message, status())) const inMinimal = createMemo(() => ctx.thinkingMode() === "hide") - const duration = createMemo(() => { - const end = props.part.time.end - return end === undefined ? 0 : Math.max(0, end - props.part.time.start) - }) const summary = createMemo(() => reasoningSummary(content())) const syntax = createSyntaxStyleMemo(() => generateSubtleSyntax(theme)) const toggle = () => { - if (!inMinimal() || opaque()) return + if (!inMinimal()) return setExpanded((prev) => !prev) } return ( - + alwaysSeparate.add(el)} paddingLeft={3} @@ -1759,16 +1748,15 @@ export function ReasoningPartView(props: { last: boolean; part: ReasoningPart; m > - + ReasoningPart[]; message: AssistantMessage }) { + const ctx = use() + const { theme } = useTheme() + const sync = useSync() + const status = createMemo(() => assistantStatus(sync, props.message)) + const states = createMemo(() => props.members().map((part) => reasoningState(part, props.message, status()))) + const done = createMemo(() => states().every((state) => state.done)) + const unknown = createMemo(() => states().some((state) => state.unresolved)) + const duration = createMemo(() => states().reduce((total, state) => total + state.duration, 0)) + const fg = () => + ctx.thinkingMode() !== "hide" + ? RGBA.fromValues(theme.warning.r, theme.warning.g, theme.warning.b, theme.thinkingOpacity) + : theme.warning + // The label is one text node for the whole lifecycle; only its content and the spinner beside it change. + // A selection on the label therefore survives the in-flight and completed transitions. + const label = () => { + if (unknown()) return "Thinking status unknown" + if (!done() && states().length === 1) return "Thinking" + return `Thought${states().length > 1 ? ` ${states().length}` : ""} · ${Locale.duration(duration())}` + } + + return ( + alwaysSeparate.add(el)} + paddingLeft={3} + marginTop={1} + flexDirection="column" + flexShrink={0} + > + + + + + + {label()} + + + + ) +} + +function reasoningState(part: ReasoningPart, message: AssistantMessage, status: ReturnType) { + const unresolved = + part.time.end === undefined && + message.time.completed === undefined && + message.error === undefined && + status === "unknown" + const done = + part.time.end !== undefined || + message.time.completed !== undefined || + message.error !== undefined || + status === "failed" || + unresolved + const end = part.time.end + return { done, unresolved, duration: end === undefined ? 0 : Math.max(0, end - part.time.start) } +} + function ReasoningHeader(props: { toggleable: boolean open: boolean @@ -1793,7 +1838,6 @@ function ReasoningHeader(props: { unknown?: boolean title: string | null duration?: string - encrypted?: boolean }) { const { theme } = useTheme() const fg = () => @@ -1801,7 +1845,6 @@ function ReasoningHeader(props: { ? RGBA.fromValues(theme.warning.r, theme.warning.g, theme.warning.b, theme.thinkingOpacity) : theme.warning const completed = () => { - if (props.encrypted) return `Thought${props.duration ? ` · ${props.duration}` : ""}` const detail = [props.title, props.duration].filter(Boolean).join(" · ") return `${props.toggleable ? (props.open ? "- " : "+ ") : ""}Thought${detail ? `: ${detail}` : ""}` } diff --git a/packages/tui/src/routes/session/opaque-reasoning.ts b/packages/tui/src/routes/session/opaque-reasoning.ts new file mode 100644 index 000000000000..c348fb4f5630 --- /dev/null +++ b/packages/tui/src/routes/session/opaque-reasoning.ts @@ -0,0 +1,47 @@ +import type { Part, ReasoningPart } from "@opencode-ai/sdk/v2" + +// A run's row is its first part, the same object on every recompute, so the view keyed on it persists while +// the run grows. The later members are not rows; they reach the view through `runs`. +export type OpaqueReasoningGroups = { + rows: Part[] + runs: Map +} + +// OpenRouter encrypts some reasoning blocks and marks them with a placeholder. +export function reasoningText(part: ReasoningPart) { + return part.text.replace("[REDACTED]", "").trim() +} + +// Encrypted reasoning from OpenAI Responses has provider metadata and no text, +// so it has nothing to show but a duration. +export function isOpaqueReasoning(part: ReasoningPart) { + return !reasoningText(part) && Boolean(part.metadata) +} + +// A run ends at a part that renders a line of its own. Every other part stays in +// order, so a part type the view does not render yet still reaches the view. +export function groupOpaqueReasoning(parts: readonly Part[]): OpaqueReasoningGroups { + const breakers = parts.flatMap((part, index) => (breaksRun(part) ? [index] : [])) + const starts = [0, ...breakers.map((index) => index + 1)] + const runs = new Map() + const rows = starts.flatMap((start, index) => { + const end = breakers[index] ?? parts.length + const segment = parts.slice(start, end) + const members = segment.filter(isOpaquePart) + const leader = members[0] + if (leader) runs.set(leader.id, members) + return [...segment.filter((part) => !isOpaquePart(part) || part === leader), ...parts.slice(end, end + 1)] + }) + return { rows, runs } +} + +function breaksRun(part: Part) { + if (part.type === "tool") return true + if (part.type === "text") return part.text.trim() !== "" + if (part.type === "reasoning") return reasoningText(part) !== "" + return false +} + +function isOpaquePart(part: Part): part is ReasoningPart { + return part.type === "reasoning" && isOpaqueReasoning(part) +} diff --git a/packages/tui/test/routes/session/assistant-activity.test.tsx b/packages/tui/test/routes/session/assistant-activity.test.tsx index 4d0cb90791f8..113c625f02f8 100644 --- a/packages/tui/test/routes/session/assistant-activity.test.tsx +++ b/packages/tui/test/routes/session/assistant-activity.test.tsx @@ -7,6 +7,7 @@ import { onCleanup } from "solid-js" import type { JSX } from "@opentui/solid" import { SessionV1 } from "@opencode-ai/core/v1/session" import type { AssistantMessage, Message, Part, Session, SessionStatus } from "@opencode-ai/sdk/v2" +import { LocalProvider } from "../../../src/context/local" import { LocationProvider } from "../../../src/context/location" import { RouteProvider } from "../../../src/context/route" import { useSync } from "../../../src/context/sync" @@ -18,7 +19,7 @@ import { activeTaskRetry, assistantStatus, SessionContext, - ReasoningPartView, + AssistantMessageView, ToolPartView, } from "../../../src/routes/session" import { SPINNER_FRAMES } from "../../../src/component/spinner" @@ -147,17 +148,33 @@ function History(props: { return ( - - - - - - - - {props.currentRead && } - - - + + + + + + + + + + {props.currentRead && ( + + )} + + + + + ) @@ -198,12 +215,19 @@ function PreAssistantHistory(props: { - - - - - - + + + + + + + + diff --git a/packages/tui/test/routes/session/opaque-reasoning-render.test.tsx b/packages/tui/test/routes/session/opaque-reasoning-render.test.tsx new file mode 100644 index 000000000000..40e3231b0b01 --- /dev/null +++ b/packages/tui/test/routes/session/opaque-reasoning-render.test.tsx @@ -0,0 +1,429 @@ +/** @jsxImportSource @opentui/solid */ +import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui" +import { expect, test } from "bun:test" +import { createSignal, For, onCleanup, Show } from "solid-js" +import { useRenderer } from "@opentui/solid" +import { TextRenderable, type Renderable } from "@opentui/core" +import type { AssistantMessage, GlobalEvent, Part, ReasoningPart } from "@opencode-ai/sdk/v2" +import { resolve, TuiConfigProvider } from "../../../src/config" +import { LocalProvider } from "../../../src/context/local" +import { LocationProvider } from "../../../src/context/location" +import { RouteProvider } from "../../../src/context/route" +import { ThemeProvider } from "../../../src/context/theme" +import { useSync } from "../../../src/context/sync" +import { OpencodeKeymapProvider, registerOpencodeKeymap } from "../../../src/keymap" +import { AssistantMessageView, SessionContext } from "../../../src/routes/session" +import { DialogProvider } from "../../../src/ui/dialog" +import { ToastProvider } from "../../../src/ui/toast" +import { directory } from "../../fixture/tui-sdk" +import { tmpdir } from "../../fixture/fixture" +import { SPINNER_FRAMES } from "../../../src/component/spinner" +import { mount, wait } from "../../cli/cmd/tui/sync-fixture" + +const sessionID = "ses_opaque_render" + +const assistant = { + sessionID, + role: "assistant" as const, + agent: "build", + modelID: "model", + providerID: "test", + mode: "build", + parentID: "msg_user", + path: { cwd: directory, root: directory }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1 }, +} + +function message(id: string, extra: Partial = {}): AssistantMessage { + return { ...assistant, id, ...extra } +} + +function partID(messageID: string, index: number) { + return `prt_${messageID}_${String(index).padStart(3, "0")}` +} + +// Shapes follow session/processor.ts: reasoning-start creates the part with empty +// text and the provider metadata, and finishReasoning adds time.end. +function encrypted(messageID: string, index: number, time: ReasoningPart["time"]): ReasoningPart { + return { + id: partID(messageID, index), + sessionID, + messageID, + type: "reasoning", + text: "", + metadata: { openai: { itemId: `rs_${index}` } }, + time, + } +} + +function text(messageID: string, index: number, value: string): Part { + return { id: partID(messageID, index), sessionID, messageID, type: "text", text: value } +} + +function tool(messageID: string, index: number): Part { + return { + id: partID(messageID, index), + sessionID, + messageID, + type: "tool", + callID: `call_${index}`, + tool: "bash", + state: { + status: "completed", + input: { command: "ls" }, + output: "ok", + title: "ls", + metadata: {}, + time: { start: 1, end: 2 }, + }, + } +} + +function global(payload: GlobalEvent["payload"]): GlobalEvent { + return { directory: "/tmp/other", project: "proj_test", payload } +} + +function partUpdated(part: Part) { + return global({ + id: crypto.randomUUID(), + type: "message.part.updated", + properties: { sessionID, part, time: 0 }, + }) +} + +// Mirrors the session route's message loop: each assistant message reads its parts from the sync store. +function Transcript(props: { messages: AssistantMessage[] }) { + const sync = useSync() + const renderer = useRenderer() + const keymap = createDefaultOpenTuiKeymap(renderer) + const config = resolve({}, { terminalSuspend: false }) + onCleanup(registerOpencodeKeymap(keymap, renderer, config)) + const sessionContext = { + width: 100, + sessionID, + conceal: () => false, + thinkingMode: () => "show" as const, + showThinking: () => true, + showTimestamps: () => false, + showDetails: () => true, + showGenericToolOutput: () => false, + diffWrapMode: () => "word" as const, + providers: () => new Map(), + sync, + tui: config, + } + + return ( + + + + + + + + + + + + {(item) => ( + + )} + + + + + + + + + + + + ) +} + +function thoughtLines(frame: string) { + return frame.split("\n").filter((line) => line.includes("Thought")) +} + +// The rendered text node that shows a label. A row that is kept across an update is the same node after it. +function renderableWith(node: Renderable, label: string): Renderable | undefined { + for (const child of node.getChildren()) { + if (child instanceof TextRenderable && child.plainText.includes(label)) return child + const found = renderableWith(child, label) + if (found) return found + } + return undefined +} + +// Polls the rendered frame until it shows what the test expects, bounded so a wrong frame still fails. +async function frameWhen(app: Awaited>["app"], ready: (frame: string) => boolean) { + const start = Date.now() + while (true) { + await app.renderOnce() + const frame = app.captureCharFrame() + if (ready(frame)) return frame + if (Date.now() - start > 2000) throw new Error(`timed out waiting for the expected frame:\n${frame}`) + await Bun.sleep(10) + } +} + +test("history: a loaded run of encrypted parts renders one count line", async () => { + await using tmp = await tmpdir() + await Bun.write(`${tmp.path}/kv.json`, "{}") + const [shown, setShown] = createSignal(false) + const history = message("msg_history") + const parts = [ + encrypted("msg_history", 1, { start: 1000, end: 2000 }), + encrypted("msg_history", 2, { start: 3000, end: 5000 }), + encrypted("msg_history", 3, { start: 6000, end: 9000 }), + encrypted("msg_history", 4, { start: 9500, end: 11300 }), + ] + const { app, emit, sync } = await mount(undefined, tmp.path, true, () => ( + + + + )) + + try { + emit(global({ id: "evt_history_message", type: "message.updated", properties: { sessionID, info: history } })) + for (const part of parts) { + emit(partUpdated(part)) + } + await wait(() => sync.data.part[history.id]?.length === parts.length) + + setShown(true) + const frame = await frameWhen(app, (rendered) => thoughtLines(rendered).length > 0) + + const lines = thoughtLines(frame) + expect(lines).toHaveLength(1) + expect(lines[0]).toContain("Thought 4 · 7.8s") + } finally { + app.renderer.destroy() + } +}) + +test("live: a run updates in place as parts arrive", async () => { + await using tmp = await tmpdir() + await Bun.write(`${tmp.path}/kv.json`, "{}") + const live = message("msg_live") + const { app, emit, sync } = await mount(undefined, tmp.path, true, () => ) + + try { + emit(global({ id: "evt_live_message", type: "message.updated", properties: { sessionID, info: live } })) + + const first = encrypted("msg_live", 1, { start: 1000, end: 2000 }) + emit(partUpdated(first)) + await wait(() => sync.data.part[live.id]?.length === 1) + const single = await frameWhen(app, (rendered) => thoughtLines(rendered).length > 0) + expect(thoughtLines(single)).toEqual([expect.stringContaining("Thought · 1.0s")]) + + const second = encrypted("msg_live", 2, { start: 3000, end: 5000 }) + emit(partUpdated(second)) + await wait(() => sync.data.part[live.id]?.length === 2) + const pair = thoughtLines(await frameWhen(app, (rendered) => rendered.includes("Thought 2 · 3.0s"))) + expect(pair).toHaveLength(1) + + emit(global({ id: "evt_live_busy", type: "session.status", properties: { sessionID, status: { type: "busy" } } })) + const third = encrypted("msg_live", 3, { start: 6000, end: 9000 }) + const inFlight = { ...third, time: { start: 6000 } } + emit(partUpdated(inFlight)) + await wait(() => sync.data.part[live.id]?.length === 3) + const pending = thoughtLines(await frameWhen(app, (rendered) => rendered.includes("Thought 3 · 3.0s"))) + expect(pending).toHaveLength(1) + expect(SPINNER_FRAMES.some((glyph) => pending[0].includes(glyph))).toBe(true) + + emit(partUpdated(third)) + await wait(() => { + const stored = sync.data.part[live.id]?.find((part) => part.id === third.id) + return stored?.type === "reasoning" && stored.time.end === 9000 + }) + const done = thoughtLines(await frameWhen(app, (rendered) => rendered.includes("Thought 3 · 6.0s"))) + expect(done).toHaveLength(1) + expect(SPINNER_FRAMES.some((glyph) => done[0].includes(glyph))).toBe(false) + } finally { + app.renderer.destroy() + } +}) + +test("a tool call and visible text each end the run and start a new line", async () => { + await using tmp = await tmpdir() + await Bun.write(`${tmp.path}/kv.json`, "{}") + const turn = message("msg_breaks") + const parts: Part[] = [ + encrypted("msg_breaks", 1, { start: 1000, end: 2000 }), + encrypted("msg_breaks", 2, { start: 3000, end: 5000 }), + tool("msg_breaks", 3), + encrypted("msg_breaks", 4, { start: 6000, end: 10000 }), + encrypted("msg_breaks", 5, { start: 10500, end: 11000 }), + text("msg_breaks", 6, "Here is the answer."), + encrypted("msg_breaks", 7, { start: 12000, end: 12500 }), + ] + const { app, emit, sync } = await mount(undefined, tmp.path, true, () => ) + + try { + emit(global({ id: "evt_breaks_message", type: "message.updated", properties: { sessionID, info: turn } })) + for (const part of parts) { + emit(partUpdated(part)) + } + await wait(() => sync.data.part[turn.id]?.length === parts.length) + + const frame = await frameWhen( + app, + (rendered) => rendered.includes("Thought · 500ms") && rendered.includes("Here is the answer."), + ) + const lines = thoughtLines(frame) + expect(lines).toHaveLength(3) + expect(lines[0]).toContain("Thought 2 · 3.0s") + expect(lines[1]).toContain("Thought 2 · 4.5s") + expect(lines[2]).toContain("Thought · 500ms") + const all = frame.split("\n") + const firstRun = all.findIndex((line) => line.includes("Thought 2 · 3.0s")) + const secondRun = all.findIndex((line) => line.includes("Thought 2 · 4.5s")) + const lastRun = all.findIndex((line) => line.includes("Thought · 500ms")) + const answer = all.findIndex((line) => line.includes("Here is the answer.")) + expect(all.slice(firstRun + 1, secondRun).some((line) => line.includes("ls"))).toBe(true) + expect(secondRun).toBeLessThan(answer) + expect(answer).toBeLessThan(lastRun) + } finally { + app.renderer.destroy() + } +}) + +test("plain reasoning between two runs keeps its own header and body", async () => { + await using tmp = await tmpdir() + await Bun.write(`${tmp.path}/kv.json`, "{}") + const turn = message("msg_plain") + const plain: ReasoningPart = { + id: partID("msg_plain", 3), + sessionID, + messageID: "msg_plain", + type: "reasoning", + text: "Checking the tests first.", + time: { start: 5000, end: 6000 }, + } + const parts: Part[] = [ + encrypted("msg_plain", 1, { start: 1000, end: 2000 }), + encrypted("msg_plain", 2, { start: 3000, end: 5000 }), + plain, + encrypted("msg_plain", 4, { start: 6500, end: 7000 }), + ] + const { app, emit, sync } = await mount(undefined, tmp.path, true, () => ) + + try { + emit(global({ id: "evt_plain_message", type: "message.updated", properties: { sessionID, info: turn } })) + for (const part of parts) { + emit(partUpdated(part)) + } + await wait(() => sync.data.part[turn.id]?.length === parts.length) + + const frame = await frameWhen( + app, + (rendered) => rendered.includes("Checking the tests first.") && rendered.includes("Thought · 500ms"), + ) + const lines = thoughtLines(frame) + expect(lines).toHaveLength(3) + expect(lines[0]).toContain("Thought 2 · 3.0s") + expect(lines[1]).toContain("Thought: 1.0s") + expect(lines[2]).toContain("Thought · 500ms") + const all = frame.split("\n") + const plainHeader = all.findIndex((line) => line.includes("Thought: 1.0s")) + const body = all.findIndex((line) => line.includes("Checking the tests first.")) + expect(body).toBeGreaterThan(plainHeader) + } finally { + app.renderer.destroy() + } +}) + +test("a live run keeps its label and selection as a third part goes in flight and completes", async () => { + await using tmp = await tmpdir() + await Bun.write(`${tmp.path}/kv.json`, "{}") + const live = message("msg_identity") + const { app, emit, sync } = await mount(undefined, tmp.path, true, () => ) + + try { + emit(global({ id: "evt_identity_message", type: "message.updated", properties: { sessionID, info: live } })) + emit(partUpdated(encrypted("msg_identity", 1, { start: 1000, end: 2000 }))) + emit(partUpdated(encrypted("msg_identity", 2, { start: 3000, end: 5000 }))) + await wait(() => sync.data.part[live.id]?.length === 2) + const before = "Thought 2 · 3.0s" + const frame = await frameWhen(app, (rendered) => rendered.includes(before)) + + const label = renderableWith(app.renderer.root, before) + expect(label).toBeDefined() + const y = frame.split("\n").findIndex((line) => line.includes(before)) + const x = frame.split("\n")[y].indexOf(before) + await app.mockMouse.drag(x, y, x + before.length - 1, y) + expect(app.renderer.hasSelection).toBe(true) + const selection = app.renderer.getSelectionContainer() + + // The third part arrives in flight: the line gains the spinner and keeps its label and selection. + emit( + global({ id: "evt_identity_busy", type: "session.status", properties: { sessionID, status: { type: "busy" } } }), + ) + const third = encrypted("msg_identity", 3, { start: 6000, end: 9000 }) + emit(partUpdated({ ...third, time: { start: 6000 } })) + await wait(() => sync.data.part[live.id]?.length === 3) + const inFlight = "Thought 3 · 3.0s" + const pending = await frameWhen(app, (rendered) => rendered.includes(inFlight)) + const pendingLine = pending.split("\n").find((line) => line.includes(inFlight)) ?? "" + expect(SPINNER_FRAMES.some((glyph) => pendingLine.includes(glyph))).toBe(true) + expect(renderableWith(app.renderer.root, inFlight) === label).toBe(true) + expect(app.renderer.hasSelection).toBe(true) + expect(app.renderer.getSelectionContainer() === selection).toBe(true) + + // The part completes: the same label and selection survive the spinner going away. + emit(partUpdated(third)) + await wait(() => { + const stored = sync.data.part[live.id]?.find((part) => part.id === third.id) + return stored?.type === "reasoning" && stored.time.end === 9000 + }) + const completed = "Thought 3 · 6.0s" + await frameWhen(app, (rendered) => rendered.includes(completed)) + expect(renderableWith(app.renderer.root, completed) === label).toBe(true) + expect(app.renderer.hasSelection).toBe(true) + expect(app.renderer.getSelectionContainer() === selection).toBe(true) + } finally { + app.renderer.destroy() + } +}) + +test("a message error ends the run, and the next message starts a new line", async () => { + await using tmp = await tmpdir() + await Bun.write(`${tmp.path}/kv.json`, "{}") + const failed = message("msg_failed", { error: { name: "UnknownError", data: { message: "Upstream failed" } } }) + const next = message("msg_next") + const failedParts = [ + encrypted("msg_failed", 1, { start: 1000, end: 2000 }), + encrypted("msg_failed", 2, { start: 3000, end: 5000 }), + ] + const nextParts = [encrypted("msg_next", 1, { start: 6000, end: 7000 })] + const { app, emit, sync } = await mount(undefined, tmp.path, true, () => ) + + try { + for (const info of [failed, next]) { + emit(global({ id: `evt_${info.id}`, type: "message.updated", properties: { sessionID, info } })) + } + for (const part of [...failedParts, ...nextParts]) { + emit(partUpdated(part)) + } + await wait(() => sync.data.part[next.id]?.length === nextParts.length) + + const frame = await frameWhen(app, (rendered) => rendered.includes("Upstream failed")) + const lines = thoughtLines(frame) + expect(lines).toHaveLength(2) + expect(lines[0]).toContain("Thought 2 · 3.0s") + expect(lines[1]).toContain("Thought · 1.0s") + expect(frame.indexOf(lines[0])).toBeLessThan(frame.indexOf("Upstream failed")) + expect(frame.indexOf("Upstream failed")).toBeLessThan(frame.indexOf(lines[1])) + } finally { + app.renderer.destroy() + } +}) diff --git a/packages/tui/test/routes/session/opaque-reasoning.test.ts b/packages/tui/test/routes/session/opaque-reasoning.test.ts new file mode 100644 index 000000000000..3a26bbf34bec --- /dev/null +++ b/packages/tui/test/routes/session/opaque-reasoning.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, test } from "bun:test" +import type { Part, ReasoningPart } from "@opencode-ai/sdk/v2" +import { + groupOpaqueReasoning, + isOpaqueReasoning, + type OpaqueReasoningGroups, +} from "../../../src/routes/session/opaque-reasoning" + +// Shapes follow session/processor.ts: reasoning-start creates the part with empty +// text and the provider metadata, and finishReasoning adds time.end. +const ids = { sessionID: "ses_test", messageID: "msg_test" } + +function encrypted(id: string): ReasoningPart { + return { + ...ids, + id, + type: "reasoning", + text: "", + metadata: { openai: { itemId: `rs_${id}` } }, + time: { start: 1, end: 2 }, + } +} + +function summarized(id: string, text: string): ReasoningPart { + return { ...ids, id, type: "reasoning", text, time: { start: 1, end: 2 } } +} + +function emptyReasoning(id: string): ReasoningPart { + return { ...ids, id, type: "reasoning", text: "", time: { start: 1, end: 2 } } +} + +function text(id: string, value: string): Part { + return { ...ids, id, type: "text", text: value } +} + +function tool(id: string): Part { + return { + ...ids, + id, + type: "tool", + callID: `call_${id}`, + tool: "bash", + state: { status: "completed", input: {}, output: "ok", title: "", metadata: {}, time: { start: 1, end: 2 } }, + } +} + +function failedTool(id: string): Part { + return { + ...ids, + id, + type: "tool", + callID: `call_${id}`, + tool: "apply_patch", + state: { status: "error", input: {}, error: "Patch failed", time: { start: 1, end: 2 } }, + } +} + +function stepStart(id: string): Part { + return { ...ids, id, type: "step-start" } +} + +function stepFinish(id: string): Part { + return { + ...ids, + id, + type: "step-finish", + reason: "tool-calls", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + } +} + +function file(id: string): Part { + return { ...ids, id, type: "file", mime: "text/plain", url: "file:///notes.txt" } +} + +// Each row by its id, or as "run:" when the row leads a run. +function shape(groups: OpaqueReasoningGroups) { + return groups.rows.map((row) => { + const members = groups.runs.get(row.id) + return members ? `run:${members.map((part) => part.id).join(",")}` : row.id + }) +} + +describe("groupOpaqueReasoning", () => { + test("collapses a run of consecutive encrypted parts into one row", () => { + const parts = [encrypted("a"), encrypted("b"), encrypted("c"), encrypted("d")] + + const groups = groupOpaqueReasoning(parts) + + expect(shape(groups)).toEqual(["run:a,b,c,d"]) + expect(groups.runs.get("a")).toEqual(parts) + }) + + test("a lone encrypted part is a run of one and keeps its object", () => { + const part = encrypted("a") + + const groups = groupOpaqueReasoning([part]) + + expect(groups.rows).toHaveLength(1) + expect(groups.rows[0]).toBe(part) + expect(groups.runs.get("a")).toEqual([part]) + }) + + test("a run keeps its first part's object as it grows", () => { + const first = encrypted("a") + + const before = groupOpaqueReasoning([first, encrypted("b")]) + const after = groupOpaqueReasoning([first, encrypted("b"), encrypted("c")]) + + expect(before.rows[0]).toBe(first) + expect(after.rows[0]).toBe(first) + expect(after.runs.get("a")).toHaveLength(3) + }) + + test("a tool call ends the run, and the next encrypted part starts a new one", () => { + const groups = groupOpaqueReasoning([encrypted("a"), encrypted("b"), tool("call"), encrypted("c")]) + + expect(shape(groups)).toEqual(["run:a,b", "call", "run:c"]) + }) + + test("visible text ends the run, and the next encrypted part starts a new one", () => { + const groups = groupOpaqueReasoning([ + encrypted("a"), + encrypted("b"), + text("answer", "Here is the result."), + encrypted("c"), + encrypted("d"), + ]) + + expect(shape(groups)).toEqual(["run:a,b", "answer", "run:c,d"]) + }) + + test("a failed tool call ends the run", () => { + const groups = groupOpaqueReasoning([encrypted("a"), failedTool("failed"), encrypted("b"), encrypted("c")]) + + expect(shape(groups)).toEqual(["run:a", "failed", "run:b,c"]) + }) + + test("parts that render no line do not end a run, and stay in order", () => { + const groups = groupOpaqueReasoning([ + encrypted("a"), + stepFinish("finish"), + stepStart("start"), + text("blank", " \n"), + emptyReasoning("e"), + encrypted("b"), + ]) + + expect(shape(groups)).toEqual(["run:a,b", "finish", "start", "blank", "e"]) + }) + + test("a part type the view does not render yet keeps its place", () => { + const groups = groupOpaqueReasoning([encrypted("a"), file("attachment"), encrypted("b"), tool("call")]) + + expect(shape(groups)).toEqual(["run:a,b", "attachment", "call"]) + }) + + test("summarized reasoning ends the run and renders as its own part", () => { + const groups = groupOpaqueReasoning([ + encrypted("a"), + encrypted("b"), + summarized("plan", "Reading the config first"), + encrypted("c"), + ]) + + expect(shape(groups)).toEqual(["run:a,b", "plan", "run:c"]) + }) + + test("a [REDACTED] placeholder with metadata is still encrypted and joins the run", () => { + const redacted: ReasoningPart = { ...encrypted("redacted"), text: "[REDACTED]" } + + const groups = groupOpaqueReasoning([encrypted("a"), redacted, encrypted("b")]) + + expect(shape(groups)).toEqual(["run:a,redacted,b"]) + }) + + test("returns no rows for no parts", () => { + const groups = groupOpaqueReasoning([]) + + expect(groups.rows).toEqual([]) + expect(groups.runs.size).toBe(0) + }) +}) + +describe("isOpaqueReasoning", () => { + test("requires provider metadata and no visible text", () => { + expect(isOpaqueReasoning(encrypted("a"))).toBe(true) + expect(isOpaqueReasoning(summarized("s", "Reading the config"))).toBe(false) + expect(isOpaqueReasoning(emptyReasoning("e"))).toBe(false) + }) +})