fix(tui): harden reasoning groups (#36913)
This commit is contained in:
parent
ece2b16cdf
commit
a149a61a89
4 changed files with 138 additions and 97 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue