diff --git a/packages/tui/TIMELINE_PLAN.md b/packages/tui/TIMELINE_PLAN.md new file mode 100644 index 000000000000..ea98205a06c1 --- /dev/null +++ b/packages/tui/TIMELINE_PLAN.md @@ -0,0 +1,118 @@ +# Session Timeline Frontend Design + +## Problem + +The server exposes projected messages and pending work separately while live events may arrive during either read. The TUI must keep admitted work visible through hydration, promotion, reconnect, revert, and compaction without treating pending records as projected history. + +The server contract remains unchanged. + +## Model + +Projected history stays in the message cache. Everything not yet projected lives in one overlay: + +```ts +type SessionTimelineWork = + | { + kind: "input" + id: string + admittedSeq: number + delivery: "steer" | "queue" + message: SessionMessageInfo + } + | { + kind: "promoted" + id: string + promotedSeq: number + message?: SessionMessageInfo + } + | { + kind: "compaction" + id: string + admittedSeq: number + } +``` + +The variants prevent fabricated state: + +- Admitted input has content, delivery, and admission order. +- Promotion-before-admission needs only its ID and promotion order. +- Queued compaction has no message payload or delivery mode. + +The visible timeline is projected messages followed by non-projected overlay messages. Queued compaction is exposed as its own row kind. + +## Hydration + +Hydration reads pending work before projected history: + +```ts +const pending = await api.session.pending.list({ sessionID }) +const projected = await api.message.list({ sessionID }) +``` + +Promotion atomically removes the pending record and inserts the projected message. Given pending read `P`, projected read `M`, and promotion `X`, with `P < M`: + +- `X < P`: projected contains the input. +- `P < X < M`: projected wins by ID. +- `M < X`: pending contains the input and the promotion event retains it in the overlay. + +Concurrent reads permit the unsafe order `M < X < P`, where both snapshots omit the input. Sequential reads eliminate it. + +## Live Operations + +Only topology operations are journaled during hydration: + +```ts +type SessionTimelineOperation = + | { type: "admitted"; work: SessionAdmittedWork } + | { type: "promoted"; inputID: string; promotedSeq: number; created: number } + | { type: "removed"; inputID: string } + | { type: "reverted"; to: string } +``` + +The journal is folded onto the two server snapshots before installation. Projected IDs always win. + +Text, reasoning, tool, shell, and compaction deltas are not replayed. They are additive, and projected responses expose no event watermark proving whether a delta is already included. + +## Refresh Ownership + +One refresh record per Session owns its promise, identity token, and topology journal. + +- Same-Session callers join the active promise. +- Reconnect clears active records and starts replacements for every loaded, pending-only, or refreshing Session. +- Invalidated callers join the replacement refresh when one exists. +- Delete and committed revert remove the active record, preventing stale installation. +- Failed reads install nothing, preserving visible state. + +No generation counters or durable client-side event log are required. + +## Ordering + +Overlay work is ordered by execution eligibility: + +1. Promoted inputs awaiting projection. +2. Queued compaction barriers. +3. Steering inputs by `admittedSeq`. +4. Queued inputs by `admittedSeq`. + +Projected history always precedes the overlay. + +## Solid Identity + +Every `SessionRow` has a stable semantic ID and rows are installed with: + +```ts +setRows(reconcile(next, { key: "id" })) +``` + +The same durable input ID identifies: + +- pending, promoted, and projected prompt rows; +- queued and running compaction rows. + +Solid therefore moves or updates the existing row owner instead of remounting it. Existing sticky-bottom behavior remains responsible for following output; this change adds no scroll-position policy. + +## Boundary + +`session.message` remains projected-only. `session.timeline` composes projected history with the overlay for rendering and pending-aware interactions. The public `session.input` queries are compatibility views over admitted input variants. + +This design guarantees pending-work visibility under the existing atomic-promotion, ordered-live-connection, and reconnect contracts. Recovering arbitrary silently dropped streaming events would require a server snapshot cursor or replay API. diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index e71c3a6985e2..cb0d3d07d0e9 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -1,7 +1,7 @@ // Client data layer: apply server events and cache API reads into a Solid store. -// Prefer straightforward projection. Do not add generation counters, stale-response -// merges, live/history overlays, or other race machinery here—last write wins. -// Reconnect may re-bootstrap; that is enough. UI and the server own ordering concerns. +// Prefer straightforward projection. API reads replace cached state, except admitted +// inputs survive history refresh until the server projects them. Do not add generation +// counters or live/history overlays here. UI and the server own ordering concerns. import type { AgentInfo, @@ -28,7 +28,15 @@ import type { OpenCodeEvent } from "@opencode-ai/client/promise" import { createStore, produce, reconcile } from "solid-js/store" import { createSimpleContext } from "./helper" import { useSDK } from "./sdk" -import { createSignal, onCleanup } from "solid-js" +import { batch, createSignal, onCleanup } from "solid-js" +import { + applyTimelineOperations, + fromPending, + pendingCompactions, + visibleMessages, + type SessionTimelineWork, + type SessionTimelineOperation, +} from "./session-timeline" export type DataSessionStatus = "idle" | "running" @@ -62,8 +70,7 @@ type Data = { family: Record status: Record message: Record - input: Record - compaction: Record + pending: Record permission: Record // Pending forms keyed by owner: a session ID or the temporary "global" elicitation sentinel. form: Record @@ -91,8 +98,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ family: {}, status: {}, message: {}, - input: {}, - compaction: {}, + pending: {}, permission: {}, form: {}, }, @@ -107,6 +113,11 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ directory: process.cwd(), }) const messageIndex = new Map>() + const timelineHydrated = new Set() + const timelineRefreshes = new Map< + string, + { token: object; operations: SessionTimelineOperation[]; promise: Promise } + >() let bootstrapping: Promise | undefined let connected = false @@ -114,18 +125,21 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ setStore("session", "status", sessionID, status) } - function addCompaction(sessionID: string, inputID: string) { - if (store.session.compaction[sessionID]?.includes(inputID)) return - setStore("session", "compaction", sessionID, [...(store.session.compaction[sessionID] ?? []), inputID]) - } - - function removeCompaction(sessionID: string, inputID?: string) { - if (!inputID || !store.session.compaction[sessionID]?.includes(inputID)) return + function applyTimelineOperation(sessionID: string, operation: SessionTimelineOperation) { + const refresh = timelineRefreshes.get(sessionID) + if (refresh) refresh.operations.push(operation) setStore( "session", - "compaction", + "pending", sessionID, - store.session.compaction[sessionID].filter((id) => id !== inputID), + reconcile( + applyTimelineOperations( + store.session.pending[sessionID] ?? [], + [operation], + new Set((store.session.message[sessionID] ?? []).map((message) => message.id)), + ), + { key: "id" }, + ), ) } @@ -144,13 +158,16 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ index.set(item.id, messages.length) messages.push(item) }, + get(messages: SessionMessageInfo[] | undefined, index: Map | undefined, messageID: string) { + const position = index?.get(messageID) + return position === undefined ? undefined : messages?.[position] + }, activeAssistant(messages: SessionMessageInfo[]) { const item = messages.findLast((item) => item.type === "assistant" && !item.time.completed) return item?.type === "assistant" ? item : undefined }, assistant(messages: SessionMessageInfo[], index: Map, messageID: string) { - const position = index.get(messageID) - const item = position === undefined ? undefined : messages[position] + const item = message.get(messages, index, messageID) return item?.type === "assistant" ? item : undefined }, shell(messages: SessionMessageInfo[], shellID: string) { @@ -230,15 +247,16 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ } function removeSession(sessionID: string) { + timelineRefreshes.delete(sessionID) messageIndex.delete(sessionID) + timelineHydrated.delete(sessionID) setStore( "session", produce((draft) => { delete draft.info[sessionID] delete draft.status[sessionID] delete draft.message[sessionID] - delete draft.input[sessionID] - delete draft.compaction[sessionID] + delete draft.pending[sessionID] delete draft.permission[sessionID] delete draft.form[sessionID] for (const [rootID, family] of Object.entries(draft.family)) { @@ -326,51 +344,27 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ } break case "session.input.promoted": { - message.update(event.data.sessionID, (draft, index) => { - const position = index.get(event.data.inputID) - if (position === undefined) return - const existing = draft[position] - if (!existing || !store.session.input[event.data.sessionID]?.includes(event.data.inputID)) return - existing.time.created = event.created - draft.splice(position, 1) - draft.push(existing) - index.clear() - draft.forEach((message, indexValue) => index.set(message.id, indexValue)) + applyTimelineOperation(event.data.sessionID, { + type: "promoted", + inputID: event.data.inputID, + promotedSeq: event.durable.seq, + created: event.created, }) - setStore( - "session", - "input", - event.data.sessionID, - (store.session.input[event.data.sessionID] ?? []).filter((id) => id !== event.data.inputID), - ) break } - case "session.input.admitted": - if (!store.session.input[event.data.sessionID]?.includes(event.data.inputID)) - setStore("session", "input", event.data.sessionID, [ - ...(store.session.input[event.data.sessionID] ?? []), - event.data.inputID, - ]) - message.update(event.data.sessionID, (draft, index) => { - message.append( - draft, - index, - event.data.input.type === "user" - ? { - id: event.data.inputID, - type: "user", - ...event.data.input.data, - time: { created: event.created }, - } - : { - id: event.data.inputID, - type: "synthetic", - ...event.data.input.data, - time: { created: event.created }, - }, - ) + case "session.input.admitted": { + applyTimelineOperation(event.data.sessionID, { + type: "admitted", + work: fromPending({ + id: event.data.inputID, + sessionID: event.data.sessionID, + admittedSeq: event.durable.seq, + timeCreated: event.created, + ...event.data.input, + }), }) break + } case "session.instructions.updated": message.update(event.data.sessionID, (draft, index) => { message.append(draft, index, { @@ -629,19 +623,29 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ setSessionStatus(event.data.sessionID, "running") break case "session.compaction.admitted": - addCompaction(event.data.sessionID, event.data.inputID) + applyTimelineOperation(event.data.sessionID, { + type: "admitted", + work: { + kind: "compaction", + id: event.data.inputID, + admittedSeq: event.durable.seq, + }, + }) break case "session.compaction.started": - removeCompaction(event.data.sessionID, event.data.inputID) - message.update(event.data.sessionID, (draft, index) => { - message.append(draft, index, { - id: event.data.inputID ?? messageIDFromEvent(event.id), - type: "compaction", - status: "running", - reason: event.data.reason, - summary: "", - recent: event.data.recent ?? "", - time: { created: event.created }, + batch(() => { + if (event.data.inputID) + applyTimelineOperation(event.data.sessionID, { type: "removed", inputID: event.data.inputID }) + message.update(event.data.sessionID, (draft, index) => { + message.append(draft, index, { + id: event.data.inputID ?? messageIDFromEvent(event.id), + type: "compaction", + status: "running", + reason: event.data.reason, + summary: "", + recent: event.data.recent ?? "", + time: { created: event.created }, + }) }) }) break @@ -663,15 +667,11 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ setStore("session", "info", event.data.sessionID, "revert", undefined) break case "session.revert.committed": + timelineRefreshes.delete(event.data.sessionID) if (store.session.info[event.data.sessionID]) { setStore("session", "info", event.data.sessionID, "revert", undefined) } - setStore( - "session", - "input", - event.data.sessionID, - (store.session.input[event.data.sessionID] ?? []).filter((id) => id < event.data.to), - ) + applyTimelineOperation(event.data.sessionID, { type: "reverted", to: event.data.to }) message.update(event.data.sessionID, (draft, index) => { const position = draft.findIndex((item) => item.id >= event.data.to) if (position === -1) return @@ -709,27 +709,30 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }) break case "session.compaction.failed": - removeCompaction(event.data.sessionID, event.data.inputID) - message.update(event.data.sessionID, (draft, index) => { - const position = draft.findLastIndex((item) => item.type === "compaction" && item.status === "running") - const current = draft[position] - const failed: Extract = { - id: current?.id ?? event.data.inputID ?? messageIDFromEvent(event.id), - type: "compaction", - status: "failed", - reason: event.data.reason ?? "manual", - error: event.data.error ?? { - type: "compaction.failed", - message: "Compaction failed before recording an error", - }, - metadata: current?.type === "compaction" ? current.metadata : event.metadata, - time: current?.type === "compaction" ? current.time : { created: event.created }, - } - if (current?.type === "compaction") { - draft[position] = failed - return - } - message.append(draft, index, failed) + batch(() => { + if (event.data.inputID) + applyTimelineOperation(event.data.sessionID, { type: "removed", inputID: event.data.inputID }) + message.update(event.data.sessionID, (draft, index) => { + const position = draft.findLastIndex((item) => item.type === "compaction" && item.status === "running") + const current = draft[position] + const failed: Extract = { + id: current?.id ?? event.data.inputID ?? messageIDFromEvent(event.id), + type: "compaction", + status: "failed", + reason: event.data.reason ?? "manual", + error: event.data.error ?? { + type: "compaction.failed", + message: "Compaction failed before recording an error", + }, + metadata: current?.type === "compaction" ? current.metadata : event.metadata, + time: current?.type === "compaction" ? current.time : { created: event.created }, + } + if (current?.type === "compaction") { + draft[position] = failed + return + } + message.append(draft, index, failed) + }) }) break case "permission.v2.asked": @@ -806,6 +809,65 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ } } + function refreshTimeline(sessionID: string): Promise { + const existing = timelineRefreshes.get(sessionID) + if (existing) return existing.promise + + const operations: SessionTimelineOperation[] = [] + const token = {} + const baseline = new Set( + timelineHydrated.has(sessionID) ? (store.session.message[sessionID] ?? []).map((message) => message.id) : [], + ) + const promise = (async () => { + const pending = await sdk.api.session.pending.list({ sessionID }) + if (timelineRefreshes.get(sessionID)?.token !== token) return timelineRefreshes.get(sessionID)?.promise + const projected = await sdk.api.message.list({ sessionID, limit: 200, order: "desc" }) + if (timelineRefreshes.get(sessionID)?.token !== token) return timelineRefreshes.get(sessionID)?.promise + + const messages: SessionMessageInfo[] = projected.data.toReversed() + const messagePositions = new Map(messages.map((message, index) => [message.id, index])) + store.session.message[sessionID] + ?.filter((message) => !baseline.has(message.id)) + .forEach((message) => { + const position = messagePositions.get(message.id) + if (position === undefined) { + messagePositions.set(message.id, messages.length) + messages.push(message) + return + } + messages[position] = message + }) + const pendingWork = applyTimelineOperations( + pending.map(fromPending), + operations, + new Set(messages.map((message) => message.id)), + ) + batch(() => { + messageIndex.set(sessionID, messagePositions) + setStore("session", "message", sessionID, reconcile(messages, { key: "id" })) + setStore("session", "pending", sessionID, reconcile(pendingWork, { key: "id" })) + timelineHydrated.add(sessionID) + }) + })() + timelineRefreshes.set(sessionID, { token, operations, promise }) + const cleanup = () => { + if (timelineRefreshes.get(sessionID)?.token === token) timelineRefreshes.delete(sessionID) + } + void promise.then(cleanup, cleanup) + return promise + } + + function timelineList(sessionID: string) { + return visibleMessages(store.session.message[sessionID] ?? [], store.session.pending[sessionID] ?? []) + } + + function timelineGet(sessionID: string, messageID: string) { + const projected = message.get(store.session.message[sessionID], messageIndex.get(sessionID), messageID) + if (projected) return projected + const work = store.session.pending[sessionID]?.find((work) => work.id === messageID) + return work?.kind === "input" || work?.kind === "promoted" ? work.message : undefined + } + const result = { on: sdk.event.on, listen: sdk.event.listen, @@ -836,27 +898,15 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }, input: { list(sessionID: string) { - return store.session.input[sessionID] ?? [] + return (store.session.pending[sessionID] ?? []) + .filter((work) => work.kind === "input") + .map((work) => work.id) }, has(sessionID: string, inputID: string) { - return store.session.input[sessionID]?.includes(inputID) ?? false - }, - }, - compaction: { - list(sessionID: string) { - return store.session.compaction[sessionID] ?? [] - }, - async refresh(sessionID: string) { - if (!store.session.compaction[sessionID]) setStore("session", "compaction", sessionID, []) - setStore( - "session", - "compaction", - sessionID, - reconcile( - (await sdk.api.session.pending.list({ sessionID })) - .filter((item) => item.type === "compaction") - .map((item) => item.id), - ), + return ( + store.session.pending[sessionID]?.some( + (work) => work.kind === "input" && work.id === inputID, + ) ?? false ) }, }, @@ -872,14 +922,21 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return store.session.message[sessionID] ?? [] }, get(sessionID: string, messageID: string) { - const messages = store.session.message[sessionID] - const position = messageIndex.get(sessionID)?.get(messageID) - return position === undefined ? undefined : messages?.[position] + return message.get(store.session.message[sessionID], messageIndex.get(sessionID), messageID) }, - async refresh(sessionID: string) { - const messages = (await sdk.api.message.list({ sessionID, limit: 200, order: "desc" })).data.toReversed() - messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index]))) - setStore("session", "message", sessionID, reconcile(messages)) + refresh(sessionID: string) { + return refreshTimeline(sessionID) + }, + }, + timeline: { + list(sessionID: string) { + return timelineList(sessionID) + }, + get(sessionID: string, messageID: string) { + return timelineGet(sessionID, messageID) + }, + compactions(sessionID: string) { + return pendingCompactions(store.session.pending[sessionID] ?? []) }, }, permission: { @@ -1136,15 +1193,15 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ onCleanup( sdk.event.listen(({ details }) => { if (details.type === "server.connected") { - const messages = connected ? Object.keys(store.session.message) : [] - const compactions = connected ? Object.keys(store.session.compaction) : [] + const sessions = new Set([ + ...(connected ? Object.keys(store.session.message) : []), + ...(connected ? Object.keys(store.session.pending) : []), + ...timelineRefreshes.keys(), + ]) + timelineRefreshes.clear() connected = true refreshActive() - void Promise.allSettled([ - bootstrap(), - ...messages.map(result.session.message.refresh), - ...compactions.map(result.session.compaction.refresh), - ]) + void Promise.allSettled([bootstrap(), ...Array.from(sessions).map(result.session.message.refresh)]) return } handleEvent(details) diff --git a/packages/tui/src/context/session-timeline.ts b/packages/tui/src/context/session-timeline.ts new file mode 100644 index 000000000000..024581fd1468 --- /dev/null +++ b/packages/tui/src/context/session-timeline.ts @@ -0,0 +1,138 @@ +import type { SessionMessageInfo, SessionPendingInfo } from "@opencode-ai/sdk/v2" + +export type SessionTimelineWork = + | { + kind: "input" + id: string + admittedSeq: number + delivery: "steer" | "queue" + message: SessionMessageInfo + } + | { + kind: "promoted" + id: string + promotedSeq: number + message?: SessionMessageInfo + } + | { + kind: "compaction" + id: string + admittedSeq: number + } + +export type SessionAdmittedWork = Exclude + +export type SessionTimelineOperation = + | { type: "admitted"; work: SessionAdmittedWork } + | { type: "promoted"; inputID: string; promotedSeq: number; created: number } + | { type: "removed"; inputID: string } + | { type: "reverted"; to: string } + +export function fromPending(item: SessionPendingInfo): SessionAdmittedWork { + if (item.type === "user") + return { + kind: "input", + id: item.id, + admittedSeq: item.admittedSeq, + delivery: item.delivery, + message: { + id: item.id, + type: "user", + ...item.data, + time: { created: item.timeCreated }, + }, + } + if (item.type === "synthetic") + return { + kind: "input", + id: item.id, + admittedSeq: item.admittedSeq, + delivery: item.delivery, + message: { + id: item.id, + type: "synthetic", + ...item.data, + time: { created: item.timeCreated }, + }, + } + return { + kind: "compaction", + id: item.id, + admittedSeq: item.admittedSeq, + } +} + +export function applyTimelineOperations( + pending: SessionTimelineWork[], + operations: SessionTimelineOperation[], + projectedIDs = new Set(), +) { + const result = new Map(pending.filter((work) => !projectedIDs.has(work.id)).map((work) => [work.id, work])) + operations.forEach((operation) => { + if (operation.type === "reverted") { + result.forEach((_, id) => { + if (id >= operation.to) result.delete(id) + }) + return + } + if (operation.type === "removed") { + result.delete(operation.inputID) + return + } + if (projectedIDs.has(operation.type === "admitted" ? operation.work.id : operation.inputID)) return + if (operation.type === "admitted") { + const existing = result.get(operation.work.id) + if (operation.work.kind === "compaction") { + result.set(operation.work.id, existing ?? operation.work) + return + } + result.set( + operation.work.id, + existing?.kind === "promoted" + ? { ...existing, message: existing.message ?? operation.work.message } + : (existing ?? operation.work), + ) + return + } + const existing = result.get(operation.inputID) + result.set(operation.inputID, { + kind: "promoted", + id: operation.inputID, + promotedSeq: operation.promotedSeq, + message: existing?.kind === "input" + ? { + ...existing.message, + time: { ...existing.message.time, created: operation.created }, + } + : undefined, + }) + }) + return orderInputs([...result.values()]) +} + +export function visibleMessages(projected: SessionMessageInfo[], pending: SessionTimelineWork[]) { + if (pending.length === 0) return projected + const ids = new Set(projected.map((message) => message.id)) + return [ + ...projected, + ...pending.flatMap((work) => + work.kind !== "compaction" && !ids.has(work.id) && work.message ? [work.message] : [], + ), + ] +} + +export function pendingCompactions(pending: SessionTimelineWork[]) { + return pending.flatMap((work) => (work.kind === "compaction" ? [work.id] : [])) +} + +function orderInputs(pending: SessionTimelineWork[]) { + const bucket = (work: SessionTimelineWork) => { + if (work.kind === "promoted") return 0 + if (work.kind === "compaction") return 1 + if (work.delivery === "steer") return 2 + return 3 + } + const sequence = (work: SessionTimelineWork) => + work.kind === "promoted" ? work.promotedSeq : work.admittedSeq + return pending.toSorted((a, b) => bucket(a) - bucket(b) || sequence(a) - sequence(b)) +} diff --git a/packages/tui/src/routes/session/dialog-fork.tsx b/packages/tui/src/routes/session/dialog-fork.tsx index ed2a5c7f4fdf..07ba5abbf329 100644 --- a/packages/tui/src/routes/session/dialog-fork.tsx +++ b/packages/tui/src/routes/session/dialog-fork.tsx @@ -25,7 +25,7 @@ export function DialogFork(props: { sessionID: string; messageID?: string; onMov return undefined }) if (!result) return dialog.clear() - const message = messageID ? data.session.message.get(props.sessionID, messageID) : undefined + const message = messageID ? data.session.timeline.get(props.sessionID, messageID) : undefined route.navigate({ sessionID: result.id, type: "session", @@ -59,7 +59,7 @@ export function DialogFork(props: { sessionID: string; messageID?: string; onMov value: undefined, onSelect: () => fork(), }, - ...data.session.message + ...data.session.timeline .list(props.sessionID) .filter((message) => message.type === "user") .toReversed() diff --git a/packages/tui/src/routes/session/dialog-message.tsx b/packages/tui/src/routes/session/dialog-message.tsx index db64c7b5da28..4b85d95b5145 100644 --- a/packages/tui/src/routes/session/dialog-message.tsx +++ b/packages/tui/src/routes/session/dialog-message.tsx @@ -12,7 +12,7 @@ export function DialogMessage(props: { messageID: string; sessionID: string }) { const clipboard = useClipboard() const toast = useToast() const sdk = useSDK() - const message = createMemo(() => data.session.message.get(props.sessionID, props.messageID)) + const message = createMemo(() => data.session.timeline.get(props.sessionID, props.messageID)) return ( data.session.get(route.sessionID)) - const messageIDs = createMemo(() => data.session.message.ids(route.sessionID)) - const sessionMessages = () => - messageIDs().flatMap((id) => { - const message = data.session.message.get(route.sessionID, id) - return message ? [message] : [] - }) + const sessionMessages = createMemo(() => data.session.timeline.list(route.sessionID)) const location = createMemo(() => session()?.location) createEffect(() => { @@ -962,7 +957,7 @@ export function Session() { {(row) => ( data.session.message.get(route.sessionID, messageID)} + message={(messageID) => data.session.timeline.get(route.sessionID, messageID)} /> )} @@ -1053,7 +1048,7 @@ export function Session() { function SessionRowView(props: { row: SessionRow; message: (messageID: string) => SessionMessageInfo | undefined }) { return ( - + {(row) => ( @@ -1516,7 +1511,6 @@ function UserMessage(props: { message: SessionMessageUser }) { return ( ) { const data = useData() @@ -27,7 +28,7 @@ export function createSessionRows(sessionID: Accessor) { const revertBoundary = () => data.session.get(sessionID())?.revert?.messageID function reduce() { - const messages = data.session.message.list(sessionID()) + const messages = data.session.timeline.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) @@ -36,9 +37,9 @@ export function createSessionRows(sessionID: Accessor) { rows.splice( position === -1 ? rows.length : position, 0, - ...data.session.compaction - .list(sessionID()) - .map((inputID): SessionRow => ({ type: "compaction-queued", inputID })), + ...data.session.timeline + .compactions(sessionID()) + .map((inputID): SessionRow => ({ id: inputID, type: "compaction-queued", inputID })), ) return rows } @@ -51,6 +52,10 @@ export function createSessionRows(sessionID: Accessor) { ) } + function replaceRows() { + setRows(reconcile(reduce(), { key: "id" })) + } + createEffect(() => { const pending = pendingPermissions() setRows( @@ -62,12 +67,10 @@ export function createSessionRows(sessionID: Accessor) { createEffect( on(sessionID, (id) => { - setRows(reconcile(reduce())) - void data.session.compaction.refresh(id).catch(() => undefined) + replaceRows() void data.session.message.refresh(id).then( () => { - if (sessionID() !== id) return - setRows(reconcile(reduce())) + if (sessionID() === id) replaceRows() }, () => undefined, ) @@ -77,39 +80,35 @@ export function createSessionRows(sessionID: Accessor) { // Re-reduce when the revert boundary changes (stage/clear/commit). createEffect( on(revertBoundary, () => { - setRows(reconcile(reduce())) + replaceRows() }), ) - createEffect( - on( - () => data.session.compaction.list(sessionID()).map((inputID) => inputID), - () => setRows(reconcile(reduce())), - ), - ) - createEffect( on( () => - data.session.message.list(sessionID()).flatMap((message) => - message.type === "user" || message.type === "synthetic" - ? [ - { - id: message.id, - created: message.time.created, - input: data.session.input.has(sessionID(), message.id), - }, - ] - : message.type === "compaction" + [ + ...data.session.timeline.list(sessionID()).flatMap((message) => + message.type === "user" || message.type === "synthetic" ? [ { id: message.id, created: message.time.created, + input: data.session.input.has(sessionID(), message.id), }, ] - : [], - ), - () => setRows(reconcile(reduce())), + : message.type === "compaction" + ? [ + { + id: message.id, + created: message.time.created, + }, + ] + : [], + ), + ...data.session.timeline.compactions(sessionID()).map((id) => ({ id, pending: true })), + ], + replaceRows, ), ) @@ -118,11 +117,11 @@ export function createSessionRows(sessionID: Accessor) { 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 message = data.session.timeline.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 }) + draft.splice(index, 0, { id: messageID, type: "message", messageID }) }), ) @@ -139,6 +138,7 @@ export function createSessionRows(sessionID: Accessor) { } completePrevious(draft, index) draft.splice(index, 0, { + id: rowID(ref), type: "group", kind: "exploration", refs: [ref], @@ -148,7 +148,7 @@ export function createSessionRows(sessionID: Accessor) { return } completePrevious(draft, index) - draft.splice(index, 0, { type: "part", ref }) + draft.splice(index, 0, { id: rowID(ref), type: "part", ref }) }), ) @@ -158,7 +158,7 @@ export function createSessionRows(sessionID: Accessor) { 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 }) + draft.splice(index, 0, { id: `assistant-footer:${messageID}`, type: "assistant-footer", messageID }) }), ) @@ -171,7 +171,7 @@ export function createSessionRows(sessionID: Accessor) { ) const isPending = (messageID: string) => { - const message = data.session.message.get(sessionID(), messageID) + const message = data.session.timeline.get(sessionID(), messageID) if (message?.type === "user" || message?.type === "synthetic") return data.session.input.has(sessionID(), messageID) return message?.type === "compaction" && message.status === "running" } @@ -261,7 +261,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({ id: message.id, type: "message", messageID: message.id }) return rows } const ordinals = { text: 0, reasoning: 0 } @@ -272,7 +272,7 @@ 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({ id: `assistant-footer:${message.id}`, type: "assistant-footer", messageID: message.id }) } return rows }, []) @@ -296,12 +296,16 @@ function append(rows: SessionRow[], ref: PartRef, part: SessionMessageAssistant[ return } completePrevious(rows) - rows.push({ type: "group", kind: "exploration", refs: [ref], pending: [], completed: false }) + rows.push({ id: rowID(ref), type: "group", kind: "exploration", refs: [ref], pending: [], completed: false }) return } } completePrevious(rows) - rows.push({ type: "part", ref }) + rows.push({ id: rowID(ref), type: "part", ref }) +} + +function rowID(ref: PartRef) { + return `part:${ref.messageID}:${ref.partID}` } 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 936e141b3107..117631a7ebf8 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -647,7 +647,7 @@ test("completes exploration when a queued prompt is promoted", async () => { 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" }) + expect(rows.at(-1)).toMatchObject({ id: "message-user", type: "message", messageID: "message-user" }) } finally { app.renderer.destroy() } @@ -656,8 +656,27 @@ test("completes exploration when a queued prompt is promoted", async () => { test("removes committed revert messages from local state", async () => { const events = createEventStream() const sessionID = "session-revert" + const messageResponse = Promise.withResolvers() + const messageRequested = Promise.withResolvers() + let hydrating = false const calls = createFetch((url) => { - if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} }) + if (url.pathname === `/api/session/${sessionID}/pending` && hydrating) + return json({ + data: ["msg_001", "msg_002", "msg_003"].map((id, admittedSeq) => ({ + admittedSeq, + id, + sessionID, + timeCreated: admittedSeq, + type: "user", + data: { text: id }, + delivery: "steer", + })), + }) + if (url.pathname === `/api/session/${sessionID}/message`) { + if (!hydrating) return json({ data: [], cursor: {} }) + messageRequested.resolve() + return messageResponse.promise + } }, events) let data!: ReturnType @@ -688,7 +707,10 @@ test("removes committed revert messages from local state", async () => { data: { sessionID, inputID, input: { type: "user", data: { text: inputID }, delivery: "steer" } }, }) } - await wait(() => data.session.message.ids(sessionID).length === 3) + await wait(() => data.session.timeline.list(sessionID).length === 3) + hydrating = true + const refresh = data.session.message.refresh(sessionID) + await messageRequested.promise emitEvent(events, { id: EventV2.ID.create(), @@ -697,11 +719,21 @@ test("removes committed revert messages from local state", async () => { durable: durable(sessionID, 3), data: { sessionID, to: "msg_002" }, }) + messageResponse.resolve( + json({ + data: [ + { id: "msg_003", type: "user", text: "msg_003", time: { created: 2 } }, + { id: "msg_002", type: "user", text: "msg_002", time: { created: 1 } }, + ], + cursor: {}, + }), + ) + await refresh - await wait(() => data.session.message.ids(sessionID).length === 1) - expect(data.session.message.ids(sessionID)).toEqual(["msg_001"]) - expect(data.session.message.get(sessionID, "msg_002")).toBeUndefined() - expect(data.session.message.get(sessionID, "msg_003")).toBeUndefined() + await wait(() => data.session.timeline.list(sessionID).length === 1) + expect(data.session.timeline.list(sessionID).map((message) => message.id)).toEqual(["msg_001"]) + expect(data.session.timeline.get(sessionID, "msg_002")).toBeUndefined() + expect(data.session.timeline.get(sessionID, "msg_003")).toBeUndefined() } finally { app.renderer.destroy() } @@ -1054,7 +1086,7 @@ test("tracks session status from active sessions and execution events", async () durable: durable("session-manual", 1), data: { sessionID: "session-manual", inputID: "message-compaction" }, }) - await wait(() => data.session.compaction.list("session-manual").includes("message-compaction")) + await wait(() => data.session.timeline.compactions("session-manual").includes("message-compaction")) emitEvent(events, { id: "evt_manual_compaction_started", created: 1, @@ -1072,7 +1104,7 @@ 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 === "running" && message.summary === "Streamed summary" }) - expect(data.session.compaction.list("session-manual")).toEqual([]) + expect(data.session.timeline.compactions("session-manual")).toEqual([]) const compactionRow = manualRows.find( (row) => row.type === "message" && row.messageID === "message-compaction", ) @@ -1088,7 +1120,7 @@ test("tracks session status from active sessions and execution events", async () return message?.type === "compaction" && message.status === "completed" }) expect(manualRows.filter((row) => row.type === "message")).toEqual([ - { type: "message", messageID: "message-compaction" }, + { id: "message-compaction", type: "message", messageID: "message-compaction" }, ]) expect(manualRows.find((row) => row.type === "message" && row.messageID === "message-compaction")).toBe( compactionRow, @@ -1191,16 +1223,25 @@ test("restores queued compaction from durable pending input", async () => { )) try { - await wait(() => data.session.compaction.list(sessionID).length === 2) - expect(data.session.compaction.list(sessionID)).toEqual([ + await wait(() => data.session.timeline.compactions(sessionID).length === 2) + expect(data.session.timeline.compactions(sessionID)).toEqual([ "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([ - { type: "compaction-queued", inputID: "message-compaction-queued" }, - { type: "compaction-queued", inputID: "message-compaction-later" }, + { + id: "message-compaction-queued", + type: "compaction-queued", + inputID: "message-compaction-queued", + }, + { + id: "message-compaction-later", + type: "compaction-queued", + inputID: "message-compaction-later", + }, ]) + const queuedRow = rows.find((row) => row.id === "message-compaction-queued") emitEvent(events, { id: "evt_compaction_started", @@ -1214,8 +1255,10 @@ test("restores queued compaction from durable pending input", async () => { inputID: "message-compaction-queued", }, }) - await wait(() => data.session.compaction.list(sessionID).length === 1) - expect(data.session.compaction.list(sessionID)).toEqual(["message-compaction-later"]) + await wait(() => data.session.timeline.compactions(sessionID).length === 1) + expect(data.session.timeline.compactions(sessionID)).toEqual(["message-compaction-later"]) + await wait(() => rows.some((row) => row.type === "message" && row.messageID === "message-compaction-queued")) + expect(rows.find((row) => row.id === "message-compaction-queued")).toBe(queuedRow) emitEvent(events, { id: "evt_compaction_ended", @@ -1224,7 +1267,7 @@ test("restores queued compaction from durable pending input", async () => { durable: durable(sessionID, 5), data: { sessionID, reason: "manual", text: "Summary", recent: "" }, }) - expect(data.session.compaction.list(sessionID)).toEqual(["message-compaction-later"]) + expect(data.session.timeline.compactions(sessionID)).toEqual(["message-compaction-later"]) pending = [] emitEvent(events, { @@ -1232,7 +1275,7 @@ test("restores queued compaction from durable pending input", async () => { type: "server.connected", data: {}, }) - await wait(() => data.session.compaction.list(sessionID).length === 0) + await wait(() => data.session.timeline.compactions(sessionID).length === 0) } finally { app.renderer.destroy() } @@ -2121,16 +2164,40 @@ test("settles pending tools when a live failure arrives", async () => { } }) -test("renders admitted prompts immediately and tracks them until promoted", async () => { +test("preserves admitted prompts when hydration races with promotion", async () => { const events = createEventStream() const sessionID = "session-1" const messageID = "msg_user_1" + const queuedID = "msg_user_2" + const requested = Promise.withResolvers() + const response = Promise.withResolvers() const calls = createFetch((url) => { - if (url.pathname === `/api/session/${sessionID}/message`) + if (url.pathname === `/api/session/${sessionID}/pending`) return json({ - data: [{ id: messageID, type: "user", text: "hello", time: { created: 0 } }], - cursor: {}, + data: [ + { + admittedSeq: 0, + id: messageID, + sessionID, + timeCreated: 0, + type: "user", + data: { text: "hello" }, + delivery: "steer", + }, + { + admittedSeq: 1, + id: queuedID, + sessionID, + timeCreated: 1, + type: "user", + data: { text: "queued" }, + delivery: "queue", + }, + ], }) + if (url.pathname !== `/api/session/${sessionID}/message`) return + requested.resolve() + return response.promise }, events) let sync!: ReturnType let ready!: () => void @@ -2171,18 +2238,17 @@ test("renders admitted prompts immediately and tracks them until promoted", asyn input: { type: "user", data: { text: "hello" }, delivery: "steer" }, }, }) - await wait(() => sync.session.message.list(sessionID)?.length === 1) - const admitted = sync.session.message.list(sessionID)?.[0] + await wait(() => sync.session.timeline.list(sessionID).length === 1) + const admitted = sync.session.timeline.list(sessionID)[0] expect(admitted).toMatchObject({ id: messageID, type: "user", text: "hello" }) expect(admitted?.metadata).toBeUndefined() expect(sync.session.input.list(sessionID)).toEqual([messageID]) - await sync.session.message.refresh(sessionID) - expect(sync.session.message.list(sessionID)?.[0]?.metadata).toBeUndefined() - + const refresh = sync.session.message.refresh(sessionID) + await requested.promise emitEvent(events, { id: "evt_prompted_1", - created: 0, + created: 1, type: "session.input.promoted", durable: durable(sessionID, 1), data: { @@ -2194,22 +2260,168 @@ test("renders admitted prompts immediately and tracks them until promoted", asyn await wait(() => received.at(-1) === "session.input.promoted") expect(received.slice(-2)).toEqual(["session.input.admitted", "session.input.promoted"]) unsubscribe() - const message = sync.session.message.list(sessionID)?.[0] + response.resolve(json({ data: [], cursor: {} })) + await refresh + + const message = sync.session.timeline.get(sessionID, messageID) expect(message?.type).toBe("user") if (message?.type !== "user") return expect(message).toMatchObject({ id: messageID, text: "hello" }) expect(message.metadata).toBeUndefined() - expect(sync.session.input.list(sessionID)).toEqual([]) - expect(sync.session.message.ids(sessionID)).toEqual([messageID]) - expect(sync.session.message.ids("missing")).toEqual([]) - expect(sync.session.message.get(sessionID, messageID)).toBe(message) - expect(sync.session.message.get(sessionID, "missing")).toBeUndefined() + expect(sync.session.input.list(sessionID)).toEqual([queuedID]) + expect(sync.session.timeline.list(sessionID).map((message) => message.id)).toEqual([messageID, queuedID]) + expect(sync.session.timeline.get(sessionID, queuedID)).toMatchObject({ id: queuedID, text: "queued" }) + expect(sync.session.timeline.list("missing")).toEqual([]) + expect(sync.session.timeline.get(sessionID, messageID)).toBe(message) + expect(sync.session.timeline.get(sessionID, "missing")).toBeUndefined() expect(received).toHaveLength(3) } finally { app.renderer.destroy() } }) +test("journals admissions after the pending snapshot and joins concurrent refreshes", async () => { + const events = createEventStream() + const sessionID = "session-journal" + const messageID = "msg_late" + const pendingResponse = Promise.withResolvers() + const messageResponse = Promise.withResolvers() + const messageRequested = Promise.withResolvers() + let pendingCalls = 0 + let messageCalls = 0 + const calls = createFetch((url) => { + if (url.pathname === `/api/session/${sessionID}/pending`) { + pendingCalls += 1 + return pendingResponse.promise + } + if (url.pathname === `/api/session/${sessionID}/message`) { + messageCalls += 1 + messageRequested.resolve() + return messageResponse.promise + } + }, events) + let data!: ReturnType + let sdk!: ReturnType + + function Probe() { + data = useData() + sdk = useSDK() + return + } + + const app = await testRender(() => ( + + + + + + + + + + )) + + try { + await wait(() => sdk.connection.status() === "connected") + const first = data.session.message.refresh(sessionID) + const second = data.session.message.refresh(sessionID) + expect(second).toBe(first) + expect(pendingCalls).toBe(1) + expect(messageCalls).toBe(0) + + pendingResponse.resolve(json({ data: [] })) + await messageRequested.promise + emitEvent(events, { + id: "evt_late_admission", + created: 1, + type: "session.input.admitted", + durable: durable(sessionID), + data: { + sessionID, + inputID: messageID, + input: { type: "user", data: { text: "late" }, delivery: "steer" }, + }, + }) + messageResponse.resolve(json({ data: [], cursor: {} })) + await Promise.all([first, second]) + + expect(messageCalls).toBe(1) + expect(data.session.timeline.list(sessionID).map((message) => message.id)).toEqual([messageID]) + expect(data.session.timeline.get(sessionID, messageID)).toMatchObject({ text: "late" }) + expect(data.session.input.list(sessionID)).toEqual([messageID]) + } finally { + app.renderer.destroy() + } +}) + +test("refreshes overlay-only sessions after reconnect", async () => { + const events = createEventStream() + const sessionID = "session-overlay" + const messageID = "msg_overlay" + let pendingCalls = 0 + const calls = createFetch((url) => { + if (url.pathname === `/api/session/${sessionID}/pending`) { + pendingCalls += 1 + return json({ + data: [ + { + admittedSeq: 0, + id: messageID, + sessionID, + timeCreated: 0, + type: "user", + data: { text: "overlay" }, + delivery: "steer", + }, + ], + }) + } + if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} }) + }, events) + let data!: ReturnType + let sdk!: ReturnType + + function Probe() { + data = useData() + sdk = useSDK() + return + } + + const app = await testRender(() => ( + + + + + + + + + + )) + + try { + await wait(() => sdk.connection.status() === "connected") + emitEvent(events, { + id: "evt_overlay_admitted", + created: 0, + type: "session.input.admitted", + durable: durable(sessionID), + data: { + sessionID, + inputID: messageID, + input: { type: "user", data: { text: "overlay" }, delivery: "steer" }, + }, + }) + await wait(() => data.session.timeline.list(sessionID).some((message) => message.id === messageID)) + + emitEvent(events, { id: "evt_reconnected", type: "server.connected", data: {} }) + await wait(() => pendingCalls === 1) + expect(data.session.timeline.list(sessionID).map((message) => message.id)).toEqual([messageID]) + } finally { + app.renderer.destroy() + } +}) + test("projects live instruction updates with their message ID", async () => { const events = createEventStream() const calls = createFetch(undefined, events) diff --git a/packages/tui/test/cli/tui/session-rows.test.ts b/packages/tui/test/cli/tui/session-rows.test.ts index c43a6faf9dff..e7f9a2dfe988 100644 --- a/packages/tui/test/cli/tui/session-rows.test.ts +++ b/packages/tui/test/cli/tui/session-rows.test.ts @@ -1,5 +1,6 @@ import { expect, test } from "bun:test" import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/sdk/v2" +import { createStore, reconcile } from "solid-js/store" import { reduceSessionRows } from "../../../src/routes/session/rows" test("groups exploration parts across assistant messages until a delimiter", () => { @@ -17,9 +18,10 @@ test("groups exploration parts across assistant messages until a delimiter", () ] expect(reduceSessionRows(messages)).toEqual([ - { type: "message", messageID: "user-1" }, - { type: "part", ref: { messageID: "assistant-1", partID: "text:0" } }, + { id: "user-1", type: "message", messageID: "user-1" }, + { id: "part:assistant-1:text:0", type: "part", ref: { messageID: "assistant-1", partID: "text:0" } }, { + id: "part:assistant-1:read-1", type: "group", kind: "exploration", pending: [], @@ -30,7 +32,7 @@ test("groups exploration parts across assistant messages until a delimiter", () { messageID: "assistant-2", partID: "grep-1" }, ], }, - { type: "part", ref: { messageID: "assistant-2", partID: "text:0" } }, + { id: "part:assistant-2:text:0", type: "part", ref: { messageID: "assistant-2", partID: "text:0" } }, ]) }) @@ -45,14 +47,16 @@ test("keeps non-exploration tools as individual part rows", () => { expect(reduceSessionRows(messages)).toEqual([ { + id: "part:assistant-1:read-1", type: "group", kind: "exploration", pending: [], completed: true, refs: [{ messageID: "assistant-1", partID: "read-1" }], }, - { type: "part", ref: { messageID: "assistant-1", partID: "bash-1" } }, + { id: "part:assistant-1:bash-1", type: "part", ref: { messageID: "assistant-1", partID: "bash-1" } }, { + id: "part:assistant-1:grep-1", type: "group", kind: "exploration", pending: [], @@ -73,10 +77,18 @@ test("assigns stable kind ordinals within an assistant message", () => { ] expect(reduceSessionRows(messages)).toEqual([ - { type: "part", ref: { messageID: "assistant-1", partID: "text:0" } }, - { type: "part", ref: { messageID: "assistant-1", partID: "reasoning:0" } }, - { type: "part", ref: { messageID: "assistant-1", partID: "text:1" } }, - { type: "part", ref: { messageID: "assistant-1", partID: "reasoning:1" } }, + { id: "part:assistant-1:text:0", type: "part", ref: { messageID: "assistant-1", partID: "text:0" } }, + { + id: "part:assistant-1:reasoning:0", + type: "part", + ref: { messageID: "assistant-1", partID: "reasoning:0" }, + }, + { id: "part:assistant-1:text:1", type: "part", ref: { messageID: "assistant-1", partID: "text:1" } }, + { + id: "part:assistant-1:reasoning:1", + type: "part", + ref: { messageID: "assistant-1", partID: "reasoning:1" }, + }, ]) }) @@ -93,8 +105,13 @@ test("groups across empty assistant reasoning parts", () => { ] expect(reduceSessionRows(messages)).toEqual([ - { type: "part", ref: { messageID: "assistant-1", partID: "reasoning:0" } }, { + id: "part:assistant-1:reasoning:0", + type: "part", + ref: { messageID: "assistant-1", partID: "reasoning:0" }, + }, + { + id: "part:assistant-1:read-1", type: "group", kind: "exploration", pending: [], @@ -120,21 +137,23 @@ test("completes exploration groups when another row follows", () => { expect(reduceSessionRows(messages)).toEqual([ { + id: "part:assistant-1:read-1", type: "group", kind: "exploration", pending: [], completed: true, refs: [{ messageID: "assistant-1", partID: "read-1" }], }, - { type: "message", messageID: "user-1" }, + { id: "user-1", type: "message", messageID: "user-1" }, { + id: "part:assistant-2:grep-1", type: "group", kind: "exploration", pending: [], completed: true, refs: [{ messageID: "assistant-2", partID: "grep-1" }], }, - { type: "assistant-footer", messageID: "assistant-2" }, + { id: "assistant-footer:assistant-2", type: "assistant-footer", messageID: "assistant-2" }, ]) }) @@ -152,6 +171,7 @@ test("hides synthetic messages without descriptions", () => { expect(reduceSessionRows(messages)).toEqual([ { + id: "part:assistant-1:read-1", type: "group", kind: "exploration", pending: [], @@ -179,14 +199,16 @@ test("renders synthetic messages with descriptions", () => { expect(reduceSessionRows(messages)).toEqual([ { + id: "part:assistant-1:read-1", type: "group", kind: "exploration", pending: [], completed: true, refs: [{ messageID: "assistant-1", partID: "read-1" }], }, - { type: "message", messageID: "synthetic-1" }, + { id: "synthetic-1", type: "message", messageID: "synthetic-1" }, { + id: "part:assistant-2:grep-1", type: "group", kind: "exploration", pending: [], @@ -204,7 +226,9 @@ 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(reduceSessionRows([message])).toEqual([ + { id: "assistant-footer:assistant-retry", type: "assistant-footer", messageID: "assistant-retry" }, + ]) }) test("places a running compaction barrier before every queued user message", () => { @@ -229,12 +253,43 @@ test("places a running compaction barrier before every queued user message", () ] expect(reduceSessionRows(messages, new Set(["user-before", "user-after"]))).toEqual([ - { type: "message", messageID: "compaction" }, - { type: "message", messageID: "user-before" }, - { type: "message", messageID: "user-after" }, + { id: "compaction", type: "message", messageID: "compaction" }, + { id: "user-before", type: "message", messageID: "user-before" }, + { id: "user-after", type: "message", messageID: "user-after" }, + ]) +}) + +test("assigns stable IDs to every row", () => { + const message = assistant("assistant", [ + { type: "text", text: "Hello" }, + { type: "tool", id: "read", name: "read", state: pending(), time: { created: 1 } }, + ]) + message.finish = "stop" + + expect(reduceSessionRows([message]).map((row) => row.id)).toEqual([ + "part:assistant:text:0", + "part:assistant:read", + "assistant-footer:assistant", ]) }) +test("keyed reconciliation preserves exploration row ownership", () => { + const first = assistant("assistant", [ + { type: "tool", id: "read", name: "read", state: pending(), time: { created: 1 } }, + ]) + const [rows, setRows] = createStore(reduceSessionRows([first])) + const group = rows[0] + const second = assistant("assistant", [ + { type: "tool", id: "read", name: "read", state: pending(), time: { created: 1 } }, + { type: "tool", id: "glob", name: "glob", state: pending(), time: { created: 2 } }, + ]) + + setRows(reconcile(reduceSessionRows([second]), { key: "id" })) + + expect(rows[0]).toBe(group) + expect(rows[0]?.type === "group" && rows[0].refs).toHaveLength(2) +}) + function assistant(id: string, content: SessionMessageAssistant["content"]): SessionMessageAssistant { return { type: "assistant", diff --git a/packages/tui/test/context/session-timeline.test.ts b/packages/tui/test/context/session-timeline.test.ts new file mode 100644 index 000000000000..641635c8fcf9 --- /dev/null +++ b/packages/tui/test/context/session-timeline.test.ts @@ -0,0 +1,82 @@ +import { expect, test } from "bun:test" +import type { SessionMessageInfo, SessionPendingInfo } from "@opencode-ai/sdk/v2" +import { + applyTimelineOperations, + fromPending, + pendingCompactions, + visibleMessages, + type SessionAdmittedWork, +} from "../../src/context/session-timeline" + +test("orders promoted work, compaction, steers, then queued inputs", () => { + const inputs = [ + pending("queue", 0, "queue"), + pending("steer-2", 3, "steer"), + fromPending({ admittedSeq: 2, id: "compaction", sessionID: "session", timeCreated: 2, type: "compaction" }), + pending("steer-1", 1, "steer"), + ] + + const result = applyTimelineOperations(inputs, [ + { type: "promoted", inputID: "steer-2", promotedSeq: 4, created: 4 }, + ]) + + expect(result.map((input) => input.id)).toEqual(["steer-2", "compaction", "steer-1", "queue"]) + expect(pendingCompactions(result)).toEqual(["compaction"]) +}) + +test("replays an admission after the pending snapshot", () => { + const input = pending("late", 2, "steer") + expect(applyTimelineOperations([], [{ type: "admitted", work: input }])).toEqual([input]) +}) + +test("does not let late admission downgrade a promotion", () => { + const input = pending("input", 1, "steer") + const result = applyTimelineOperations([], [ + { type: "admitted", work: input }, + { type: "promoted", inputID: input.id, promotedSeq: 2, created: 2 }, + { type: "admitted", work: input }, + ]) + + expect(result).toMatchObject([{ id: input.id, kind: "promoted", promotedSeq: 2 }]) +}) + +test("retains promotion state until a later admission provides content", () => { + const input = pending("input", 1, "steer") + const result = applyTimelineOperations([], [ + { type: "promoted", inputID: input.id, promotedSeq: 2, created: 2 }, + { type: "admitted", work: input }, + ]) + + expect(result).toMatchObject([{ id: input.id, kind: "promoted", promotedSeq: 2, message: { text: "input" } }]) +}) + +test("applies committed reverts after a stale pending snapshot", () => { + const inputs = [pending("msg_001", 1, "steer"), pending("msg_002", 2, "queue")] + expect(applyTimelineOperations(inputs, [{ type: "reverted", to: "msg_002" }]).map((input) => input.id)).toEqual([ + "msg_001", + ]) +}) + +test("projected messages replace pending and promoted representations", () => { + const projected: SessionMessageInfo[] = [{ id: "input", type: "user", text: "hello", time: { created: 2 } }] + const inputs = applyTimelineOperations( + [pending("input", 1, "steer")], + [{ type: "promoted", inputID: "input", promotedSeq: 2, created: 2 }], + new Set(["input"]), + ) + + expect(inputs).toEqual([]) + expect(visibleMessages(projected, [pending("input", 1, "steer")])).toEqual(projected) +}) + +function pending(id: string, admittedSeq: number, delivery: "steer" | "queue"): SessionAdmittedWork { + return fromPending({ + admittedSeq, + id, + sessionID: "session", + timeCreated: admittedSeq, + type: "user", + data: { text: id }, + delivery, + } satisfies SessionPendingInfo) +} diff --git a/packages/tui/test/fixture/tui-sdk.ts b/packages/tui/test/fixture/tui-sdk.ts index 6708fd0fa4bc..282e33f0caed 100644 --- a/packages/tui/test/fixture/tui-sdk.ts +++ b/packages/tui/test/fixture/tui-sdk.ts @@ -105,6 +105,8 @@ export function createFetch(override?: FetchHandler, events?: ReturnType