Skip to content

Commit c77bc4c

Browse files
committed
test(app): verify session diff recovery flow
1 parent 4ade939 commit c77bc4c

File tree

5 files changed

+239
-167
lines changed

5 files changed

+239
-167
lines changed

packages/app/src/context/sync-session-diff.test.ts

Lines changed: 9 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,11 @@
1-
import { beforeAll, describe, expect, mock, test } from "bun:test"
1+
import { describe, expect, mock, test } from "bun:test"
22
import { createRoot } from "solid-js"
33
import { createStore } from "solid-js/store"
44
import type { FileDiff } from "@opencode-ai/sdk/v2/client"
55
import type { State } from "./global-sync/types"
6+
import { createSyncContextValue } from "./sync"
67

7-
type SyncValue = {
8-
session: {
9-
diff: (sessionID: string, opts?: { force?: boolean }) => Promise<void> | undefined
10-
}
11-
}
12-
13-
let initSyncForTest: () => SyncValue
14-
let currentGlobalSync: unknown
15-
let currentSDK: unknown
16-
17-
beforeAll(async () => {
18-
let capturedInit: (() => SyncValue) | undefined
19-
20-
mock.module("@opencode-ai/ui/context", () => ({
21-
createSimpleContext: (input: { init: () => SyncValue }) => {
22-
capturedInit = input.init
23-
return {
24-
use: () => undefined,
25-
provider: () => undefined,
26-
}
27-
},
28-
}))
29-
30-
mock.module("./global-sync", () => ({
31-
useGlobalSync: () => currentGlobalSync,
32-
}))
33-
34-
mock.module("./sdk", () => ({
35-
useSDK: () => currentSDK,
36-
}))
37-
38-
await import("./sync")
39-
if (!capturedInit) throw new Error("Failed to capture sync context init")
40-
initSyncForTest = capturedInit
41-
})
8+
type SyncDeps = Parameters<typeof createSyncContextValue>[0]
429

