diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 9a3456c7a9..f8d3302461 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -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: { 0}> {(item) => } - } + fallback={{(ref) => }} > - - {(item) => ( - - - - - - )} + + {(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 ( + + + + + + + + ) + }} @@ -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 diff --git a/packages/tui/src/routes/session/rows.ts b/packages/tui/src/routes/session/rows.ts index 6d3f4cf422..3fb9323715 100644 --- a/packages/tui/src/routes/session/rows.ts +++ b/packages/tui/src/routes/session/rows.ts @@ -132,39 +132,11 @@ export function createSessionRows(sessionID: Accessor) { }), ) - 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) { 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) { diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index 493c290325..ae1eabf683 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -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 + + function Probe() { + rows = createSessionRows(() => sessionID) + return + } + + const app = await testRender(() => ( + + + + + + + + + + )) + + 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" diff --git a/packages/tui/test/cli/tui/session-rows.test.ts b/packages/tui/test/cli/tui/session-rows.test.ts index b0f2f64fbc..e158a377b0 100644 --- a/packages/tui/test/cli/tui/session-rows.test.ts +++ b/packages/tui/test/cli/tui/session-rows.test.ts @@ -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",