Skip to content

Commit ecb6457

Browse files
committed
test: cover TUI optimistic prompt sync
1 parent de800a9 commit ecb6457

3 files changed

Lines changed: 158 additions & 34 deletions

File tree

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import type { AgentPartInput, FilePartInput, Message, Part, SubtaskPartInput, TextPartInput } from "@opencode-ai/sdk/v2"
2+
import { Binary } from "@opencode-ai/core/util/binary"
3+
4+
export type OptimisticPromptPart = (TextPartInput | FilePartInput | AgentPartInput | SubtaskPartInput) & { id: string }
5+
6+
export function optimisticParts(input: { sessionID: string; messageID: string; parts: OptimisticPromptPart[] }) {
7+
return input.parts.map((part): Part => {
8+
const withIDs = {
9+
...part,
10+
sessionID: input.sessionID,
11+
messageID: input.messageID,
12+
}
13+
if (withIDs.type === "file") return { ...withIDs, url: "" }
14+
return withIDs
15+
})
16+
}
17+
18+
export function mergeFetchedMessages(input: {
19+
currentMessages: Message[]
20+
currentParts: Record<string, Part[] | undefined>
21+
fetched: { info: Message; parts: Part[] }[]
22+
optimisticMessages: ReadonlySet<string>
23+
}) {
24+
const fetchedIDs = new Set(input.fetched.map((message) => message.info.id))
25+
const messages = input.fetched.map((message) => message.info)
26+
const parts = new Map<string, Part[]>()
27+
const resolved = new Set<string>()
28+
29+
for (const message of input.currentMessages) {
30+
if (input.optimisticMessages.has(message.id) && !fetchedIDs.has(message.id)) {
31+
Binary.insert(messages, message, (item) => item.id)
32+
}
33+
}
34+
35+
for (const message of input.fetched) {
36+
if (message.parts.length > 0) {
37+
resolved.add(message.info.id)
38+
parts.set(message.info.id, message.parts)
39+
continue
40+
}
41+
if (input.optimisticMessages.has(message.info.id)) {
42+
const current = input.currentParts[message.info.id]
43+
if (current) parts.set(message.info.id, current)
44+
continue
45+
}
46+
parts.set(message.info.id, message.parts)
47+
}
48+
49+
return { messages, parts, resolved }
50+
}

packages/opencode/src/cli/cmd/tui/context/sync.tsx

