diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 26ee2f0481cc..0e76b7f3f716 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -22,6 +22,7 @@ import type { SessionMessageAssistantText, SessionMessageAssistantTool, SessionInfo, + SessionPendingInfo, Shell, SkillInfo, } from "@opencode-ai/sdk/v2" @@ -145,6 +146,31 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ const item = messages.findLast((item) => item.type === "compaction" && item.status === "running") return item?.type === "compaction" ? item : undefined }, + fromPending(item: SessionPendingInfo): SessionMessageInfo { + if (item.type === "user") + return { + id: item.id, + type: "user", + ...item.data, + time: { created: item.timeCreated }, + } + if (item.type === "synthetic") + return { + id: item.id, + type: "synthetic", + ...item.data, + time: { created: item.timeCreated }, + } + return { + id: item.id, + type: "compaction", + status: "running", + reason: "manual", + summary: "", + recent: "", + time: { created: item.timeCreated }, + } + }, latestTool(assistant: SessionMessageAssistant | undefined, callID?: string) { return assistant?.content.findLast( (item): item is SessionMessageAssistantTool => @@ -612,11 +638,38 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ setSessionStatus(event.data.sessionID, "running") break case "session.compaction.admitted": + message.update(event.data.sessionID, (draft, index) => { + message.append(draft, index, { + id: event.data.inputID, + type: "compaction", + status: "running", + reason: "manual", + summary: "", + recent: "", + time: { created: event.created }, + }) + }) break case "session.compaction.started": message.update(event.data.sessionID, (draft, index) => { + const id = event.data.inputID ?? messageIDFromEvent(event.id) + const position = index.get(id) + const existing = position === undefined ? undefined : draft[position] + if (position !== undefined && existing?.type === "compaction") { + draft[position] = { + id, + type: "compaction", + status: "running", + reason: event.data.reason, + summary: existing.status === "running" ? existing.summary : "", + recent: event.data.recent, + metadata: existing.metadata, + time: existing.time, + } + return + } message.append(draft, index, { - id: event.data.inputID ?? messageIDFromEvent(event.id), + id, type: "compaction", status: "running", reason: event.data.reason, @@ -836,9 +889,22 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return position === undefined ? undefined : messages?.[position] }, 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)) + const [projected, pending] = await Promise.all([ + sdk.api.message.list({ sessionID, limit: 200, order: "desc" }), + sdk.api.session.pending.list({ sessionID }), + ]) + const messages = projected.data.toReversed() + const projectedIDs = new Set(messages.map((message) => message.id)) + const pendingMessages = pending.filter((item) => !projectedIDs.has(item.id)).map(message.fromPending) + const next = [...messages, ...pendingMessages] + messageIndex.set(sessionID, new Map(next.map((message, index) => [message.id, index]))) + setStore( + "session", + "input", + sessionID, + pendingMessages.flatMap((item) => (item.type === "user" || item.type === "synthetic" ? [item.id] : [])), + ) + setStore("session", "message", sessionID, reconcile(next)) }, }, permission: { diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index 7f675dd40c2a..4818632252d0 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -2091,6 +2091,110 @@ test("renders admitted prompts immediately and tracks them until promoted", asyn } }) +test("refreshes pending session work into message state", async () => { + const events = createEventStream() + const sessionID = "session-pending" + const calls = createFetch((url) => { + if (url.pathname === `/api/session/${sessionID}/message`) + return json({ + data: [{ id: "msg_projected", type: "user", text: "already visible", time: { created: 1 } }], + cursor: {}, + }) + if (url.pathname === `/api/session/${sessionID}/pending`) + return json({ + data: [ + { + admittedSeq: 1, + id: "msg_projected", + sessionID, + timeCreated: 1, + type: "user", + data: { text: "already visible" }, + delivery: "steer", + }, + { + admittedSeq: 2, + id: "msg_pending_user", + sessionID, + timeCreated: 2, + type: "user", + data: { text: "queued user", metadata: { source: "test" } }, + delivery: "queue", + }, + { + admittedSeq: 3, + id: "msg_pending_synthetic", + sessionID, + timeCreated: 3, + type: "synthetic", + data: { text: "internal", description: "Queued synthetic" }, + delivery: "steer", + }, + { + admittedSeq: 4, + id: "msg_pending_compaction", + sessionID, + timeCreated: 4, + type: "compaction", + }, + ], + }) + }, events) + let data!: ReturnType + + function Probe() { + data = useData() + return + } + + const app = await testRender(() => ( + + + + + + + + + + )) + + try { + await data.session.message.refresh(sessionID) + expect(data.session.message.ids(sessionID)).toEqual([ + "msg_projected", + "msg_pending_user", + "msg_pending_synthetic", + "msg_pending_compaction", + ]) + expect(data.session.input.list(sessionID)).toEqual(["msg_pending_user", "msg_pending_synthetic"]) + expect(data.session.message.get(sessionID, "msg_pending_user")).toMatchObject({ + id: "msg_pending_user", + type: "user", + text: "queued user", + metadata: { source: "test" }, + time: { created: 2 }, + }) + expect(data.session.message.get(sessionID, "msg_pending_synthetic")).toMatchObject({ + id: "msg_pending_synthetic", + type: "synthetic", + text: "internal", + description: "Queued synthetic", + time: { created: 3 }, + }) + expect(data.session.message.get(sessionID, "msg_pending_compaction")).toMatchObject({ + id: "msg_pending_compaction", + type: "compaction", + status: "running", + summary: "", + recent: "", + time: { created: 4 }, + }) + } 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/fixture/tui-sdk.ts b/packages/tui/test/fixture/tui-sdk.ts index 536f1da1a385..84fd8ee75c33 100644 --- a/packages/tui/test/fixture/tui-sdk.ts +++ b/packages/tui/test/fixture/tui-sdk.ts @@ -102,6 +102,7 @@ export function createFetch(override?: FetchHandler, events?: ReturnType