refactor(tui): extract session timeline state
This commit is contained in:
parent
daf8e539bf
commit
a653744d78
15 changed files with 1897 additions and 586 deletions
120
packages/tui/bench/session-timeline.ts
Normal file
120
packages/tui/bench/session-timeline.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import { Keyed } from "effect-quark"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { SessionTimeline, type PartRef } from "../src/routes/session/timeline"
|
||||
import { createHarness, type Workload } from "../../quark/bench/harness"
|
||||
|
||||
type Group = {
|
||||
readonly id: "group"
|
||||
readonly type: "group"
|
||||
readonly refs: readonly PartRef[]
|
||||
}
|
||||
|
||||
const bench = createHarness({ warmup: 500 })
|
||||
|
||||
function timelineAppend(): Workload {
|
||||
const timeline = SessionTimeline.make()
|
||||
let ordinal = 0
|
||||
return {
|
||||
run() {
|
||||
timeline.appendPart({ messageID: "assistant", partID: `reasoning:${ordinal++}` }, { type: "reasoning" })
|
||||
},
|
||||
consume: () => {
|
||||
const row = timeline.values()[0]
|
||||
return row?.type === "group" ? row.refs.length : 0
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function keyedAppend(): Workload {
|
||||
const seen = new Set<string>()
|
||||
const rows = Keyed.make<Group, Group["id"]>({
|
||||
key: (row) => row.id,
|
||||
equivalent: (left, right) =>
|
||||
left.refs.length === right.refs.length &&
|
||||
left.refs.every(
|
||||
(ref, index) => ref.messageID === right.refs[index].messageID && ref.partID === right.refs[index].partID,
|
||||
),
|
||||
})
|
||||
rows.set([{ id: "group", type: "group", refs: [] }])
|
||||
let ordinal = 0
|
||||
return {
|
||||
run() {
|
||||
const ref = { messageID: "assistant", partID: `reasoning:${ordinal++}` }
|
||||
if (seen.has(ref.partID)) return
|
||||
rows.modify("group", (group) => ({ ...group, refs: [...group.refs, ref] }))
|
||||
seen.add(ref.partID)
|
||||
},
|
||||
consume: () => rows.get("group")!().refs.length,
|
||||
}
|
||||
}
|
||||
|
||||
function solidAppend(): Workload {
|
||||
const [rows, setRows] = createStore<Array<{ type: "group"; refs: PartRef[] }>>([{ type: "group", refs: [] }])
|
||||
let ordinal = 0
|
||||
return {
|
||||
run() {
|
||||
const ref = { messageID: "assistant", partID: `reasoning:${ordinal++}` }
|
||||
setRows(
|
||||
produce((draft) => {
|
||||
if (draft[0].refs.some((item) => item.messageID === ref.messageID && item.partID === ref.partID)) return
|
||||
draft[0].refs.push(ref)
|
||||
}),
|
||||
)
|
||||
},
|
||||
consume: () => rows[0].refs.length,
|
||||
}
|
||||
}
|
||||
|
||||
function timelineDuplicate(size: number): Workload {
|
||||
const timeline = SessionTimeline.make()
|
||||
Array.from({ length: size }, (_, ordinal) =>
|
||||
timeline.appendPart({ messageID: "assistant", partID: `reasoning:${ordinal}` }, { type: "reasoning" }),
|
||||
)
|
||||
const duplicate = { messageID: "assistant", partID: `reasoning:${size - 1}` }
|
||||
return {
|
||||
run: () => timeline.appendPart(duplicate, { type: "reasoning" }),
|
||||
consume: () => timeline.values().length,
|
||||
}
|
||||
}
|
||||
|
||||
function solidDuplicate(size: number): Workload {
|
||||
const refs = Array.from(
|
||||
{ length: size },
|
||||
(_, ordinal): PartRef => ({ messageID: "assistant", partID: `reasoning:${ordinal}` }),
|
||||
)
|
||||
const [rows, setRows] = createStore([{ type: "group" as const, refs }])
|
||||
const duplicate = refs.at(-1)!
|
||||
return {
|
||||
run() {
|
||||
setRows(
|
||||
produce((draft) => {
|
||||
if (draft[0].refs.some((item) => item.messageID === duplicate.messageID && item.partID === duplicate.partID))
|
||||
return
|
||||
draft[0].refs.push(duplicate)
|
||||
}),
|
||||
)
|
||||
},
|
||||
consume: () => rows.length,
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Session timeline benchmark (${bench.samples} samples)\n`)
|
||||
|
||||
const append = bench.compare(2_000, [
|
||||
{ name: "SessionTimeline grouped append", make: timelineAppend },
|
||||
{ name: "Handwritten Keyed + Set append", make: keyedAppend },
|
||||
{ name: "Solid Store produce append", make: solidAppend },
|
||||
])
|
||||
const duplicate = bench.compare(10_000, [
|
||||
{ name: "SessionTimeline duplicate 1000", make: () => timelineDuplicate(1_000) },
|
||||
{ name: "Solid Store duplicate 1000", make: () => solidDuplicate(1_000) },
|
||||
])
|
||||
|
||||
console.log("\nRatios (lower is faster)")
|
||||
console.log(`Timeline / handwritten append: ${append.ratio(0, 1).toFixed(3)}x`)
|
||||
console.log(`Timeline / Solid append: ${append.ratio(0, 2).toFixed(3)}x`)
|
||||
console.log(`Timeline / Solid duplicate: ${duplicate.ratio(0, 1).toFixed(3)}x`)
|
||||
console.log(`METRIC timeline_handwritten_append_ratio=${append.ratio(0, 1).toFixed(6)}`)
|
||||
console.log(`METRIC timeline_solid_append_ratio=${append.ratio(0, 2).toFixed(6)}`)
|
||||
console.log(`METRIC timeline_solid_duplicate_ratio=${duplicate.ratio(0, 1).toFixed(6)}`)
|
||||
bench.finish()
|
||||
|
|
@ -6,6 +6,7 @@
|
|||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"bench:timeline": "bun --conditions=browser bench/session-timeline.ts",
|
||||
"test": "bun test --timeout 30000 --only-failures",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -81,7 +81,8 @@ import { PluginSlot } from "../../plugin/context"
|
|||
import { Keymap, type KeymapCommand } from "../../context/keymap"
|
||||
import { usePathFormatter } from "../../context/path-format"
|
||||
import { useLocation } from "../../context/location"
|
||||
import { createSessionRows, messageBoundaryIDs, resolvePart, type PartRef, type SessionRow } from "./rows"
|
||||
import { createSessionRows } from "./rows"
|
||||
import { messageBoundaryIDs, resolvePart, type PartRef, type SessionRow } from "./timeline"
|
||||
import { switchLabel } from "../../util/model"
|
||||
import { findMessageBoundary, messageNavigationSlack } from "./message-navigation"
|
||||
import { stringWidth } from "../../util/string-width"
|
||||
|
|
@ -214,7 +215,12 @@ export function Session() {
|
|||
})
|
||||
const editor = useEditorContext()
|
||||
const rows = createSessionRows(() => route.sessionID)
|
||||
const boundaries = createMemo(() => messageBoundaryIDs(rows.slots().map((slot) => slot()), messages()))
|
||||
const boundaries = createMemo(() =>
|
||||
messageBoundaryIDs(
|
||||
rows.slots().map((slot) => slot()),
|
||||
messages(),
|
||||
),
|
||||
)
|
||||
const [navigationMessage, setNavigationMessage] = createSignal<string>()
|
||||
const [navigationSlack, setNavigationSlack] = createSignal(0)
|
||||
|
||||
|
|
@ -1165,7 +1171,7 @@ function SessionPartView(props: { partRef: PartRef; message: (messageID: string)
|
|||
}
|
||||
|
||||
function SessionReasoningGroupView(props: {
|
||||
refs: PartRef[]
|
||||
refs: readonly PartRef[]
|
||||
completed: boolean
|
||||
message: (messageID: string) => SessionMessageInfo | undefined
|
||||
}) {
|
||||
|
|
@ -1286,8 +1292,8 @@ function SessionReasoningGroupView(props: {
|
|||
}
|
||||
|
||||
function SessionGroupView(props: {
|
||||
refs: PartRef[]
|
||||
pending: PartRef[]
|
||||
refs: readonly PartRef[]
|
||||
pending: readonly PartRef[]
|
||||
completed: boolean
|
||||
message: (messageID: string) => SessionMessageInfo | undefined
|
||||
}) {
|
||||
|
|
@ -1296,7 +1302,7 @@ function SessionGroupView(props: {
|
|||
const renderer = useRenderer()
|
||||
const [expanded, setExpanded] = createSignal(false)
|
||||
const [hover, setHover] = createSignal(false)
|
||||
const parts = (refs: PartRef[]) =>
|
||||
const parts = (refs: readonly PartRef[]) =>
|
||||
refs.flatMap((ref) => {
|
||||
const message = props.message(ref.messageID)
|
||||
if (message?.type !== "assistant") return []
|
||||
|
|
|
|||
|
|
@ -1,79 +1,48 @@
|
|||
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client"
|
||||
import { Keyed, Transaction } from "effect-quark"
|
||||
import { Keyed } 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 = {
|
||||
readonly messageID: string
|
||||
readonly partID: string
|
||||
}
|
||||
|
||||
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 }
|
||||
)
|
||||
import {
|
||||
SessionTimeline,
|
||||
compactionQueuedRow,
|
||||
isTerminalFinish,
|
||||
reduceSessionRows,
|
||||
type AppendPart,
|
||||
type PartRef,
|
||||
type SessionRow,
|
||||
} from "./timeline"
|
||||
|
||||
export function createSessionRows(sessionID: Accessor<string>, options?: { readonly metrics?: Keyed.Metrics }) {
|
||||
const data = useData()
|
||||
const client = useClient()
|
||||
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 state = SessionTimeline.make({ metrics })
|
||||
const rows = {
|
||||
slots: useValue(state.slots),
|
||||
values: state.values,
|
||||
}
|
||||
const revertBoundary = () => data.session.get(sessionID())?.revert?.messageID
|
||||
|
||||
const isPending = (messageID: string) => {
|
||||
const message = data.session.message.get(sessionID(), messageID)
|
||||
if (message?.type === "user" || message?.type === "synthetic") return data.session.input.has(sessionID(), messageID)
|
||||
return message?.type === "compaction" && message.status === "running"
|
||||
}
|
||||
|
||||
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)))
|
||||
})
|
||||
state.replace(value, isPending, pendingPermissions())
|
||||
})
|
||||
}
|
||||
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 })
|
||||
}
|
||||
const mutate = (f: () => void) => batch(f)
|
||||
|
||||
function reduce() {
|
||||
const messages = data.session.message.list(sessionID())
|
||||
const inputs = new Set(data.session.input.list(sessionID()))
|
||||
const boundary = revertBoundary()
|
||||
const rows = reduceSessionRows(boundary ? messages.filter((message) => message.id < boundary) : messages, inputs)
|
||||
partitionPending(rows, pendingPermissions())
|
||||
const position = rows.findIndex((row) => row.type === "message" && inputs.has(row.messageID))
|
||||
rows.splice(
|
||||
position === -1 ? rows.length : position,
|
||||
|
|
@ -96,20 +65,7 @@ export function createSessionRows(sessionID: Accessor<string>, options?: { reado
|
|||
|
||||
createEffect(() => {
|
||||
const pending = pendingPermissions()
|
||||
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)),
|
||||
})
|
||||
})
|
||||
})
|
||||
mutate(() => state.repartition(pending))
|
||||
})
|
||||
|
||||
createEffect(
|
||||
|
|
@ -162,6 +118,8 @@ export function createSessionRows(sessionID: Accessor<string>, options?: { reado
|
|||
{
|
||||
id: message.id,
|
||||
created: message.time.created,
|
||||
input: false,
|
||||
status: message.status,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
|
|
@ -172,64 +130,16 @@ export function createSessionRows(sessionID: Accessor<string>, options?: { reado
|
|||
|
||||
const appendMessage = (messageID: string) =>
|
||||
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))
|
||||
state.appendMessage(messageID, { pending, compaction: message?.type === "compaction" })
|
||||
})
|
||||
|
||||
const appendPart = (ref: PartRef, part: AppendPart) =>
|
||||
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 appendPart = (ref: PartRef, part: AppendPart) => mutate(() => state.appendPart(ref, part))
|
||||
|
||||
const appendFooter = (messageID: string) =>
|
||||
mutate(() => {
|
||||
if (state.has(footerRowID(messageID))) return
|
||||
const current = state.values()
|
||||
const index = queuedStart(current)
|
||||
complete(current, index)
|
||||
insert(current, index, footerRow(messageID))
|
||||
})
|
||||
const appendFooter = (messageID: string) => mutate(() => state.appendFooter(messageID))
|
||||
|
||||
const removeFooter = (messageID: string) =>
|
||||
mutate(() => {
|
||||
state.remove(footerRowID(messageID))
|
||||
})
|
||||
|
||||
const isPending = (messageID: string) => {
|
||||
const message = data.session.message.get(sessionID(), messageID)
|
||||
if (message?.type === "user" || message?.type === "synthetic") return data.session.input.has(sessionID(), messageID)
|
||||
return message?.type === "compaction" && message.status === "running"
|
||||
}
|
||||
|
||||
const queuedStart = (rows: readonly SessionRow[]) => {
|
||||
const index = rows.findIndex(
|
||||
(row) => row.type === "compaction-queued" || (row.type === "message" && isPending(row.messageID)),
|
||||
)
|
||||
return index === -1 ? rows.length : index
|
||||
}
|
||||
const removeFooter = (messageID: string) => mutate(() => state.removeFooter(messageID))
|
||||
|
||||
const message = (event: { id: string; data: { sessionID: string } }) => {
|
||||
if (event.data.sessionID === sessionID()) appendMessage(event.id.replace(/^evt_/, "msg_"))
|
||||
|
|
@ -296,7 +206,7 @@ export function createSessionRows(sessionID: Accessor<string>, options?: { reado
|
|||
if (event.data.sessionID === sessionID()) removeFooter(event.data.assistantMessageID)
|
||||
}),
|
||||
data.on("session.step.ended", (event) => {
|
||||
if (event.data.sessionID !== sessionID() || ["tool-calls", "unknown"].includes(event.data.finish)) return
|
||||
if (event.data.sessionID !== sessionID() || !isTerminalFinish(event.data.finish)) return
|
||||
appendFooter(event.data.assistantMessageID)
|
||||
}),
|
||||
data.on("session.step.failed", (event) => {
|
||||
|
|
@ -310,182 +220,3 @@ export function createSessionRows(sessionID: Accessor<string>, options?: { reado
|
|||
|
||||
return rows
|
||||
}
|
||||
|
||||
export function reduceSessionRows(messages: SessionMessageInfo[], inputs = new Set<string>()) {
|
||||
const isInput = (message: SessionMessageInfo) => inputs.has(message.id)
|
||||
const pendingCompactions = messages.filter((message) => message.type === "compaction" && message.status === "running")
|
||||
const pending = new Set([...pendingCompactions.map((message) => message.id), ...inputs])
|
||||
return [
|
||||
...messages.filter((message) => !pending.has(message.id)),
|
||||
...pendingCompactions,
|
||||
...messages.filter(isInput),
|
||||
].reduce<SessionRow[]>((rows, message) => {
|
||||
if (message.type !== "assistant") {
|
||||
if (message.type === "synthetic" && !message.description?.trim()) return rows
|
||||
if (!pending.has(message.id)) completePrevious(rows)
|
||||
rows.push(messageRow(message.id))
|
||||
return rows
|
||||
}
|
||||
const ordinals = { text: 0, reasoning: 0 }
|
||||
message.content.forEach((part) => {
|
||||
const partID = part.type === "tool" ? part.id : `${part.type}:${ordinals[part.type]++}`
|
||||
if ((part.type === "text" || part.type === "reasoning") && !part.text.trim()) return
|
||||
append(rows, { messageID: message.id, partID }, part)
|
||||
})
|
||||
if ((message.finish && !["tool-calls", "unknown"].includes(message.finish)) || message.error || message.retry) {
|
||||
completePrevious(rows)
|
||||
rows.push(footerRow(message.id))
|
||||
}
|
||||
return rows
|
||||
}, [])
|
||||
}
|
||||
|
||||
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) => {
|
||||
const id = rowBoundaryMessageID(row, byID)
|
||||
if (!id || seen.has(id)) return undefined
|
||||
seen.add(id)
|
||||
return id
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
return undefined
|
||||
}
|
||||
const messageID =
|
||||
row.type === "part"
|
||||
? row.ref.messageID
|
||||
: row.type === "group"
|
||||
? row.origin.messageID
|
||||
: row.type === "assistant-footer"
|
||||
? row.messageID
|
||||
: undefined
|
||||
if (!messageID) return undefined
|
||||
const message = messages.get(messageID)
|
||||
if (message?.type === "assistant") return message.id
|
||||
}
|
||||
|
||||
export function resolvePart(message: SessionMessageAssistant, partID: string) {
|
||||
const tool = message.content.find((part) => part.type === "tool" && part.id === partID)
|
||||
if (tool) return tool
|
||||
const match = /^(text|reasoning):(\d+)$/.exec(partID)
|
||||
if (!match) return
|
||||
const ordinal = Number(match[2])
|
||||
return message.content.filter((part) => part.type === match[1])[ordinal]
|
||||
}
|
||||
|
||||
type AppendPart = { type: "text" } | { type: "reasoning" } | { type: "tool"; name: string }
|
||||
|
||||
function append(rows: SessionRow[], ref: PartRef, part: AppendPart, index = rows.length) {
|
||||
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, 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) {
|
||||
const previous = rows[index - 1]
|
||||
if (previous?.type === "group") previous.completed = true
|
||||
}
|
||||
|
||||
function partitionPending(rows: SessionRow[], pending: Set<string>) {
|
||||
rows.forEach((row) => {
|
||||
if (row.type !== "group" || row.kind !== "exploration") return
|
||||
const refs = [...row.refs, ...row.pending]
|
||||
row.refs = refs.filter((ref) => !pending.has(ref.partID))
|
||||
row.pending = refs.filter((ref) => pending.has(ref.partID))
|
||||
})
|
||||
}
|
||||
|
||||
function exploration(name: string) {
|
||||
return ["read", "glob", "grep"].includes(name.toLowerCase())
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
|
|
|||
328
packages/tui/src/routes/session/timeline.ts
Normal file
328
packages/tui/src/routes/session/timeline.ts
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client"
|
||||
import { Keyed, Layout, Transaction } from "effect-quark"
|
||||
|
||||
const PartRefLayout = Layout.struct({
|
||||
messageID: Layout.string,
|
||||
partID: Layout.string,
|
||||
})
|
||||
|
||||
const SessionRowLayout = Layout.keyedUnion({
|
||||
key: Layout.key("id", Layout.string),
|
||||
tag: "type",
|
||||
variants: {
|
||||
message: Layout.struct({ messageID: Layout.string }),
|
||||
"compaction-queued": Layout.struct({ inputID: Layout.string }),
|
||||
part: Layout.struct({ ref: PartRefLayout }),
|
||||
group: Layout.union({
|
||||
tag: "kind",
|
||||
variants: {
|
||||
reasoning: Layout.struct({
|
||||
origin: Layout.immutable(PartRefLayout),
|
||||
refs: Layout.array(PartRefLayout),
|
||||
completed: Layout.boolean,
|
||||
}),
|
||||
exploration: Layout.struct({
|
||||
origin: Layout.immutable(PartRefLayout),
|
||||
refs: Layout.array(PartRefLayout),
|
||||
pending: Layout.array(PartRefLayout),
|
||||
completed: Layout.boolean,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
"assistant-footer": Layout.struct({ messageID: Layout.string }),
|
||||
},
|
||||
})
|
||||
|
||||
export type PartRef = Layout.Type<typeof PartRefLayout>
|
||||
export type SessionRow = Layout.Type<typeof SessionRowLayout>
|
||||
export type AppendPart = { type: "text" } | { type: "reasoning" } | { type: "tool"; name: string }
|
||||
|
||||
const SessionRows = Layout.collection(
|
||||
SessionRowLayout,
|
||||
({ members }) => ({
|
||||
parts: members((row) => {
|
||||
if (row.type === "part") return [row.id]
|
||||
if (row.type !== "group") return []
|
||||
if (row.kind === "reasoning") return row.refs.map(partRowID)
|
||||
return [...row.refs, ...row.pending].map(partRowID)
|
||||
}),
|
||||
}),
|
||||
{ backend: "generated" },
|
||||
)
|
||||
|
||||
export namespace SessionTimeline {
|
||||
export function make(options?: { readonly metrics?: Keyed.Metrics }) {
|
||||
const state = SessionRows.make([], options)
|
||||
let activeGroupID: string | undefined
|
||||
let queuedBoundaryID: string | undefined
|
||||
const queuedRowIDs = new Set<string>()
|
||||
|
||||
const insert = (row: SessionRow) => state.insert(row, queuedBoundaryID ? { before: queuedBoundaryID } : "end")
|
||||
|
||||
const complete = () => {
|
||||
if (!activeGroupID) return
|
||||
state.modify(activeGroupID, (row) => (row.type === "group" ? { ...row, completed: true } : row))
|
||||
activeGroupID = undefined
|
||||
}
|
||||
|
||||
const nextQueued = (key: string) => {
|
||||
const row = state.after(key)?.()
|
||||
return row && queuedRowIDs.has(row.id) ? row.id : undefined
|
||||
}
|
||||
|
||||
const replace = (
|
||||
rows: readonly SessionRow[],
|
||||
isQueued: (messageID: string) => boolean,
|
||||
pending: ReadonlySet<string>,
|
||||
) =>
|
||||
Transaction.run(() => {
|
||||
queuedBoundaryID = undefined
|
||||
activeGroupID = undefined
|
||||
queuedRowIDs.clear()
|
||||
state.set(
|
||||
rows.map((row) => {
|
||||
const next = partition(row, pending)
|
||||
const queued = next.type === "compaction-queued" || (next.type === "message" && isQueued(next.messageID))
|
||||
if (queued) {
|
||||
queuedRowIDs.add(next.id)
|
||||
queuedBoundaryID ??= next.id
|
||||
return next
|
||||
}
|
||||
if (queuedBoundaryID) return next
|
||||
activeGroupID = next.type === "group" && !next.completed ? next.id : undefined
|
||||
return next
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const appendMessage = (messageID: string, status: { readonly pending: boolean; readonly compaction: boolean }) => {
|
||||
const id = messageRowID(messageID)
|
||||
const exists = state.has(id)
|
||||
if (exists && (status.pending || !queuedRowIDs.has(id))) return
|
||||
Transaction.run(() => {
|
||||
const row = messageRow(messageID)
|
||||
if (status.pending) {
|
||||
queuedRowIDs.add(row.id)
|
||||
if (!status.compaction) {
|
||||
state.insert(row, "end")
|
||||
queuedBoundaryID ??= row.id
|
||||
return
|
||||
}
|
||||
insert(row)
|
||||
queuedBoundaryID = row.id
|
||||
return
|
||||
}
|
||||
if (!exists) {
|
||||
complete()
|
||||
insert(row)
|
||||
return
|
||||
}
|
||||
queuedRowIDs.delete(row.id)
|
||||
complete()
|
||||
if (queuedBoundaryID === row.id) {
|
||||
queuedBoundaryID = nextQueued(row.id)
|
||||
return
|
||||
}
|
||||
if (queuedBoundaryID) state.move(row.id, { before: queuedBoundaryID })
|
||||
})
|
||||
}
|
||||
|
||||
const appendPart = (ref: PartRef, part: AppendPart) => {
|
||||
const id = partRowID(ref)
|
||||
if (state.hasMember("parts", id)) return
|
||||
Transaction.run(() => {
|
||||
const kind = groupKind(part)
|
||||
const active = activeGroupID ? state.get(activeGroupID)?.() : undefined
|
||||
if (kind && active?.type === "group" && active.kind === kind) {
|
||||
state.modify(active.id, (row) => (row.type === "group" ? { ...row, refs: [...row.refs, ref] } : row), {
|
||||
members: { parts: { add: [id] } },
|
||||
})
|
||||
return
|
||||
}
|
||||
complete()
|
||||
const row = kind ? groupRow(kind, ref) : partRow(ref)
|
||||
insert(row)
|
||||
activeGroupID = row.type === "group" ? row.id : undefined
|
||||
})
|
||||
}
|
||||
|
||||
const appendFooter = (messageID: string) => {
|
||||
const id = footerRowID(messageID)
|
||||
if (state.has(id)) return
|
||||
Transaction.run(() => {
|
||||
const row = footerRow(messageID)
|
||||
complete()
|
||||
insert(row)
|
||||
})
|
||||
}
|
||||
|
||||
const removeFooter = (messageID: string) => state.remove(footerRowID(messageID))
|
||||
|
||||
const repartition = (pending: ReadonlySet<string>) =>
|
||||
Transaction.run(() => {
|
||||
state.values().forEach((row) => {
|
||||
if (row.type !== "group" || row.kind !== "exploration") return
|
||||
const next = partition(row, pending)
|
||||
if (next !== row) state.modify(row.id, () => next)
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
slots: state.slots,
|
||||
values: state.values,
|
||||
replace,
|
||||
appendMessage,
|
||||
appendPart,
|
||||
appendFooter,
|
||||
removeFooter,
|
||||
repartition,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function reduceSessionRows(messages: SessionMessageInfo[], inputs = new Set<string>()) {
|
||||
const isInput = (message: SessionMessageInfo) => inputs.has(message.id)
|
||||
const pendingCompactions = messages.filter((message) => message.type === "compaction" && message.status === "running")
|
||||
const pending = new Set([...pendingCompactions.map((message) => message.id), ...inputs])
|
||||
return [
|
||||
...messages.filter((message) => !pending.has(message.id)),
|
||||
...pendingCompactions,
|
||||
...messages.filter(isInput),
|
||||
].reduce<SessionRow[]>((rows, message) => {
|
||||
if (message.type !== "assistant") {
|
||||
if (message.type === "synthetic" && !message.description?.trim()) return rows
|
||||
if (!pending.has(message.id)) completePrevious(rows)
|
||||
rows.push(messageRow(message.id))
|
||||
return rows
|
||||
}
|
||||
const ordinals = { text: 0, reasoning: 0 }
|
||||
message.content.forEach((part) => {
|
||||
const partID = part.type === "tool" ? part.id : `${part.type}:${ordinals[part.type]++}`
|
||||
if ((part.type === "text" || part.type === "reasoning") && !part.text.trim()) return
|
||||
append(rows, { messageID: message.id, partID }, part)
|
||||
})
|
||||
if (isTerminalFinish(message.finish) || message.error || message.retry) {
|
||||
completePrevious(rows)
|
||||
rows.push(footerRow(message.id))
|
||||
}
|
||||
return rows
|
||||
}, [])
|
||||
}
|
||||
|
||||
export function messageBoundaryIDs(rows: readonly SessionRow[], messages: SessionMessageInfo[]) {
|
||||
const byID = new Map(messages.map((message) => [message.id, message]))
|
||||
const seen = new Set<string>()
|
||||
return rows.map((row) => {
|
||||
const id = rowBoundaryMessageID(row, byID)
|
||||
if (!id || seen.has(id)) return undefined
|
||||
seen.add(id)
|
||||
return id
|
||||
})
|
||||
}
|
||||
|
||||
function rowBoundaryMessageID(row: SessionRow, messages: Map<string, SessionMessageInfo>) {
|
||||
if (row.type === "message") {
|
||||
const message = messages.get(row.messageID)
|
||||
if (message?.type === "user" && message.text.trim()) return message.id
|
||||
return undefined
|
||||
}
|
||||
const messageID =
|
||||
row.type === "part"
|
||||
? row.ref.messageID
|
||||
: row.type === "group"
|
||||
? row.origin.messageID
|
||||
: row.type === "assistant-footer"
|
||||
? row.messageID
|
||||
: undefined
|
||||
if (!messageID) return undefined
|
||||
const message = messages.get(messageID)
|
||||
if (message?.type === "assistant") return message.id
|
||||
}
|
||||
|
||||
export function resolvePart(message: SessionMessageAssistant, partID: string) {
|
||||
const tool = message.content.find((part) => part.type === "tool" && part.id === partID)
|
||||
if (tool) return tool
|
||||
const match = /^(text|reasoning):(\d+)$/.exec(partID)
|
||||
if (!match) return
|
||||
const ordinal = Number(match[2])
|
||||
return message.content.filter((part) => part.type === match[1])[ordinal]
|
||||
}
|
||||
|
||||
export function isTerminalFinish(finish: string | undefined) {
|
||||
return !!finish && !["tool-calls", "unknown"].includes(finish)
|
||||
}
|
||||
|
||||
function append(rows: SessionRow[], ref: PartRef, part: AppendPart, index = rows.length) {
|
||||
const previous = rows[index - 1]
|
||||
const kind = groupKind(part)
|
||||
if (kind && previous?.type === "group" && previous.kind === kind) {
|
||||
rows[index - 1] = { ...previous, refs: [...previous.refs, ref] }
|
||||
return
|
||||
}
|
||||
completePrevious(rows, index)
|
||||
rows.splice(index, 0, 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) {
|
||||
const previous = rows[index - 1]
|
||||
if (previous?.type === "group") rows[index - 1] = { ...previous, completed: true }
|
||||
}
|
||||
|
||||
function partition(row: SessionRow, pending: ReadonlySet<string>): SessionRow {
|
||||
if (row.type !== "group" || row.kind !== "exploration") return row
|
||||
const changed = row.refs.some((ref) => pending.has(ref.partID)) || row.pending.some((ref) => !pending.has(ref.partID))
|
||||
if (!changed) return row
|
||||
const refs = [...row.refs, ...row.pending]
|
||||
return {
|
||||
...row,
|
||||
refs: refs.filter((ref) => !pending.has(ref.partID)),
|
||||
pending: refs.filter((ref) => pending.has(ref.partID)),
|
||||
}
|
||||
}
|
||||
|
||||
function exploration(name: string) {
|
||||
return ["read", "glob", "grep"].includes(name.toLowerCase())
|
||||
}
|
||||
|
||||
function messageRow(messageID: string): SessionRow {
|
||||
return { id: messageRowID(messageID), type: "message", messageID }
|
||||
}
|
||||
|
||||
function messageRowID(messageID: string) {
|
||||
return `m${segment(messageID)}`
|
||||
}
|
||||
|
||||
export 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}`
|
||||
}
|
||||
|
|
@ -8,7 +8,8 @@ import { createEffect, onMount, type ParentProps } from "solid-js"
|
|||
import { ClientProvider, useClient } from "../../../src/context/client"
|
||||
import { DataProvider as DataProviderBase, useData } from "../../../src/context/data"
|
||||
import { LocationProvider, useLocation } from "../../../src/context/location"
|
||||
import { createSessionRows, type SessionRow } from "../../../src/routes/session/rows"
|
||||
import { createSessionRows } from "../../../src/routes/session/rows"
|
||||
import type { SessionRow } from "../../../src/routes/session/timeline"
|
||||
import { createApi, createEventStream, createFetch, directory, json } from "../../fixture/tui-client"
|
||||
import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client"
|
||||
import { messageBoundaryIDs, reduceSessionRows, type SessionRow } from "../../../src/routes/session/rows"
|
||||
import {
|
||||
SessionTimeline,
|
||||
messageBoundaryIDs,
|
||||
reduceSessionRows,
|
||||
type SessionRow,
|
||||
} from "../../../src/routes/session/timeline"
|
||||
|
||||
const withoutIDs = (rows: ReturnType<typeof reduceSessionRows>) =>
|
||||
rows.map(({ id: _id, ...row }) => {
|
||||
|
|
@ -317,6 +322,162 @@ test("places a running compaction barrier before every queued user message", ()
|
|||
])
|
||||
})
|
||||
|
||||
test("matches snapshot reduction through direct timeline operations", () => {
|
||||
const timeline = SessionTimeline.make()
|
||||
const response = assistant("assistant-1", [
|
||||
{ type: "reasoning", text: "First" },
|
||||
{ type: "reasoning", text: "Second" },
|
||||
{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 2 } },
|
||||
{ type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 3 } },
|
||||
{ type: "text", text: "Done" },
|
||||
])
|
||||
response.finish = "stop"
|
||||
const messages: SessionMessageInfo[] = [
|
||||
{ type: "user", id: "user-1", text: "Explore", time: { created: 0 } },
|
||||
response,
|
||||
{ type: "user", id: "user-queued", text: "Continue", time: { created: 4 } },
|
||||
]
|
||||
|
||||
timeline.appendMessage("user-1", { pending: false, compaction: false })
|
||||
timeline.appendPart({ messageID: "assistant-1", partID: "reasoning:0" }, { type: "reasoning" })
|
||||
timeline.appendPart({ messageID: "assistant-1", partID: "reasoning:1" }, { type: "reasoning" })
|
||||
timeline.appendPart({ messageID: "assistant-1", partID: "read-1" }, { type: "tool", name: "read" })
|
||||
timeline.appendPart({ messageID: "assistant-1", partID: "grep-1" }, { type: "tool", name: "grep" })
|
||||
timeline.appendPart({ messageID: "assistant-1", partID: "text:0" }, { type: "text" })
|
||||
timeline.appendFooter("assistant-1")
|
||||
timeline.appendMessage("user-queued", { pending: true, compaction: false })
|
||||
|
||||
expect(timeline.values()).toEqual(reduceSessionRows(messages, new Set(["user-queued"])))
|
||||
})
|
||||
|
||||
test("ignores a duplicate part through the parts membership index", () => {
|
||||
const timeline = SessionTimeline.make()
|
||||
const ref = { messageID: "assistant-1", partID: "read-1" }
|
||||
timeline.appendPart(ref, { type: "tool", name: "read" })
|
||||
const values = timeline.values()
|
||||
const slots = timeline.slots()
|
||||
|
||||
timeline.appendPart(ref, { type: "text" })
|
||||
|
||||
expect(timeline.values()).toBe(values)
|
||||
expect(timeline.slots()).toBe(slots)
|
||||
})
|
||||
|
||||
test("inserts output before the earliest queued compaction and prompt", () => {
|
||||
const timeline = SessionTimeline.make()
|
||||
timeline.appendPart({ messageID: "assistant-1", partID: "read-1" }, { type: "tool", name: "read" })
|
||||
timeline.appendMessage("user-1", { pending: true, compaction: false })
|
||||
timeline.appendMessage("user-2", { pending: true, compaction: false })
|
||||
timeline.appendMessage("compaction-1", { pending: true, compaction: true })
|
||||
timeline.appendPart({ messageID: "assistant-1", partID: "text:0" }, { type: "text" })
|
||||
|
||||
expect(withoutIDs([...timeline.values()])).toEqual([
|
||||
{
|
||||
type: "group",
|
||||
kind: "exploration",
|
||||
pending: [],
|
||||
completed: true,
|
||||
refs: [{ messageID: "assistant-1", partID: "read-1" }],
|
||||
},
|
||||
{ type: "part", ref: { messageID: "assistant-1", partID: "text:0" } },
|
||||
{ type: "message", messageID: "compaction-1" },
|
||||
{ type: "message", messageID: "user-1" },
|
||||
{ type: "message", messageID: "user-2" },
|
||||
])
|
||||
})
|
||||
|
||||
test("keeps a group slot stable through join, repartition, and completion", () => {
|
||||
const timeline = SessionTimeline.make()
|
||||
timeline.appendPart({ messageID: "assistant-1", partID: "read-1" }, { type: "tool", name: "read" })
|
||||
const slot = timeline.slots()[0]
|
||||
|
||||
timeline.appendPart({ messageID: "assistant-2", partID: "grep-1" }, { type: "tool", name: "grep" })
|
||||
expect(timeline.slots()[0]).toBe(slot)
|
||||
|
||||
timeline.repartition(new Set(["read-1"]))
|
||||
expect(timeline.slots()[0]).toBe(slot)
|
||||
expect(slot()).toMatchObject({
|
||||
refs: [{ messageID: "assistant-2", partID: "grep-1" }],
|
||||
pending: [{ messageID: "assistant-1", partID: "read-1" }],
|
||||
completed: false,
|
||||
})
|
||||
|
||||
timeline.appendMessage("user-queued", { pending: true, compaction: false })
|
||||
expect(slot()).toMatchObject({ completed: false })
|
||||
timeline.appendMessage("user-queued", { pending: false, compaction: false })
|
||||
|
||||
expect(timeline.slots()[0]).toBe(slot)
|
||||
expect(slot()).toMatchObject({ completed: true })
|
||||
})
|
||||
|
||||
test("does not complete an active group for duplicate messages or footers", () => {
|
||||
const timeline = SessionTimeline.make()
|
||||
timeline.appendMessage("user-1", { pending: false, compaction: false })
|
||||
timeline.appendPart({ messageID: "assistant-1", partID: "reasoning:0" }, { type: "reasoning" })
|
||||
const first = timeline.slots()[1]
|
||||
|
||||
timeline.appendMessage("user-1", { pending: false, compaction: false })
|
||||
expect(first()).toMatchObject({ completed: false })
|
||||
|
||||
timeline.appendFooter("assistant-1")
|
||||
timeline.appendPart({ messageID: "assistant-2", partID: "reasoning:0" }, { type: "reasoning" })
|
||||
const second = timeline.slots()[3]
|
||||
timeline.appendFooter("assistant-1")
|
||||
|
||||
expect(second()).toMatchObject({ completed: false })
|
||||
})
|
||||
|
||||
test("moves a promoted queued message before the remaining queue", () => {
|
||||
const timeline = SessionTimeline.make()
|
||||
timeline.appendPart({ messageID: "assistant-1", partID: "reasoning:0" }, { type: "reasoning" })
|
||||
timeline.appendMessage("user-1", { pending: true, compaction: false })
|
||||
timeline.appendMessage("user-2", { pending: true, compaction: false })
|
||||
|
||||
timeline.appendMessage("user-2", { pending: false, compaction: false })
|
||||
timeline.appendPart({ messageID: "assistant-2", partID: "text:0" }, { type: "text" })
|
||||
|
||||
expect(withoutIDs([...timeline.values()])).toEqual([
|
||||
{
|
||||
type: "group",
|
||||
kind: "reasoning",
|
||||
completed: true,
|
||||
refs: [{ messageID: "assistant-1", partID: "reasoning:0" }],
|
||||
},
|
||||
{ type: "message", messageID: "user-2" },
|
||||
{ type: "part", ref: { messageID: "assistant-2", partID: "text:0" } },
|
||||
{ type: "message", messageID: "user-1" },
|
||||
])
|
||||
})
|
||||
|
||||
test("advances the queue boundary when a running compaction completes", () => {
|
||||
const timeline = SessionTimeline.make()
|
||||
timeline.appendPart({ messageID: "assistant-1", partID: "read-1" }, { type: "tool", name: "read" })
|
||||
timeline.appendMessage("compaction-1", { pending: true, compaction: true })
|
||||
timeline.appendMessage("user-1", { pending: true, compaction: false })
|
||||
|
||||
timeline.appendMessage("compaction-1", { pending: false, compaction: true })
|
||||
timeline.appendPart({ messageID: "assistant-2", partID: "grep-1" }, { type: "tool", name: "grep" })
|
||||
|
||||
expect(withoutIDs([...timeline.values()])).toEqual([
|
||||
{
|
||||
type: "group",
|
||||
kind: "exploration",
|
||||
pending: [],
|
||||
completed: true,
|
||||
refs: [{ messageID: "assistant-1", partID: "read-1" }],
|
||||
},
|
||||
{ type: "message", messageID: "compaction-1" },
|
||||
{
|
||||
type: "group",
|
||||
kind: "exploration",
|
||||
pending: [],
|
||||
completed: false,
|
||||
refs: [{ messageID: "assistant-2", partID: "grep-1" }],
|
||||
},
|
||||
{ type: "message", messageID: "user-1" },
|
||||
])
|
||||
})
|
||||
|
||||
function assistant(id: string, content: SessionMessageAssistant["content"]): SessionMessageAssistant {
|
||||
return {
|
||||
type: "assistant",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue