mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-21 18:26:09 +00:00
fix(tui): preserve part collection identity across sync
This commit is contained in:
@@ -955,7 +955,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
await client.api.message.list({ sessionID, limit: 200, order: "desc" })
|
||||
).data.toReversed()
|
||||
messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index])))
|
||||
content.drop(sessionID)
|
||||
content.prune(sessionID, new Set(messages.map((message) => message.id)))
|
||||
for (const item of messages) {
|
||||
if (item.type === "assistant") content.seed(sessionID, item.id, item.content)
|
||||
}
|
||||
|
||||
@@ -86,6 +86,18 @@ export namespace SessionContent {
|
||||
if (key.startsWith(prefix)) collections.delete(key)
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Drop collections for messages absent from a refetched snapshot.
|
||||
* Present messages must be reseeded in place, never dropped: mounted
|
||||
* views hold their collection reference for their whole lifetime, so
|
||||
* replacing the object would orphan their subscriptions.
|
||||
*/
|
||||
prune(sessionID: string, keep: ReadonlySet<string>) {
|
||||
const prefix = `${sessionID}\u0000`
|
||||
for (const key of [...collections.keys()]) {
|
||||
if (key.startsWith(prefix) && !keep.has(key.slice(prefix.length))) collections.delete(key)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -973,6 +973,102 @@ test("publishes streaming deltas to one part slot without touching siblings", as
|
||||
}
|
||||
})
|
||||
|
||||
test("keeps part slot references alive across message sync", async () => {
|
||||
const events = createEventStream()
|
||||
const sessionID = "session-slot-identity"
|
||||
// The refetch snapshot is identical to the streamed state, so any failure
|
||||
// here is collection identity loss, not snapshot staleness.
|
||||
let snapshot: unknown[] = []
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: snapshot, cursor: {} })
|
||||
}, events)
|
||||
let data!: ReturnType<typeof useData>
|
||||
let client!: ReturnType<typeof useClient>
|
||||
|
||||
function Probe() {
|
||||
data = useData()
|
||||
client = useClient()
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<TestTuiContexts>
|
||||
<ClientProvider api={createApi(calls.fetch)}>
|
||||
<ProjectProvider>
|
||||
<DataProvider>
|
||||
<Probe />
|
||||
</DataProvider>
|
||||
</ProjectProvider>
|
||||
</ClientProvider>
|
||||
</TestTuiContexts>
|
||||
))
|
||||
|
||||
try {
|
||||
await wait(() => client.connection.status() === "connected")
|
||||
emitEvent(events, {
|
||||
id: "evt_identity_step",
|
||||
created: 1,
|
||||
type: "session.step.started",
|
||||
durable: durable(sessionID),
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID: "message-assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
},
|
||||
})
|
||||
emitEvent(events, {
|
||||
id: "evt_identity_text",
|
||||
created: 2,
|
||||
type: "session.text.started",
|
||||
durable: durable(sessionID, 1),
|
||||
data: { sessionID, assistantMessageID: "message-assistant", ordinal: 0 },
|
||||
})
|
||||
emitEvent(events, {
|
||||
id: "evt_identity_ended",
|
||||
created: 3,
|
||||
type: "session.text.ended",
|
||||
durable: durable(sessionID, 2),
|
||||
data: { sessionID, assistantMessageID: "message-assistant", ordinal: 0, text: "hello world" },
|
||||
})
|
||||
// A mounted view captures the collection once, at useSlot setup.
|
||||
const mounted = data.session.message.parts(sessionID, "message-assistant")
|
||||
await wait(() => {
|
||||
const part = mounted.get("text:0")?.()
|
||||
return part?.type === "text" && part.text === "hello world"
|
||||
})
|
||||
|
||||
snapshot = [
|
||||
{
|
||||
id: "message-assistant",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: [{ type: "text", text: "hello world" }],
|
||||
time: { created: 1 },
|
||||
},
|
||||
]
|
||||
await data.session.message.sync(sessionID)
|
||||
|
||||
// The registry must hand back the same collection the view is holding.
|
||||
expect(data.session.message.parts(sessionID, "message-assistant")).toBe(mounted)
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_identity_delta",
|
||||
created: 4,
|
||||
type: "session.text.delta",
|
||||
data: { sessionID, assistantMessageID: "message-assistant", ordinal: 0, delta: "!!" },
|
||||
})
|
||||
// The delta must reach the reference a mounted view is subscribed to.
|
||||
await wait(() => {
|
||||
const part = mounted.get("text:0")?.()
|
||||
return part?.type === "text" && part.text === "hello world!!"
|
||||
})
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("does not publish timeline rows for duplicate streaming deltas", async () => {
|
||||
const events = createEventStream()
|
||||
const sessionID = "session-stream-metrics"
|
||||
|
||||
Reference in New Issue
Block a user