Lines changed: 16 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,6 @@ import type {
44
Provider,
55
Session,
66
Part,
7-
TextPartInput,
8-
FilePartInput,
9-
AgentPartInput,
10-
SubtaskPartInput,
117
Config,
128
Todo,
139
Command,
@@ -36,8 +32,7 @@ import * as Log from "@opencode-ai/core/util/log"
3632
import { emptyConsoleState, type ConsoleState } from "@/config/console-state"
3733
import path from "path"
3834
import { useKV } from "./kv"
39-
40-
type OptimisticPromptPart = (TextPartInput | FilePartInput | AgentPartInput | SubtaskPartInput) & { id: string }
35+
import { mergeFetchedMessages, optimisticParts, type OptimisticPromptPart } from "./sync-optimistic"
4136

4237
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
4338
name: "Sync",
@@ -226,6 +221,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
226221
break
227222

228223
case "session.deleted": {
224+
for (const message of store.message[event.properties.info.id] ?? []) optimisticMessages.delete(message.id)
229225
const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
230226
if (result.found) {
231227
setStore(
@@ -258,7 +254,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
258254
}
259255

260256
case "message.updated": {
261-
optimisticMessages.delete(event.properties.info.id)
262257
const messages = store.message[event.properties.info.sessionID]
263258
if (!messages) {
264259
setStore("message", event.properties.info.sessionID, [event.properties.info])
@@ -313,6 +308,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
313308
break
314309
}
315310
case "message.part.updated": {
311+
optimisticMessages.delete(event.properties.part.messageID)
316312
const parts = store.part[event.properties.part.messageID]
317313
if (!parts) {
318314
setStore("part", event.properties.part.messageID, [event.properties.part])
@@ -386,6 +382,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
386382
const workspace = project.workspace.current()
387383
if (workspace !== syncedWorkspace) {
388384
fullSyncedSessions.clear()
385+
optimisticMessages.clear()
389386
syncedWorkspace = workspace
390387
}
391388
const projectPromise = project.sync()
@@ -544,21 +541,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
544541
...(input.variant ? { variant: input.variant } : {}),
545542
},
546543
}
547-
const parts = input.parts.map((part): Part => {
548-
const withIDs = {
549-
...part,
550-
sessionID: input.sessionID,
551-
messageID: input.messageID,
552-
}
553-
if (withIDs.type === "file") {
554-
return {
555-
...withIDs,
556-
url: "",
557-
}
558-
}
559-
return withIDs
560-
})
561-
562544
batch(() => {
563545
if (!messages) {
564546
setStore("message", input.sessionID, [info])
@@ -571,7 +553,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
571553
}),
572554
)
573555
}
574-
setStore("part", input.messageID, reconcile(parts))
556+
setStore("part", input.messageID, reconcile(optimisticParts(input)))
575557
})
576558
},
577559
removeOptimisticPrompt(sessionID: string, messageID: string) {
@@ -607,21 +589,21 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
607589
setStore(
608590
produce((draft) => {
609591
const match = Binary.search(draft.session, sessionID, (s) => s.id)
610-
const fetched = messages.data!
611-
const fetchedIDs = new Set(fetched.map((message) => message.info.id))
612-
const optimistic = (draft.message[sessionID] ?? []).filter(
613-
(message) => optimisticMessages.has(message.id) && !fetchedIDs.has(message.id),
614-
)
592+
const merged = mergeFetchedMessages({
593+
currentMessages: draft.message[sessionID] ?? [],
594+
currentParts: draft.part,
595+
fetched: messages.data!,
596+
optimisticMessages,
597+
})
615598
if (match.found) draft.session[match.index] = session.data!
616599
if (!match.found) draft.session.splice(match.index, 0, session.data!)
617600
draft.todo[sessionID] = todo.data ?? []
618-
draft.message[sessionID] = fetched.map((x) => x.info)
619-
for (const message of optimistic) {
620-
Binary.insert(draft.message[sessionID], message, (item) => item.id)
601+
draft.message[sessionID] = merged.messages
602+
for (const messageID of merged.resolved) {
603+
optimisticMessages.delete(messageID)
621604
}
622-
for (const message of fetched) {
623-
optimisticMessages.delete(message.info.id)
624-
draft.part[message.info.id] = message.parts
605+
for (const [messageID, parts] of merged.parts) {
606+
draft.part[messageID] = parts
625607
}
626608
draft.session_diff[sessionID] = diff.data ?? []
627609
}),
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { describe, expect, test } from "bun:test"
2+
import type { Message, Part } from "@opencode-ai/sdk/v2"
3+
import { mergeFetchedMessages, optimisticParts } from "@/cli/cmd/tui/context/sync-optimistic"
4+
5+
function user(id: string): Message {
6+
return {
7+
id,
8+
sessionID: "ses_test",
9+
role: "user",
10+
time: { created: 1 },
11+
agent: "build",
12+
model: { providerID: "test", modelID: "model" },
13+
}
14+
}
15+
16+
function text(messageID: string, text: string): Part {
17+
return {
18+
id: `part_${messageID}`,
19+
sessionID: "ses_test",
20+
messageID,
21+
type: "text",
22+
text,
23+
}
24+
}
25+
26+
describe("TUI optimistic prompt sync", () => {
27+
test("keeps an optimistic message while session sync has not fetched it yet", () => {
28+
const merged = mergeFetchedMessages({
29+
currentMessages: [user("msg_2")],
30+
currentParts: { msg_2: [text("msg_2", "optimistic")] },
31+
fetched: [{ info: user("msg_1"), parts: [text("msg_1", "persisted")] }],
32+
optimisticMessages: new Set(["msg_2"]),
33+
})
34+
35+
expect(merged.messages.map((message) => message.id)).toEqual(["msg_1", "msg_2"])
36+
expect(merged.parts.get("msg_1")?.map((part) => (part.type === "text" ? part.text : ""))).toEqual(["persisted"])
37+
expect(merged.resolved.has("msg_2")).toBe(false)
38+
})
39+
40+
test("preserves optimistic parts when sync fetches the message before its parts", () => {
41+
const merged = mergeFetchedMessages({
42+
currentMessages: [user("msg_1")],
43+
currentParts: { msg_1: [text("msg_1", "optimistic")] },
44+
fetched: [{ info: user("msg_1"), parts: [] }],
45+
optimisticMessages: new Set(["msg_1"]),
46+
})
47+
48+
expect(merged.messages.map((message) => message.id)).toEqual(["msg_1"])
49+
expect(merged.parts.get("msg_1")?.map((part) => (part.type === "text" ? part.text : ""))).toEqual(["optimistic"])
50+
expect(merged.resolved.has("msg_1")).toBe(false)
51+
})
52+
53+
test("replaces optimistic parts once real fetched parts arrive", () => {
54+
const merged = mergeFetchedMessages({
55+
currentMessages: [user("msg_1")],
56+
currentParts: { msg_1: [text("msg_1", "optimistic")] },
57+
fetched: [{ info: user("msg_1"), parts: [text("msg_1", "persisted")] }],
58+
optimisticMessages: new Set(["msg_1"]),
59+
})
60+
61+
expect(merged.parts.get("msg_1")?.map((part) => (part.type === "text" ? part.text : ""))).toEqual(["persisted"])
62+
expect(merged.resolved.has("msg_1")).toBe(true)
63+
})
64+
65+
test("strips file URLs from optimistic render parts", () => {
66+
const parts = optimisticParts({
67+
sessionID: "ses_test",
68+
messageID: "msg_1",
69+
parts: [
70+
{
71+
id: "part_file",
72+
type: "file",
73+
mime: "image/png",
74+
filename: "image.png",
75+
url: "data:image/png;base64,large",
76+
},
77+
],
78+
})
79+
80+
expect(parts).toEqual([
81+
{
82+
id: "part_file",
83+
sessionID: "ses_test",
84+
messageID: "msg_1",
85+
type: "file",
86+
mime: "image/png",
87+
filename: "image.png",
88+
url: "",
89+
},
90+
])
91+
})
92+
})

0 commit comments

Comments
 (0)