4310
function baseState(): State {
4411
return {
@@ -84,7 +51,7 @@ describe("sync session diff recovery", () => {
8451
return count === 1 ? ({ data: {} } as const) : ({ data: valid } as const)
8552
})
8653

87-
currentGlobalSync = {
54+
const globalSync = {
8855
child: () => [store, setStore],
8956
data: {
9057
project: [],
@@ -95,7 +62,7 @@ describe("sync session diff recovery", () => {
9562
},
9663
}
9764

98-
currentSDK = {
65+
const sdk = {
9966
directory: "/tmp/project",
10067
client: {
10168
session: {
@@ -106,7 +73,10 @@ describe("sync session diff recovery", () => {
10673

10774
await new Promise<void>((resolve, reject) => {
10875
createRoot((dispose) => {
109-
const sync = initSyncForTest()
76+
const sync = createSyncContextValue({
77+
globalSync: globalSync as unknown as SyncDeps["globalSync"],
78+
sdk: sdk as unknown as SyncDeps["sdk"],
79+
})
11080
void (async () => {
11181
try {
11282
await sync.session.diff(sessionID)

packages/app/src/context/sync.tsx

Lines changed: 128 additions & 119 deletions
Original file line numberDiff line numberDiff line change
@@ -166,109 +166,109 @@ function setOptimisticRemove(setStore: (...args: unknown[]) => void, input: Opti
166166
})
167167
}
168168

169-
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
170-
name: "Sync",
171-
init: () => {
172-
const globalSync = useGlobalSync()
173-
const sdk = useSDK()
169+
export function createSyncContextValue(input: {
170+
globalSync: ReturnType<typeof useGlobalSync>
171+
sdk: ReturnType<typeof useSDK>
172+
}) {
173+
const { globalSync, sdk } = input
174+
175+
type Child = ReturnType<(typeof globalSync)["child"]>
176+
type Setter = Child[1]
177+
178+
const current = createMemo(() => globalSync.child(sdk.directory))
179+
const target = (directory?: string) => {
180+
if (!directory || directory === sdk.directory) return current()
181+
return globalSync.child(directory)
182+
}
183+
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
184+
const initialMessagePageSize = 80
185+
const historyMessagePageSize = 200
186+
const inflight = new Map<string, Promise<void>>()
187+
const inflightDiff = new Map<string, Promise<void>>()
188+
const inflightTodo = new Map<string, Promise<void>>()
189+
const optimistic = new Map<string, Map<string, OptimisticItem>>()
190+
const maxDirs = 30
191+
const seen = new Map<string, Set<string>>()
192+
const [meta, setMeta] = createStore({
193+
limit: {} as Record<string, number>,
194+
cursor: {} as Record<string, string | undefined>,
195+
complete: {} as Record<string, boolean>,
196+
loading: {} as Record<string, boolean>,
197+
})
174198

175-
type Child = ReturnType<(typeof globalSync)["child"]>
176-
type Setter = Child[1]
199+
const getSession = (sessionID: string) => {
200+
const store = current()[0]
201+
const match = Binary.search(store.session, sessionID, (s) => s.id)
202+
if (match.found) return store.session[match.index]
203+
return undefined
204+
}
177205

178-
const current = createMemo(() => globalSync.child(sdk.directory))
179-
const target = (directory?: string) => {
180-
if (!directory || directory === sdk.directory) return current()
181-
return globalSync.child(directory)
206+
const setOptimistic = (directory: string, sessionID: string, item: OptimisticItem) => {
207+
const key = keyFor(directory, sessionID)
208+
const list = optimistic.get(key)
209+
if (list) {
210+
list.set(item.message.id, { message: item.message, parts: sortParts(item.parts) })
211+
return
182212
}
183-
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
184-
const initialMessagePageSize = 80
185-
const historyMessagePageSize = 200
186-
const inflight = new Map<string, Promise<void>>()
187-
const inflightDiff = new Map<string, Promise<void>>()
188-
const inflightTodo = new Map<string, Promise<void>>()
189-
const optimistic = new Map<string, Map<string, OptimisticItem>>()
190-
const maxDirs = 30
191-
const seen = new Map<string, Set<string>>()
192-
const [meta, setMeta] = createStore({
193-
limit: {} as Record<string, number>,
194-
cursor: {} as Record<string, string | undefined>,
195-
complete: {} as Record<string, boolean>,
196-
loading: {} as Record<string, boolean>,
197-
})
213+
optimistic.set(key, new Map([[item.message.id, { message: item.message, parts: sortParts(item.parts) }]]))
214+
}
198215

199-
const getSession = (sessionID: string) => {
200-
const store = current()[0]
201-
const match = Binary.search(store.session, sessionID, (s) => s.id)
202-
if (match.found) return store.session[match.index]
203-
return undefined
216+
const clearOptimistic = (directory: string, sessionID: string, messageID?: string) => {
217+
const key = keyFor(directory, sessionID)
218+
if (!messageID) {
219+
optimistic.delete(key)
220+
return
204221
}
205222

206-
const setOptimistic = (directory: string, sessionID: string, item: OptimisticItem) => {
207-
const key = keyFor(directory, sessionID)
208-
const list = optimistic.get(key)
209-
if (list) {
210-
list.set(item.message.id, { message: item.message, parts: sortParts(item.parts) })
211-
return
212-
}
213-
optimistic.set(key, new Map([[item.message.id, { message: item.message, parts: sortParts(item.parts) }]]))
214-
}
223+
const list = optimistic.get(key)
224+
if (!list) return
225+
list.delete(messageID)
226+
if (list.size === 0) optimistic.delete(key)
227+
}
215228

216-
const clearOptimistic = (directory: string, sessionID: string, messageID?: string) => {
217-
const key = keyFor(directory, sessionID)
218-
if (!messageID) {
219-
optimistic.delete(key)
220-
return
221-
}
229+
const getOptimistic = (directory: string, sessionID: string) => [
230+
...(optimistic.get(keyFor(directory, sessionID))?.values() ?? []),
231+
]
222232

223-
const list = optimistic.get(key)
224-
if (!list) return
225-
list.delete(messageID)
226-
if (list.size === 0) optimistic.delete(key)
233+
const seenFor = (directory: string) => {
234+
const existing = seen.get(directory)
235+
if (existing) {
236+
seen.delete(directory)
237+
seen.set(directory, existing)
238+
return existing
227239
}
228-
229-
const getOptimistic = (directory: string, sessionID: string) => [
230-
...(optimistic.get(keyFor(directory, sessionID))?.values() ?? []),
231-
]
232-
233-
const seenFor = (directory: string) => {
234-
const existing = seen.get(directory)
235-
if (existing) {
236-
seen.delete(directory)
237-
seen.set(directory, existing)
238-
return existing
239-
}
240-
const created = new Set<string>()
241-
seen.set(directory, created)
242-
while (seen.size > maxDirs) {
243-
const first = seen.keys().next().value
244-
if (!first) break
245-
const stale = [...(seen.get(first) ?? [])]
246-
seen.delete(first)
247-
const [, setStore] = globalSync.child(first, { bootstrap: false })
248-
evict(first, setStore, stale)
249-
}
250-
return created
240+
const created = new Set<string>()
241+
seen.set(directory, created)
242+
while (seen.size > maxDirs) {
243+
const first = seen.keys().next().value
244+
if (!first) break
245+
const stale = [...(seen.get(first) ?? [])]
246+
seen.delete(first)
247+
const [, setStore] = globalSync.child(first, { bootstrap: false })
248+
evict(first, setStore, stale)
251249
}
250+
return created
251+
}
252252

253-
const clearMeta = (directory: string, sessionIDs: string[]) => {
254-
if (sessionIDs.length === 0) return
255-
for (const sessionID of sessionIDs) {
256-
clearOptimistic(directory, sessionID)
257-
}
258-
setMeta(
259-
produce((draft) => {
260-
for (const sessionID of sessionIDs) {
261-
const key = keyFor(directory, sessionID)
262-
delete draft.limit[key]
263-
delete draft.cursor[key]
264-
delete draft.complete[key]
265-
delete draft.loading[key]
266-
}
267-
}),
268-
)
253+
const clearMeta = (directory: string, sessionIDs: string[]) => {
254+
if (sessionIDs.length === 0) return
255+
for (const sessionID of sessionIDs) {
256+
clearOptimistic(directory, sessionID)
269257
}
258+
setMeta(
259+
produce((draft) => {
260+
for (const sessionID of sessionIDs) {
261+
const key = keyFor(directory, sessionID)
262+
delete draft.limit[key]
263+
delete draft.cursor[key]
264+
delete draft.complete[key]
265+
delete draft.loading[key]
266+
}
267+
}),
268+
)
269+
}
270270

271-
const evict = (directory: string, setStore: Setter, sessionIDs: string[]) => {
271+
const evict = (directory: string, setStore: Setter, sessionIDs: string[]) => {
272272
if (sessionIDs.length === 0) return
273273
clearSessionPrefetch(directory, sessionIDs)
274274
for (const sessionID of sessionIDs) {
@@ -282,7 +282,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
282282
clearMeta(directory, sessionIDs)
283283
}
284284

285-
const touch = (directory: string, setStore: Setter, sessionID: string) => {
285+
const touch = (directory: string, setStore: Setter, sessionID: string) => {
286286
const stale = pickSessionCacheEvictions({
287287
seen: seenFor(directory),
288288
keep: sessionID,
@@ -291,7 +291,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
291291
evict(directory, setStore, stale)
292292
}
293293

294-
const fetchMessages = async (input: {
294+
const fetchMessages = async (input: {
295295
client: typeof sdk.client
296296
sessionID: string
297297
limit: number
@@ -312,9 +312,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
312312
}
313313
}
314314

315-
const tracked = (directory: string, sessionID: string) => seen.get(directory)?.has(sessionID) ?? false
315+
const tracked = (directory: string, sessionID: string) => seen.get(directory)?.has(sessionID) ?? false
316316

317-
const loadMessages = async (input: {
317+
const loadMessages = async (input: {
318318
directory: string
319319
client: typeof sdk.client
320320
setStore: Setter
@@ -368,26 +368,26 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
368368
})
369369
}
370370

371-
return {
372-
get data() {
373-
return current()[0]
374-
},
375-
get set(): Setter {
376-
return current()[1]
377-
},
378-
get status() {
379-
return current()[0].status
380-
},
381-
get ready() {
382-
return current()[0].status !== "loading"
383-
},
384-
get project() {
385-
const store = current()[0]
386-
const match = Binary.search(globalSync.data.project, store.project, (p) => p.id)
387-
if (match.found) return globalSync.data.project[match.index]
388-
return undefined
389-
},
390-
session: {
371+
return {
372+
get data() {
373+
return current()[0]
374+
},
375+
get set(): Setter {
376+
return current()[1]
377+
},
378+
get status() {
379+
return current()[0].status
380+
},
381+
get ready() {
382+
return current()[0].status !== "loading"
383+
},
384+
get project() {
385+
const store = current()[0]
386+
const match = Binary.search(globalSync.data.project, store.project, (p) => p.id)
387+
if (match.found) return globalSync.data.project[match.index]
388+
return undefined
389+
},
390+
session: {
391391
get: getSession,
392392
optimistic: {
393393
add(input: { directory?: string; sessionID: string; message: Message; parts: Part[] }) {
@@ -614,10 +614,19 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
614614
)
615615
},
616616
},
617-
absolute,
618-
get directory() {
619-
return current()[0].path.directory
620-
},
621-
}
617+
absolute,
618+
get directory() {
619+
return current()[0].path.directory
620+
},
621+
}
622+
}
623+
624+
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
625+
name: "Sync",
626+
init: () => {
627+
return createSyncContextValue({
628+
globalSync: useGlobalSync(),
629+
sdk: useSDK(),
630+
})
622631
},
623632
})

0 commit comments

Comments
 (0)