Skip to content

Commit c78a0ba

Browse files
committed
fix(tui): serialize session hydration
1 parent df2731b commit c78a0ba

3 files changed

Lines changed: 44 additions & 162 deletions

File tree

packages/tui/src/context/data.tsx

Lines changed: 15 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -121,14 +121,14 @@ function createSync() {
121121
}
122122
}
123123

124-
// Apply local admissions and promotions that arrived while the server snapshot was loading.
125-
function reconcilePendingInputs(baseline: string[], current: string[], server: string[]) {
126-
const result = new Set(server)
127-
const before = new Set(baseline)
128-
const now = new Set(current)
129-
baseline.filter((id) => !now.has(id)).forEach((id) => result.delete(id))
130-
current.filter((id) => !before.has(id)).forEach((id) => result.add(id))
131-
return [...result]
124+
// Apply admissions and promotions that arrived while the pending snapshot was loading.
125+
function reconcilePending(baseline: SessionPendingInfo[], current: SessionPendingInfo[], server: SessionPendingInfo[]) {
126+
const result = new Map(server.map((item) => [item.id, item]))
127+
const before = new Set(baseline.map((item) => item.id))
128+
const now = new Map(current.map((item) => [item.id, item]))
129+
baseline.filter((item) => !now.has(item.id)).forEach((item) => result.delete(item.id))
130+
current.filter((item) => !before.has(item.id)).forEach((item) => result.set(item.id, item))
131+
return [...result.values()]
132132
}
133133

134134
export const { use: useData, provider: DataProvider } = createSimpleContext({
@@ -961,7 +961,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
961961
},
962962
sync(sessionID: string) {
963963
return sync.run(`session.pending:${sessionID}`, async () => {
964-
const pending = await client.api.session.pending.list({ sessionID })
964+
const baseline = [...(store.session.pending[sessionID] ?? [])]
965+
const server = await client.api.session.pending.list({ sessionID })
966+
const pending = reconcilePending(baseline, store.session.pending[sessionID] ?? [], server)
965967
setStore("session", "pending", sessionID, reconcile(pending))
966968
setStore(
967969
"session",
@@ -995,32 +997,17 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
995997
},
996998
sync(sessionID: string) {
997999
return sync.run(`session.message:${sessionID}`, async () => {
998-
const localInputs = store.session.input[sessionID] ?? []
999-
const [projected, pending] = await Promise.all([
1000-
client.api.message.list({ sessionID, limit: 200, order: "desc" }),
1001-
client.api.session.pending.list({ sessionID }),
1002-
])
1003-
const pendingMessages = pending.map(message.fromPending)
1000+
const localInputs = [...(store.session.input[sessionID] ?? [])]
1001+
const pendingMessages = [...(store.session.pending[sessionID] ?? [])].map(message.fromPending)
1002+
const projected = await client.api.message.list({ sessionID, limit: 200, order: "desc" })
10041003
const next = projected.data.toReversed()
10051004
const index = new Map(next.map((message, index) => [message.id, index]))
1006-
const localInputIDs = new Set(localInputs)
1005+
const localInputIDs = new Set([...localInputs, ...(store.session.input[sessionID] ?? [])])
10071006
store.session.message[sessionID]
10081007
?.filter((message) => localInputIDs.has(message.id))
10091008
.forEach((item) => message.append(next, index, item))
10101009
pendingMessages.forEach((item) => message.append(next, index, item))
10111010
messageIndex.set(sessionID, index)
1012-
setStore(
1013-
"session",
1014-
"input",
1015-
sessionID,
1016-
reconcilePendingInputs(
1017-
localInputs,
1018-
store.session.input[sessionID] ?? [],
1019-
pendingMessages.flatMap((message) =>
1020-
message.type === "user" || message.type === "synthetic" ? [message.id] : [],
1021-
),
1022-
),
1023-
)
10241011
setStore("session", "message", sessionID, reconcile(next))
10251012
})
10261013
},

packages/tui/src/routes/session/rows.ts

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -73,14 +73,16 @@ export function createSessionRows(sessionID: Accessor<string>) {
7373
on([sessionID, () => client.connection.status()], ([id, status]) => {
7474
if (status !== "connected") return
7575
setRows(reconcile(reduce()))
76-
void data.session.pending.sync(id).catch(() => undefined)
77-
void data.session.message.sync(id).then(
78-
() => {
79-
if (sessionID() !== id) return
80-
setRows(reconcile(reduce()))
81-
},
82-
() => undefined,
83-
)
76+
void data.session.pending
77+
.sync(id)
78+
.then(() => data.session.message.sync(id))
79+
.then(
80+
() => {
81+
if (sessionID() !== id) return
82+
setRows(reconcile(reduce()))
83+
},
84+
() => undefined,
85+
)
8486
}),
8587
)
8688

packages/tui/test/cli/tui/data.test.tsx

Lines changed: 19 additions & 126 deletions
Original file line numberDiff line numberDiff line change
@@ -2468,7 +2468,7 @@ test("preserves admitted prompts when hydration races with promotion", async ()
24682468
])
24692469
expect(sync.session.input.list(sessionID)).toEqual([messageID])
24702470

2471-
const refresh = sync.session.message.sync(sessionID)
2471+
const refresh = sync.session.pending.sync(sessionID).then(() => sync.session.message.sync(sessionID))
24722472
await requested.promise
24732473
emitEvent(events, {
24742474
id: "evt_prompted_1",
@@ -2504,31 +2504,21 @@ test("preserves admitted prompts when hydration races with promotion", async ()
25042504
}
25052505
})
25062506

2507-
test("preserves an admitted prompt when hydration races with promotion", async () => {
2507+
test("preserves admissions that arrive while pending work hydrates", async () => {
25082508
const events = createEventStream()
2509-
const sessionID = "session-hydration-race"
2510-
const messageID = "msg_initial_user"
2511-
let resolveMessages!: (response: Response) => void
2512-
let requestMessages!: () => void
2513-
const requested = new Promise<void>((resolve) => {
2514-
requestMessages = resolve
2515-
})
2509+
const sessionID = "session-pending-race"
2510+
const messageID = "msg_late_user"
2511+
const requested = Promise.withResolvers<void>()
2512+
const response = Promise.withResolvers<Response>()
25162513
const calls = createFetch((url) => {
2517-
if (url.pathname !== `/api/session/${sessionID}/message`) return
2518-
requestMessages()
2519-
return new Promise<Response>((resolve) => {
2520-
resolveMessages = resolve
2521-
})
2514+
if (url.pathname !== `/api/session/${sessionID}/pending`) return
2515+
requested.resolve()
2516+
return response.promise
25222517
}, events)
25232518
let data!: ReturnType<typeof useData>
2524-
let ready!: () => void
2525-
const mounted = new Promise<void>((resolve) => {
2526-
ready = resolve
2527-
})
25282519

25292520
function Probe() {
25302521
data = useData()
2531-
onMount(ready)
25322522
return <box />
25332523
}
25342524

@@ -2545,129 +2535,32 @@ test("preserves an admitted prompt when hydration races with promotion", async (
25452535
))
25462536

25472537
try {
2548-
await mounted
2549-
emitEvent(events, {
2550-
id: "evt_initial_admitted",
2551-
created: 1,
2552-
type: "session.input.admitted",
2553-
durable: durable(sessionID),
2554-
data: {
2555-
sessionID,
2556-
inputID: messageID,
2557-
input: { type: "user", data: { text: "Initial prompt" }, delivery: "steer" },
2558-
},
2559-
})
2560-
await wait(() => data.session.message.get(sessionID, messageID)?.type === "user")
2561-
2562-
const refresh = data.session.message.sync(sessionID)
2563-
await requested
2564-
emitEvent(events, {
2565-
id: "evt_initial_promoted",
2566-
created: 2,
2567-
type: "session.input.promoted",
2568-
durable: durable(sessionID, 1),
2569-
data: { sessionID, inputID: messageID },
2570-
})
2571-
await wait(() => data.session.input.list(sessionID).length === 0)
2572-
2573-
resolveMessages(json({ data: [], cursor: {} }))
2574-
await refresh
2575-
2576-
expect(data.session.message.get(sessionID, messageID)).toMatchObject({
2577-
id: messageID,
2578-
type: "user",
2579-
text: "Initial prompt",
2580-
time: { created: 2 },
2581-
})
2582-
} finally {
2583-
app.renderer.destroy()
2584-
}
2585-
})
2586-
2587-
/*
2588-
test("preserves an admitted prompt when hydration races with promotion", async () => {
2589-
const events = createEventStream()
2590-
const sessionID = "session-hydration-race"
2591-
const messageID = "msg_initial_user"
2592-
let resolveMessages!: (response: Response) => void
2593-
let requestMessages!: () => void
2594-
const requested = new Promise<void>((resolve) => {
2595-
requestMessages = resolve
2596-
})
2597-
const calls = createFetch((url) => {
2598-
if (url.pathname !== `/api/session/${sessionID}/message`) return
2599-
requestMessages()
2600-
return new Promise<Response>((resolve) => {
2601-
resolveMessages = resolve
2602-
})
2603-
}, events)
2604-
let data!: ReturnType<typeof useData>
2605-
let ready!: () => void
2606-
const mounted = new Promise<void>((resolve) => {
2607-
ready = resolve
2608-
})
2609-
2610-
function Probe() {
2611-
data = useData()
2612-
onMount(ready)
2613-
return <box />
2614-
}
2615-
2616-
const app = await testRender(() => (
2617-
<TestTuiContexts>
2618-
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
2619-
<ProjectProvider>
2620-
<DataProvider>
2621-
<Probe />
2622-
</DataProvider>
2623-
</ProjectProvider>
2624-
</SDKProvider>
2625-
</TestTuiContexts>
2626-
))
2627-
2628-
try {
2629-
await mounted
2538+
const sync = data.session.pending.sync(sessionID)
2539+
await requested.promise
26302540
emitEvent(events, {
2631-
id: "evt_initial_admitted",
2541+
id: "evt_late_admitted",
26322542
created: 1,
26332543
type: "session.input.admitted",
26342544
durable: durable(sessionID),
26352545
data: {
26362546
sessionID,
26372547
inputID: messageID,
2638-
input: { type: "user", data: { text: "Initial prompt" }, delivery: "steer" },
2548+
input: { type: "user", data: { text: "late" }, delivery: "steer" },
26392549
},
26402550
})
2641-
await wait(() => data.session.message.get(sessionID, messageID)?.type === "user")
2642-
2643-
const refresh = data.session.message.refresh(sessionID)
2644-
await requested
2645-
emitEvent(events, {
2646-
id: "evt_initial_promoted",
2647-
created: 2,
2648-
type: "session.input.promoted",
2649-
durable: durable(sessionID, 1),
2650-
data: { sessionID, inputID: messageID },
2651-
})
2652-
await wait(() => data.session.input.list(sessionID).length === 0)
2653-
2654-
resolveMessages(json({ data: [], cursor: {} }))
2655-
await refresh
2551+
await wait(() => data.session.pending.list(sessionID).length === 1)
2552+
response.resolve(json({ data: [] }))
2553+
await sync
26562554

2657-
expect(data.session.message.get(sessionID, messageID)).toMatchObject({
2658-
id: messageID,
2659-
type: "user",
2660-
text: "Initial prompt",
2661-
time: { created: 2 },
2662-
})
2555+
expect(data.session.pending.list(sessionID).map((item) => item.id)).toEqual([messageID])
2556+
expect(data.session.input.list(sessionID)).toEqual([messageID])
2557+
expect(data.session.message.get(sessionID, messageID)).toMatchObject({ text: "late" })
26632558
} finally {
26642559
app.renderer.destroy()
26652560
}
26662561
})
26672562

26682563
test("skips initial instruction state and projects later updates with their message ID", async () => {
2669-
*/
2670-
test("projects live instruction updates with their message ID", async () => {
26712564
const events = createEventStream()
26722565
const calls = createFetch(undefined, events)
26732566
let sync!: ReturnType<typeof useData>

0 commit comments

Comments
 (0)