fix(tui): harden reasoning groups (#36913)

This commit is contained in:
Kit Langton 2026-07-14 16:38:42 -04:00 committed by GitHub
commit a149a61a89
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 138 additions and 97 deletions

View file

@ -1092,32 +1092,28 @@ function SessionReasoningGroupView(props: {
const renderer = useRenderer()
const [expanded, setExpanded] = createSignal(false)
const [hover, setHover] = createSignal(false)
const parts = createMemo<{ message: SessionMessageAssistant; part: SessionMessageAssistantReasoning }[]>(
(previous) => {
const next = props.refs.flatMap((ref) => {
const message = props.message(ref.messageID)
if (message?.type !== "assistant") return []
const part = resolvePart(message, ref.partID)
if (part?.type !== "reasoning" || !part.text.replace("[REDACTED]", "").trim()) return []
return [{ message, part }]
})
return next.length > 0 ? next : previous
},
[] as { message: SessionMessageAssistant; part: SessionMessageAssistantReasoning }[],
const parts = createMemo(() =>
props.refs.flatMap((ref) => {
const message = props.message(ref.messageID)
if (message?.type !== "assistant") return []
const part = resolvePart(message, ref.partID)
if (part?.type !== "reasoning" || !reasoningContent(part)) return []
return [{ message, part }]
}),
)
const latest = createMemo((previous: string | null) => {
const item = parts().at(-1)
if (!item) return previous
const title = reasoningSummary(item.part.text.replace("[REDACTED]", "").trim()).title
const title = reasoningSummary(reasoningContent(item.part)).title
if (title) return title
if (item.part.time?.completed !== undefined || item.message.time.completed !== undefined) return null
return previous
}, null)
const duration = createMemo(() =>
parts().reduce((total, item) => {
const end = item.part.time?.completed ?? item.message.time.completed
const start = item.part.time?.created ?? item.message.time.created
return total + (end === undefined ? 0 : Math.max(0, end - start))
const start = item.part.time?.created
const end = item.part.time?.completed
return total + (start === undefined || end === undefined ? 0 : Math.max(0, end - start))
}, 0),
)
@ -1125,9 +1121,7 @@ function SessionReasoningGroupView(props: {
<Show when={parts().length > 0}>
<Show
when={ctx.thinkingMode() === "hide"}
fallback={
<For each={parts()}>{(item) => <ReasoningPart part={item.part} message={item.message} last={false} />}</For>
}
fallback={<For each={props.refs}>{(ref) => <SessionPartView partRef={ref} message={props.message} />}</For>}
>
<box flexDirection="column" flexShrink={0}>
<InlineToolRow
@ -1156,27 +1150,45 @@ function SessionReasoningGroupView(props: {
</InlineToolRow>
<Show when={expanded()}>
<box paddingLeft={3}>
<For each={parts()}>
{(item) => (
<box marginTop={1}>
<box
border={["left"]}
customBorderChars={SplitBorder.customBorderChars}
borderColor={theme.backgroundElement}
paddingLeft={1}
>
<code
filetype="markdown"
drawUnstyledText={false}
streaming={false}
syntaxStyle={syntax()}
content={item.part.text.replace("[REDACTED]", "").trim()}
conceal={ctx.markdownMode() === "rendered"}
fg={theme.textMuted}
/>
</box>
</box>
)}
<For each={props.refs}>
{(ref) => {
const message = createMemo(() => {
const item = props.message(ref.messageID)
return item?.type === "assistant" ? item : undefined
})
const part = createMemo(() => {
const item = message()
if (!item) return undefined
const part = resolvePart(item, ref.partID)
return part?.type === "reasoning" ? part : undefined
})
const content = createMemo(() => {
const item = part()
return item ? reasoningContent(item) : ""
})
return (
<Show when={content()}>
<box marginTop={1}>
<box
border={["left"]}
customBorderChars={SplitBorder.customBorderChars}
borderColor={theme.backgroundElement}
paddingLeft={1}
>
<code
filetype="markdown"
drawUnstyledText={false}
streaming={part()?.time?.completed === undefined && message()?.time.completed === undefined}
syntaxStyle={syntax()}
content={content()}
conceal={ctx.markdownMode() === "rendered"}
fg={theme.textMuted}
/>
</box>
</box>
</Show>
)
}}
</For>
</box>
</Show>
@ -1789,10 +1801,7 @@ function ReasoningPart(props: {
// 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 content = createMemo(() => reasoningContent(props.part))
const isDone = createMemo(
() => props.part.time?.completed !== undefined || props.message.time.completed !== undefined,
)
@ -1852,6 +1861,11 @@ function ReasoningPart(props: {
)
}
function reasoningContent(part: SessionMessageAssistantReasoning) {
// OpenRouter encrypts some reasoning blocks; drop the placeholder.
return part.text.replace("[REDACTED]", "").trim()
}
function ReasoningHeader(props: {
toggleable: boolean
open: boolean

View file

@ -132,39 +132,11 @@ export function createSessionRows(sessionID: Accessor<string>) {
}),
)
const appendPart = (ref: PartRef, name?: string) =>
const appendPart = (ref: PartRef, part: AppendPart) =>
setRows(
produce((draft) => {
if (hasPart(draft, ref)) return
const index = queuedStart(draft)
if (ref.partID.startsWith("reasoning:")) {
const previous = draft[index - 1]
if (previous?.type === "group" && previous.kind === "reasoning") {
previous.refs.push(ref)
return
}
completePrevious(draft, index)
draft.splice(index, 0, { type: "group", kind: "reasoning", refs: [ref], completed: false })
return
}
if (name && exploration(name)) {
const previous = draft[index - 1]
if (previous?.type === "group" && previous.kind === "exploration") {
previous.refs.push(ref)
return
}
completePrevious(draft, index)
draft.splice(index, 0, {
type: "group",
kind: "exploration",
refs: [ref],
pending: [],
completed: false,
})
return
}
completePrevious(draft, index)
draft.splice(index, 0, { type: "part", ref })
append(draft, ref, part, queuedStart(draft))
}),
)
@ -230,23 +202,32 @@ export function createSessionRows(sessionID: Accessor<string>) {
data.on("session.model.selected", message),
data.on("session.text.delta", (event) => {
if (event.data.sessionID === sessionID() && event.data.delta.trim())
appendPart({ messageID: event.data.assistantMessageID, partID: `text:${event.data.ordinal}` })
appendPart({ messageID: event.data.assistantMessageID, partID: `text:${event.data.ordinal}` }, { type: "text" })
}),
data.on("session.text.ended", (event) => {
if (event.data.sessionID === sessionID() && event.data.text.trim())
appendPart({ messageID: event.data.assistantMessageID, partID: `text:${event.data.ordinal}` })
appendPart({ messageID: event.data.assistantMessageID, partID: `text:${event.data.ordinal}` }, { type: "text" })
}),
data.on("session.reasoning.delta", (event) => {
if (event.data.sessionID === sessionID())
appendPart({ messageID: event.data.assistantMessageID, partID: `reasoning:${event.data.ordinal}` })
if (event.data.sessionID === sessionID() && event.data.delta.trim())
appendPart(
{ messageID: event.data.assistantMessageID, partID: `reasoning:${event.data.ordinal}` },
{ type: "reasoning" },
)
}),
data.on("session.reasoning.ended", (event) => {
if (event.data.sessionID === sessionID() && event.data.text.trim())
appendPart({ messageID: event.data.assistantMessageID, partID: `reasoning:${event.data.ordinal}` })
appendPart(
{ messageID: event.data.assistantMessageID, partID: `reasoning:${event.data.ordinal}` },
{ type: "reasoning" },
)
}),
data.on("session.tool.input.started", (event) => {
if (event.data.sessionID === sessionID())
appendPart({ messageID: event.data.assistantMessageID, partID: event.data.callID }, event.data.name)
appendPart(
{ messageID: event.data.assistantMessageID, partID: event.data.callID },
{ type: "tool", name: event.data.name },
)
}),
data.on("session.retry.scheduled", (event) => {
if (event.data.sessionID === sessionID()) appendFooter(event.data.assistantMessageID)
@ -305,31 +286,31 @@ export function resolvePart(message: SessionMessageAssistant, partID: string) {
return message.content.filter((part) => part.type === match[1])[ordinal]
}
function append(rows: SessionRow[], ref: PartRef, part: SessionMessageAssistant["content"][number]) {
type AppendPart = { type: "text" } | { type: "reasoning" } | { type: "tool"; name: string }
function append(rows: SessionRow[], ref: PartRef, part: AppendPart, index = rows.length) {
if (part.type === "reasoning") {
const previous = rows.at(-1)
const previous = rows[index - 1]
if (previous?.type === "group" && previous.kind === "reasoning") {
previous.refs.push(ref)
return
}
completePrevious(rows)
rows.push({ type: "group", kind: "reasoning", refs: [ref], completed: false })
completePrevious(rows, index)
rows.splice(index, 0, { type: "group", kind: "reasoning", refs: [ref], completed: false })
return
}
if (part.type === "tool") {
if (exploration(part.name)) {
const previous = rows.at(-1)
if (previous?.type === "group" && previous.kind === "exploration") {
previous.refs.push(ref)
return
}
completePrevious(rows)
rows.push({ type: "group", kind: "exploration", refs: [ref], pending: [], completed: false })
if (part.type === "tool" && exploration(part.name)) {
const previous = rows[index - 1]
if (previous?.type === "group" && previous.kind === "exploration") {
previous.refs.push(ref)
return
}
completePrevious(rows, index)
rows.splice(index, 0, { type: "group", kind: "exploration", refs: [ref], pending: [], completed: false })
return
}
completePrevious(rows)
rows.push({ type: "part", ref })
completePrevious(rows, index)
rows.splice(index, 0, { type: "part", ref })
}
function completePrevious(rows: SessionRow[], index = rows.length) {

View file

@ -737,6 +737,52 @@ test("completes exploration when a queued prompt is promoted", async () => {
}
})
test("classifies live tool rows independently of their call ID", async () => {
const events = createEventStream()
const sessionID = "session-tool-call-id"
const calls = createFetch((url) => {
if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} })
}, events)
let rows!: ReturnType<typeof createSessionRows>
function Probe() {
rows = createSessionRows(() => sessionID)
return <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<ClientProvider api={createApi(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</ClientProvider>
</TestTuiContexts>
))
try {
emitEvent(events, {
id: "evt_tool_started",
created: 1,
type: "session.tool.input.started",
durable: durable(sessionID),
data: {
sessionID,
assistantMessageID: "message-assistant",
callID: "reasoning:0",
name: "bash",
},
})
await wait(() => rows.length > 0)
expect(rows).toEqual([{ type: "part", ref: { messageID: "message-assistant", partID: "reasoning:0" } }])
} finally {
app.renderer.destroy()
}
})
test("removes committed revert messages from local state", async () => {
const events = createEventStream()
const sessionID = "session-revert"

View file

@ -38,7 +38,7 @@ test("keeps non-exploration tools as individual part rows", () => {
const messages: SessionMessageInfo[] = [
assistant("assistant-1", [
{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 1 } },
{ type: "tool", id: "bash-1", name: "bash", state: pending(), time: { created: 2 } },
{ type: "tool", id: "reasoning:0", name: "bash", state: pending(), time: { created: 2 } },
{ type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 3 } },
]),
]
@ -51,7 +51,7 @@ test("keeps non-exploration tools as individual part rows", () => {
completed: true,
refs: [{ messageID: "assistant-1", partID: "read-1" }],
},
{ type: "part", ref: { messageID: "assistant-1", partID: "bash-1" } },
{ type: "part", ref: { messageID: "assistant-1", partID: "reasoning:0" } },
{
type: "group",
kind: "exploration",