feat(tui): add quark timeline experiment

This commit is contained in:
Kit Langton 2026-07-17 16:49:14 -04:00
commit daf8e539bf
22 changed files with 2804 additions and 122 deletions

View file

@ -92,6 +92,7 @@
"clipboardy": "4.0.0",
"diff": "catalog:",
"effect": "catalog:",
"effect-quark": "workspace:*",
"fuzzysort": "catalog:",
"get-east-asian-width": "catalog:",
"open": "10.1.2",

View file

@ -13,6 +13,7 @@ import {
Switch,
useContext,
} from "solid-js"
import { KeyedFor } from "effect-quark/solid"
import path from "node:path"
import { EOL, tmpdir } from "node:os"
import { mkdir, writeFile } from "node:fs/promises"
@ -213,7 +214,7 @@ export function Session() {
})
const editor = useEditorContext()
const rows = createSessionRows(() => route.sessionID)
const boundaries = createMemo(() => messageBoundaryIDs(rows, messages()))
const boundaries = createMemo(() => messageBoundaryIDs(rows.slots().map((slot) => slot()), messages()))
const [navigationMessage, setNavigationMessage] = createSignal<string>()
const [navigationSlack, setNavigationSlack] = createSignal(0)
@ -928,15 +929,15 @@ export function Session() {
flexGrow={1}
scrollAcceleration={scrollAcceleration()}
>
<For each={rows}>
<KeyedFor each={rows.slots}>
{(row, index) => (
<SessionRowView
row={row}
row={row()}
message={(messageID) => data.session.message.get(route.sessionID, messageID)}
boundaryID={boundaries()[index()]}
/>
)}
</For>
</KeyedFor>
<BackgroundToolHint messages={messages()} />
<Show when={session()?.revert?.messageID}>
<RevertMessage

View file

@ -1,39 +1,73 @@
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client"
import { createEffect, on, onCleanup, type Accessor } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
import { Keyed, Transaction } from "effect-quark"
import { useValue } from "effect-quark/solid"
import { batch, createEffect, on, onCleanup, type Accessor } from "solid-js"
import { useData } from "../../context/data"
import { useClient } from "../../context/client"
export type PartRef = {
messageID: string
partID: string
readonly messageID: string
readonly partID: string
}
export type SessionRow =
export type SessionRow = { readonly id: string } & (
| { type: "message"; messageID: string }
| { type: "compaction-queued"; inputID: string }
| { type: "part"; ref: PartRef }
| {
type: "group"
kind: "reasoning"
origin: PartRef
refs: PartRef[]
completed: boolean
}
| {
type: "group"
kind: "exploration"
origin: PartRef
refs: PartRef[]
pending: PartRef[]
completed: boolean
}
| { type: "assistant-footer"; messageID: string }
)
export function createSessionRows(sessionID: Accessor<string>) {
export function createSessionRows(sessionID: Accessor<string>, options?: { readonly metrics?: Keyed.Metrics }) {
const data = useData()
const client = useClient()
const [rows, setRows] = createStore<SessionRow[]>([])
const reportMetrics = process.env.OPENCODE_QUARK_METRICS === "1"
const metrics = options?.metrics ?? (reportMetrics ? Keyed.metrics() : undefined)
const state = Keyed.make({ key: rowKey, equivalent: sameRow, metrics })
const seenParts = new Set<string>()
const rows = {
slots: useValue(state.slots),
values: state.values,
}
const revertBoundary = () => data.session.get(sessionID())?.revert?.messageID
const setRows = (value: SessionRow[]) => {
batch(() => {
state.set(value)
seenParts.clear()
value.forEach((row) => {
if (row.type === "part") {
seenParts.add(row.id)
return
}
if (row.type !== "group") return
row.refs.forEach((ref) => seenParts.add(partRowID(ref)))
if (row.kind === "exploration") row.pending.forEach((ref) => seenParts.add(partRowID(ref)))
})
})
}
const mutate = (f: () => void) => batch(() => Transaction.run(f))
const insert = (current: readonly SessionRow[], index: number, row: SessionRow) =>
state.insert(row, index === current.length ? "end" : { before: current[index].id })
const complete = (current: readonly SessionRow[], index: number) => {
const previous = current[index - 1]
if (previous?.type === "group" && !previous.completed) state.update({ ...previous, completed: true })
}
function reduce() {
const messages = data.session.message.list(sessionID())
const inputs = new Set(data.session.input.list(sessionID()))
@ -47,7 +81,7 @@ export function createSessionRows(sessionID: Accessor<string>) {
...data.session.pending
.list(sessionID())
.filter((item) => item.type === "compaction")
.map((item): SessionRow => ({ type: "compaction-queued", inputID: item.id })),
.map((item) => compactionQueuedRow(item.id)),
)
return rows
}
@ -62,22 +96,31 @@ export function createSessionRows(sessionID: Accessor<string>) {
createEffect(() => {
const pending = pendingPermissions()
setRows(
produce((draft) => {
partitionPending(draft, pending)
}),
)
mutate(() => {
state.values().forEach((row) => {
if (row.type !== "group" || row.kind !== "exploration") return
const changed =
row.refs.some((ref) => pending.has(ref.partID)) || row.pending.some((ref) => !pending.has(ref.partID))
if (!changed) return
const refs = [...row.refs, ...row.pending]
state.update({
...row,
refs: refs.filter((ref) => !pending.has(ref.partID)),
pending: refs.filter((ref) => pending.has(ref.partID)),
})
})
})
})
createEffect(
on([sessionID, () => client.connection.status()], ([id, status]) => {
if (status !== "connected") return
setRows(reconcile(reduce()))
setRows(reduce())
void data.session.pending.sync(id).catch(() => undefined)
void data.session.message.sync(id).then(
() => {
if (sessionID() !== id) return
setRows(reconcile(reduce()))
setRows(reduce())
},
() => undefined,
)
@ -87,7 +130,7 @@ export function createSessionRows(sessionID: Accessor<string>) {
// Re-reduce when the revert boundary changes (stage/clear/commit).
createEffect(
on(revertBoundary, () => {
setRows(reconcile(reduce()))
setRows(reduce())
}),
)
@ -98,7 +141,7 @@ export function createSessionRows(sessionID: Accessor<string>) {
.list(sessionID())
.filter((item) => item.type === "compaction")
.map((item) => item.id),
() => setRows(reconcile(reduce())),
() => setRows(reduce()),
),
)
@ -123,48 +166,57 @@ export function createSessionRows(sessionID: Accessor<string>) {
]
: [],
),
() => setRows(reconcile(reduce())),
() => setRows(reduce()),
),
)
const appendMessage = (messageID: string) =>
setRows(
produce((draft) => {
if (draft.some((row) => row.type === "message" && row.messageID === messageID)) return
const pending = isPending(messageID)
const message = data.session.message.get(sessionID(), messageID)
const index =
message?.type === "compaction" && pending ? queuedStart(draft) : pending ? draft.length : queuedStart(draft)
if (!pending) completePrevious(draft, index)
draft.splice(index, 0, { type: "message", messageID })
}),
)
mutate(() => {
if (state.has(messageRowID(messageID))) return
const current = state.values()
const pending = isPending(messageID)
const message = data.session.message.get(sessionID(), messageID)
const index =
message?.type === "compaction" && pending
? queuedStart(current)
: pending
? current.length
: queuedStart(current)
if (!pending) complete(current, index)
insert(current, index, messageRow(messageID))
})
const appendPart = (ref: PartRef, part: AppendPart) =>
setRows(
produce((draft) => {
if (hasPart(draft, ref)) return
append(draft, ref, part, queuedStart(draft))
}),
)
mutate(() => {
const id = partRowID(ref)
if (seenParts.has(id)) return
const current = state.values()
const index = queuedStart(current)
const previous = current[index - 1]
const decision = appendDecision(previous, ref, part)
if (decision.type === "join") {
state.update({ ...decision.group, refs: [...decision.group.refs, ref] })
seenParts.add(id)
return
}
complete(current, index)
insert(current, index, decision.row)
seenParts.add(id)
})
const appendFooter = (messageID: string) =>
setRows(
produce((draft) => {
if (draft.some((row) => row.type === "assistant-footer" && row.messageID === messageID)) return
const index = queuedStart(draft)
completePrevious(draft, index)
draft.splice(index, 0, { type: "assistant-footer", messageID })
}),
)
mutate(() => {
if (state.has(footerRowID(messageID))) return
const current = state.values()
const index = queuedStart(current)
complete(current, index)
insert(current, index, footerRow(messageID))
})
const removeFooter = (messageID: string) =>
setRows(
produce((draft) => {
const index = draft.findIndex((row) => row.type === "assistant-footer" && row.messageID === messageID)
if (index !== -1) draft.splice(index, 1)
}),
)
mutate(() => {
state.remove(footerRowID(messageID))
})
const isPending = (messageID: string) => {
const message = data.session.message.get(sessionID(), messageID)
@ -172,7 +224,7 @@ export function createSessionRows(sessionID: Accessor<string>) {
return message?.type === "compaction" && message.status === "running"
}
const queuedStart = (rows: SessionRow[]) => {
const queuedStart = (rows: readonly SessionRow[]) => {
const index = rows.findIndex(
(row) => row.type === "compaction-queued" || (row.type === "message" && isPending(row.messageID)),
)
@ -252,6 +304,9 @@ export function createSessionRows(sessionID: Accessor<string>) {
}),
]
onCleanup(() => subscriptions.forEach((unsubscribe) => unsubscribe()))
onCleanup(() => {
if (reportMetrics && metrics) console.error(`QUARK_TIMELINE_METRICS ${sessionID()} ${JSON.stringify(metrics)}`)
})
return rows
}
@ -268,7 +323,7 @@ export function reduceSessionRows(messages: SessionMessageInfo[], inputs = new S
if (message.type !== "assistant") {
if (message.type === "synthetic" && !message.description?.trim()) return rows
if (!pending.has(message.id)) completePrevious(rows)
rows.push({ type: "message", messageID: message.id })
rows.push(messageRow(message.id))
return rows
}
const ordinals = { text: 0, reasoning: 0 }
@ -279,13 +334,20 @@ export function reduceSessionRows(messages: SessionMessageInfo[], inputs = new S
})
if ((message.finish && !["tool-calls", "unknown"].includes(message.finish)) || message.error || message.retry) {
completePrevious(rows)
rows.push({ type: "assistant-footer", messageID: message.id })
rows.push(footerRow(message.id))
}
return rows
}, [])
}
export function messageBoundaryIDs(rows: SessionRow[], messages: SessionMessageInfo[]) {
type BoundaryRow =
| Pick<Extract<SessionRow, { type: "message" }>, "type" | "messageID">
| Pick<Extract<SessionRow, { type: "compaction-queued" }>, "type">
| Pick<Extract<SessionRow, { type: "part" }>, "type" | "ref">
| Pick<Extract<SessionRow, { type: "group" }>, "type" | "origin">
| Pick<Extract<SessionRow, { type: "assistant-footer" }>, "type" | "messageID">
export function messageBoundaryIDs(rows: readonly BoundaryRow[], messages: SessionMessageInfo[]) {
const byID = new Map(messages.map((message) => [message.id, message]))
const seen = new Set<string>()
return rows.map((row) => {
@ -296,7 +358,7 @@ export function messageBoundaryIDs(rows: SessionRow[], messages: SessionMessageI
})
}
function rowBoundaryMessageID(row: SessionRow, messages: Map<string, SessionMessageInfo>) {
function rowBoundaryMessageID(row: BoundaryRow, messages: Map<string, SessionMessageInfo>) {
if (row.type === "message") {
const message = messages.get(row.messageID)
if (message?.type === "user" && message.text.trim()) return message.id
@ -306,7 +368,7 @@ function rowBoundaryMessageID(row: SessionRow, messages: Map<string, SessionMess
row.type === "part"
? row.ref.messageID
: row.type === "group"
? row.refs[0]?.messageID
? row.origin.messageID
: row.type === "assistant-footer"
? row.messageID
: undefined
@ -327,28 +389,25 @@ export function resolvePart(message: SessionMessageAssistant, partID: string) {
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[index - 1]
if (previous?.type === "group" && previous.kind === "reasoning") {
previous.refs.push(ref)
return
}
completePrevious(rows, index)
rows.splice(index, 0, { type: "group", kind: "reasoning", refs: [ref], completed: false })
return
}
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 })
const previous = rows[index - 1]
const decision = appendDecision(previous, ref, part)
if (decision.type === "join") {
decision.group.refs.push(ref)
return
}
completePrevious(rows, index)
rows.splice(index, 0, { type: "part", ref })
rows.splice(index, 0, decision.row)
}
function appendDecision(previous: SessionRow | undefined, ref: PartRef, part: AppendPart) {
const kind = groupKind(part)
if (kind && previous?.type === "group" && previous.kind === kind) return { type: "join" as const, group: previous }
return { type: "insert" as const, row: kind ? groupRow(kind, ref) : partRow(ref) }
}
function groupKind(part: AppendPart) {
if (part.type === "reasoning") return "reasoning" as const
if (part.type === "tool" && exploration(part.name)) return "exploration" as const
}
function completePrevious(rows: SessionRow[], index = rows.length) {
@ -369,11 +428,64 @@ function exploration(name: string) {
return ["read", "glob", "grep"].includes(name.toLowerCase())
}
function hasPart(rows: SessionRow[], ref: PartRef) {
return rows.some((row) => {
if (row.type === "part") return row.ref.messageID === ref.messageID && row.ref.partID === ref.partID
if (row.type !== "group") return false
const refs = row.kind === "exploration" ? [...row.refs, ...row.pending] : row.refs
return refs.some((item) => item.messageID === ref.messageID && item.partID === ref.partID)
})
function messageRow(messageID: string): SessionRow {
return { id: messageRowID(messageID), type: "message", messageID }
}
function messageRowID(messageID: string) {
return `m${segment(messageID)}`
}
function compactionQueuedRow(inputID: string): SessionRow {
return { id: `c${segment(inputID)}`, type: "compaction-queued", inputID }
}
function partRow(ref: PartRef): SessionRow {
return { id: partRowID(ref), type: "part", ref }
}
function partRowID(ref: PartRef) {
return `p${segment(ref.messageID)}${segment(ref.partID)}`
}
function groupRow(kind: "reasoning" | "exploration", ref: PartRef): SessionRow {
const id = `g${kind === "reasoning" ? "r" : "e"}${segment(ref.messageID)}${segment(ref.partID)}`
if (kind === "reasoning") return { id, type: "group", kind, origin: ref, refs: [ref], completed: false }
return { id, type: "group", kind, origin: ref, refs: [ref], pending: [], completed: false }
}
function footerRow(messageID: string): SessionRow {
return { id: footerRowID(messageID), type: "assistant-footer", messageID }
}
function footerRowID(messageID: string) {
return `f${segment(messageID)}`
}
function segment(value: string) {
return `${value.length}:${value}`
}
function rowKey(row: SessionRow) {
return row.id
}
function sameRow(left: SessionRow, right: SessionRow) {
if (left.type !== right.type) return false
if (left.type === "message" && right.type === "message") return left.messageID === right.messageID
if (left.type === "compaction-queued" && right.type === "compaction-queued") return left.inputID === right.inputID
if (left.type === "part" && right.type === "part") return sameRef(left.ref, right.ref)
if (left.type === "assistant-footer" && right.type === "assistant-footer") return left.messageID === right.messageID
if (left.type !== "group" || right.type !== "group") return false
if (left.kind !== right.kind || left.completed !== right.completed || !sameRefs(left.refs, right.refs)) return false
if (left.kind === "reasoning" || right.kind === "reasoning") return true
return sameRefs(left.pending, right.pending)
}
function sameRefs(left: PartRef[], right: PartRef[]) {
return left.length === right.length && left.every((ref, index) => sameRef(ref, right[index]))
}
function sameRef(left: PartRef, right: PartRef) {
return left.messageID === right.messageID && left.partID === right.partID
}

View file

@ -28,6 +28,19 @@ async function wait(fn: () => boolean, timeout = 2000) {
}
}
function readRows(rows: ReturnType<typeof createSessionRows>) {
return rows.values()
}
function withoutRowID(row: SessionRow) {
const { id: _id, ...value } = row
if (value.type === "group") {
const { origin: _origin, ...group } = value
return group
}
return value
}
function emitEvent(events: ReturnType<typeof createEventStream>, event: OpenCodeEvent) {
events.emit({ ...event, location: { directory } })
}
@ -717,10 +730,16 @@ test("completes exploration when a queued prompt is promoted", async () => {
}, events)
let rows!: ReturnType<typeof createSessionRows>
let client!: ReturnType<typeof useClient>
let structuralUpdates = 0
const metrics = { slotPublications: 0, structuralPublications: 0, equivalenceSuppressions: 0 }
function Probe() {
client = useClient()
rows = createSessionRows(() => sessionID)
rows = createSessionRows(() => sessionID, { metrics })
createEffect(() => {
rows.slots()
structuralUpdates++
})
return <box />
}
@ -762,31 +781,81 @@ test("completes exploration when a queued prompt is promoted", async () => {
name: "read",
},
})
await wait(() => rows.some((row) => row.type === "group" && !row.completed))
await wait(() => readRows(rows).some((row) => row.type === "group" && !row.completed))
expect(metrics.slotPublications).toBe(0)
expect(metrics.structuralPublications).toBe(1)
emitEvent(events, {
id: "evt_tool_started_2",
created: 2,
type: "session.tool.input.started",
durable: durable(sessionID, 2),
data: {
sessionID,
assistantMessageID: "message-assistant",
callID: "call-read-2",
name: "read",
},
})
await wait(() => {
const group = readRows(rows).find((row) => row.type === "group")
return group?.refs.length === 2
})
expect(metrics.slotPublications).toBe(1)
expect(metrics.structuralPublications).toBe(1)
const groupSlot = rows.slots().find((slot) => slot().type === "group")
expect(groupSlot).toBeDefined()
const beforePermission = structuralUpdates
emitEvent(events, {
id: "evt_permission_asked",
created: 2,
type: "permission.v2.asked",
data: {
id: "permission-read",
sessionID,
action: "read",
resources: ["src/example.ts"],
source: { type: "tool", messageID: "message-assistant", callID: "call-read" },
},
})
await wait(() => {
const group = readRows(rows).find((row) => row.type === "group" && row.kind === "exploration")
return group?.pending[0]?.partID === "call-read"
})
expect(rows.slots().find((slot) => slot().type === "group")).toBe(groupSlot)
expect(structuralUpdates).toBe(beforePermission)
expect(metrics.slotPublications).toBe(2)
expect(metrics.structuralPublications).toBe(1)
emitEvent(events, {
id: "evt_prompt_admitted",
created: 3,
type: "session.input.admitted",
durable: durable(sessionID, 2),
durable: durable(sessionID, 3),
data: {
sessionID,
inputID: "message-user",
input: { type: "user", data: { text: "Continue" }, delivery: "steer" },
},
})
await wait(() => rows.at(-1)?.type === "message")
expect(rows.find((row) => row.type === "group")?.completed).toBe(false)
await wait(() => readRows(rows).at(-1)?.type === "message")
expect(readRows(rows).find((row) => row.type === "group")?.completed).toBe(false)
expect(metrics.slotPublications).toBe(2)
expect(metrics.structuralPublications).toBe(2)
emitEvent(events, {
id: "evt_prompt_promoted",
created: 4,
type: "session.input.promoted",
durable: durable(sessionID, 3),
durable: durable(sessionID, 4),
data: { sessionID, inputID: "message-user" },
})
await wait(() => rows.find((row) => row.type === "group")?.completed === true)
expect(rows.at(-1)).toEqual({ type: "message", messageID: "message-user" })
await wait(() => readRows(rows).find((row) => row.type === "group")?.completed === true)
expect(rows.slots().find((slot) => slot().type === "group")).toBe(groupSlot)
expect(withoutRowID(readRows(rows).at(-1)!)).toEqual({ type: "message", messageID: "message-user" })
expect(metrics.slotPublications).toBe(3)
expect(metrics.structuralPublications).toBe(2)
} finally {
app.renderer.destroy()
}
@ -834,8 +903,90 @@ test("classifies live tool rows independently of their call ID", async () => {
},
})
await wait(() => rows.length > 0)
expect(rows).toEqual([{ type: "part", ref: { messageID: "message-assistant", partID: "reasoning:0" } }])
await wait(() => readRows(rows).length > 0)
expect(readRows(rows).map(withoutRowID)).toEqual([
{ type: "part", ref: { messageID: "message-assistant", partID: "reasoning:0" } },
])
} finally {
app.renderer.destroy()
}
})
test("does not publish timeline rows for duplicate streaming deltas", async () => {
const events = createEventStream()
const sessionID = "session-stream-metrics"
const calls = createFetch((url) => {
if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} })
}, events)
const metrics = { slotPublications: 0, structuralPublications: 0, equivalenceSuppressions: 0 }
let data!: ReturnType<typeof useData>
let rows!: ReturnType<typeof createSessionRows>
let client!: ReturnType<typeof useClient>
function Probe() {
data = useData()
client = useClient()
rows = createSessionRows(() => sessionID, { metrics })
return <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<ClientProvider api={createApi(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</ClientProvider>
</TestTuiContexts>
))
try {
await wait(() => client.connection.status() === "connected")
emitEvent(events, {
id: "evt_stream_step",
created: 1,
type: "session.step.started",
durable: durable(sessionID),
data: {
sessionID,
assistantMessageID: "message-assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
},
})
emitEvent(events, {
id: "evt_stream_started",
created: 2,
type: "session.text.started",
durable: durable(sessionID, 1),
data: { sessionID, assistantMessageID: "message-assistant", ordinal: 0 },
})
emitEvent(events, {
id: "evt_stream_delta_1",
created: 3,
type: "session.text.delta",
data: { sessionID, assistantMessageID: "message-assistant", ordinal: 0, delta: "one" },
})
await wait(() => readRows(rows).some((row) => row.type === "part"))
const afterFirst = { ...metrics }
emitEvent(events, {
id: "evt_stream_delta_2",
created: 4,
type: "session.text.delta",
data: { sessionID, assistantMessageID: "message-assistant", ordinal: 0, delta: "two" },
})
await wait(() => {
const message = data.session.message.get(sessionID, "message-assistant")
return (
message?.type === "assistant" && message.content.some((part) => part.type === "text" && part.text === "onetwo")
)
})
expect(afterFirst).toEqual({ slotPublications: 0, structuralPublications: 1, equivalenceSuppressions: 0 })
expect(metrics).toEqual(afterFirst)
} finally {
app.renderer.destroy()
}
@ -987,13 +1138,15 @@ test("tracks session status from active sessions and execution events", async ()
})
}, events)
let data!: ReturnType<typeof useData>
let rows!: SessionRow[]
let manualRows!: SessionRow[]
let rows!: ReturnType<typeof createSessionRows>
let manualRows!: ReturnType<typeof createSessionRows>
let liveRows!: ReturnType<typeof createSessionRows>
function Probe() {
data = useData()
rows = createSessionRows(() => "session-retry")
manualRows = createSessionRows(() => "session-manual")
liveRows = createSessionRows(() => "session-live")
return <box />
}
@ -1189,7 +1342,7 @@ test("tracks session status from active sessions and execution events", async ()
const assistant = data.session.message.get("session-retry", "message-retry")
return assistant?.type === "assistant" && assistant.retry?.attempt === 2
})
await wait(() => rows.some((row) => row.type === "assistant-footer" && row.messageID === "message-retry"))
await wait(() => readRows(rows).some((row) => row.type === "assistant-footer" && row.messageID === "message-retry"))
emitEvent(events, {
id: "evt_retry_next_step",
created: 2_000,
@ -1206,7 +1359,9 @@ test("tracks session status from active sessions and execution events", async ()
const assistant = data.session.message.get("session-retry", "message-retry")
return assistant?.type === "assistant" && assistant.retry === undefined
})
await wait(() => !rows.some((row) => row.type === "assistant-footer" && row.messageID === "message-retry"))
await wait(
() => !readRows(rows).some((row) => row.type === "assistant-footer" && row.messageID === "message-retry"),
)
expect(data.session.message.list("session-retry").filter((message) => message.type === "assistant")).toHaveLength(1)
emitEvent(events, {
id: "evt_retry_scheduled_again",
@ -1261,7 +1416,9 @@ test("tracks session status from active sessions and execution events", async ()
return message?.type === "compaction" && message.status === "running" && message.summary === "Streamed summary"
})
expect(data.session.pending.list("session-manual")).toEqual([])
const compactionRow = manualRows.find((row) => row.type === "message" && row.messageID === "message-compaction")
const compactionRow = readRows(manualRows).find(
(row) => row.type === "message" && row.messageID === "message-compaction",
)
emitEvent(events, {
id: "evt_manual_compaction_ended",
created: 3,
@ -1273,10 +1430,12 @@ test("tracks session status from active sessions and execution events", async ()
const message = data.session.message.get("session-manual", "message-compaction")
return message?.type === "compaction" && message.status === "completed"
})
expect(manualRows.filter((row) => row.type === "message")).toEqual([
{ type: "message", messageID: "message-compaction" },
])
expect(manualRows.find((row) => row.type === "message" && row.messageID === "message-compaction")).toBe(
expect(
readRows(manualRows)
.filter((row) => row.type === "message")
.map(withoutRowID),
).toEqual([{ type: "message", messageID: "message-compaction" }])
expect(readRows(manualRows).find((row) => row.type === "message" && row.messageID === "message-compaction")).toBe(
compactionRow,
)
@ -1303,7 +1462,10 @@ test("tracks session status from active sessions and execution events", async ()
const message = data.session.message.get("session-live", "msg_compaction_started")
return message?.type === "compaction" && message.status === "running" && message.summary === "Live summary"
})
const autoCompactionRow = rows.find((row) => row.type === "message" && row.messageID === "msg_compaction_started")
const autoCompactionRow = readRows(liveRows).find(
(row) => row.type === "message" && row.messageID === "msg_compaction_started",
)
expect(autoCompactionRow).toBeDefined()
emitEvent(events, {
id: "evt_compaction_ended",
@ -1321,10 +1483,12 @@ test("tracks session status from active sessions and execution events", async ()
status: "completed",
summary: "Live summary",
})
expect(rows.find((row) => row.type === "message" && row.messageID === "msg_compaction_started")).toBe(
expect(readRows(liveRows).find((row) => row.type === "message" && row.messageID === "msg_compaction_started")).toBe(
autoCompactionRow,
)
expect(rows.some((row) => row.type === "message" && row.messageID === "msg_compaction_ended")).toBeFalse()
expect(
readRows(liveRows).some((row) => row.type === "message" && row.messageID === "msg_compaction_ended"),
).toBeFalse()
} finally {
app.renderer.destroy()
}
@ -1383,8 +1547,12 @@ test("restores queued compaction from durable pending input", async () => {
"message-compaction-queued",
"message-compaction-later",
])
await wait(() => rows.filter((row) => row.type === "compaction-queued").length === 2)
expect(rows.filter((row) => row.type === "compaction-queued")).toEqual([
await wait(() => readRows(rows).filter((row) => row.type === "compaction-queued").length === 2)
expect(
readRows(rows)
.filter((row) => row.type === "compaction-queued")
.map(withoutRowID),
).toEqual([
{ type: "compaction-queued", inputID: "message-compaction-queued" },
{ type: "compaction-queued", inputID: "message-compaction-later" },
])
@ -1401,8 +1569,8 @@ test("restores queued compaction from durable pending input", async () => {
text: "Active output",
},
})
await wait(() => rows.some((row) => row.type === "part"))
expect(rows.map((row) => row.type)).toEqual(["part", "compaction-queued", "compaction-queued"])
await wait(() => readRows(rows).some((row) => row.type === "part"))
expect(readRows(rows).map((row) => row.type)).toEqual(["part", "compaction-queued", "compaction-queued"])
emitEvent(events, {
id: "evt_compaction_started",

View file

@ -1,6 +1,13 @@
import { expect, test } from "bun:test"
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client"
import { messageBoundaryIDs, reduceSessionRows } from "../../../src/routes/session/rows"
import { messageBoundaryIDs, reduceSessionRows, type SessionRow } from "../../../src/routes/session/rows"
const withoutIDs = (rows: ReturnType<typeof reduceSessionRows>) =>
rows.map(({ id: _id, ...row }) => {
if (row.type !== "group") return row
const { origin: _origin, ...group } = row
return group
})
test("assigns assistant boundaries to the first rendered row instead of the first text row", () => {
const messages: SessionMessageInfo[] = [
@ -16,6 +23,22 @@ test("assigns assistant boundaries to the first rendered row instead of the firs
expect(messageBoundaryIDs(rows, messages)).toEqual(["user-1", "assistant-1", undefined, undefined])
})
test("keeps a group boundary at its immutable origin while visible refs repartition", () => {
const messages = [assistant("assistant-1", []), assistant("assistant-2", [])]
const origin = { messageID: "assistant-1", partID: "read-1" }
const group: SessionRow = {
id: "group",
type: "group",
kind: "exploration",
origin,
refs: [{ messageID: "assistant-2", partID: "grep-1" }],
pending: [origin],
completed: false,
}
expect(messageBoundaryIDs([group], messages)).toEqual(["assistant-1"])
})
test("groups exploration parts across assistant messages until a delimiter", () => {
const messages: SessionMessageInfo[] = [
{ type: "user", id: "user-1", text: "Explore", time: { created: 0 } },
@ -30,7 +53,7 @@ test("groups exploration parts across assistant messages until a delimiter", ()
]),
]
expect(reduceSessionRows(messages)).toEqual([
expect(withoutIDs(reduceSessionRows(messages))).toEqual([
{ type: "message", messageID: "user-1" },
{ type: "part", ref: { messageID: "assistant-1", partID: "text:0" } },
{
@ -57,7 +80,7 @@ test("keeps non-exploration tools as individual part rows", () => {
]),
]
expect(reduceSessionRows(messages)).toEqual([
expect(withoutIDs(reduceSessionRows(messages))).toEqual([
{
type: "group",
kind: "exploration",
@ -86,7 +109,7 @@ test("assigns stable kind ordinals within an assistant message", () => {
]),
]
expect(reduceSessionRows(messages)).toEqual([
expect(withoutIDs(reduceSessionRows(messages))).toEqual([
{ type: "part", ref: { messageID: "assistant-1", partID: "text:0" } },
{
type: "group",
@ -114,7 +137,7 @@ test("groups adjacent reasoning parts until a visible boundary", () => {
]),
]
expect(reduceSessionRows(messages)).toEqual([
expect(withoutIDs(reduceSessionRows(messages))).toEqual([
{
type: "group",
kind: "reasoning",
@ -146,7 +169,7 @@ test("groups across empty assistant reasoning parts", () => {
]),
]
expect(reduceSessionRows(messages)).toEqual([
expect(withoutIDs(reduceSessionRows(messages))).toEqual([
{
type: "group",
kind: "reasoning",
@ -177,7 +200,7 @@ test("completes exploration groups when another row follows", () => {
finished,
]
expect(reduceSessionRows(messages)).toEqual([
expect(withoutIDs(reduceSessionRows(messages))).toEqual([
{
type: "group",
kind: "exploration",
@ -209,7 +232,7 @@ test("hides synthetic messages without descriptions", () => {
assistant("assistant-2", [{ type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 3 } }]),
]
expect(reduceSessionRows(messages)).toEqual([
expect(withoutIDs(reduceSessionRows(messages))).toEqual([
{
type: "group",
kind: "exploration",
@ -236,7 +259,7 @@ test("renders synthetic messages with descriptions", () => {
assistant("assistant-2", [{ type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 3 } }]),
]
expect(reduceSessionRows(messages)).toEqual([
expect(withoutIDs(reduceSessionRows(messages))).toEqual([
{
type: "group",
kind: "exploration",
@ -263,7 +286,7 @@ test("renders a footer for a pre-output retry assistant after replay", () => {
error: { type: "provider.transport", message: "Disconnected" },
}
expect(reduceSessionRows([message])).toEqual([{ type: "assistant-footer", messageID: "assistant-retry" }])
expect(withoutIDs(reduceSessionRows([message]))).toEqual([{ type: "assistant-footer", messageID: "assistant-retry" }])
})
test("places a running compaction barrier before every queued user message", () => {
@ -287,7 +310,7 @@ test("places a running compaction barrier before every queued user message", ()
queued("user-after", "After", 3),
]
expect(reduceSessionRows(messages, new Set(["user-before", "user-after"]))).toEqual([
expect(withoutIDs(reduceSessionRows(messages, new Set(["user-before", "user-after"])))).toEqual([
{ type: "message", messageID: "compaction" },
{ type: "message", messageID: "user-before" },
{ type: "message", messageID: "user-after" },