fix: keep client session projection stores across removal and revival
The per-session ProjectionValueStore was deleted on host/session-removed even though the Session instance survives removal (open() is idempotent and drop() only removes the map entry). A re-added or re-listed session resumed on the same instance with an empty projection face, so projection-backed UI (such as the OpenRouter cost readout) reverted to blank until a fresh host baseline landed. Retain the store on removal and lift the removed tombstone from the surviving instance on revival (host/session-added and list-refresh re-listing), so chat switching keeps cost readouts populated.
This commit is contained in:
147
packages/client/runtime/tests/switch-retention.client.spec.ts
Normal file
147
packages/client/runtime/tests/switch-retention.client.spec.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Repro + behavioral pin for the chat-switch projection retention contract:
|
||||
* a session's projection store (openRouterCost is the canonical example) must
|
||||
* survive a selection change and remain readable through the same value face
|
||||
* when the session becomes current again.
|
||||
*
|
||||
* Server frames exercised:
|
||||
* - session.list rows carrying a projections block (the cold seed path)
|
||||
* - session/projection push frames to a live session
|
||||
* - host/session-removed (transient host teardown) + host/session-added
|
||||
* - session/subscribed (durable-replay baseline) + a re-pulled history tail
|
||||
*
|
||||
* The manager's per-session ProjectionValueStore is retained across select();
|
||||
* this suite pins the retention contract so a regression in the
|
||||
* retention/seeding discipline shows up as a red test here.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { SessionManager } from '../src/client/sessions/manager.ts'
|
||||
import { FakeApiClient, fakeRemote, ok } from './fake-api.client.ts'
|
||||
|
||||
const S1 = 'fk-s1' as SessionId
|
||||
const S2 = 'fk-s2' as SessionId
|
||||
|
||||
const COST = { totalUsd: 1.23, pricedSteps: 1, unknownModelSteps: 0, currency: 'USD' as const }
|
||||
|
||||
function summary(sessionId: SessionId, projections?: { asOfSeq: number; values: Record<string, unknown> }) {
|
||||
return {
|
||||
sessionId,
|
||||
updatedAt: 100,
|
||||
running: false,
|
||||
blank: false,
|
||||
...(projections === undefined ? {} : { projections }),
|
||||
} as never
|
||||
}
|
||||
|
||||
describe('projection retention across chat switches (repro)', () => {
|
||||
it('retains the projection store for a session that was opened, seeded, and switched away from and back', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2)] }))
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
await manager.refreshList()
|
||||
|
||||
// A live push frame lands the cost in S1's resident store.
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'seed' as never,
|
||||
payload: { type: 'session/projection', sessionId: S1, key: 'openRouterCost', value: COST, seq: 5 } as never,
|
||||
})
|
||||
expect(manager.getListSnapshot().items[0]?.projectionValues?.openRouterCost).toEqual(COST)
|
||||
|
||||
// The conversation switches to another chat and back; nothing removes S1.
|
||||
manager.select(S2)
|
||||
manager.select(S1)
|
||||
expect(manager.getListSnapshot().items[0]?.projectionValues?.openRouterCost).toEqual(COST)
|
||||
})
|
||||
|
||||
it('a host/session-removed disposal followed by a re-add re-seeds the cost from the list row block', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onList = () => Promise.resolve(ok({
|
||||
items: [
|
||||
summary(S1, { asOfSeq: 6, values: { openRouterCost: COST } }),
|
||||
summary(S2),
|
||||
],
|
||||
}))
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
await manager.refreshList()
|
||||
expect(manager.getListSnapshot().items.find(item => item.sessionId === S1)?.projectionValues?.openRouterCost)
|
||||
.toEqual(COST)
|
||||
|
||||
// Transient host teardown: the chat leaves the list; its projection store
|
||||
// is retained (the Session instance survives removal), and the durable
|
||||
// chat still exists host-side, so a list refresh re-adds it.
|
||||
manager.handleHostEnvelope({
|
||||
rpcId: 'rm' as never,
|
||||
payload: { type: 'host/session-removed', sessionId: S1 } as never,
|
||||
})
|
||||
expect(manager.getListSnapshot().items.find(item => item.sessionId === S1)).toBeUndefined()
|
||||
|
||||
await manager.refreshList()
|
||||
expect(manager.getListSnapshot().items.find(item => item.sessionId === S1)?.projectionValues?.openRouterCost)
|
||||
.toEqual(COST)
|
||||
})
|
||||
|
||||
it('lifts the removed tombstone when a disposed session is re-listed by the host', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2)] }))
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
await manager.refreshList()
|
||||
|
||||
const session = manager.get(S1)
|
||||
manager.handleHostEnvelope({
|
||||
rpcId: 'rm' as never,
|
||||
payload: { type: 'host/session-removed', sessionId: S1 } as never,
|
||||
})
|
||||
expect(session.getSnapshot().removed).toBe(true)
|
||||
// The same instance survives (resident-instance rule).
|
||||
expect(manager.get(S1)).toBe(session)
|
||||
|
||||
// The durable chat comes back: the re-listing revives the surviving
|
||||
// instance rather than leaving the conversation tombstoned forever.
|
||||
manager.handleHostEnvelope({
|
||||
rpcId: 'add' as never,
|
||||
payload: { type: 'host/session-added', blank: false, sessionId: S1 } as never,
|
||||
})
|
||||
expect(session.getSnapshot().removed).toBe(false)
|
||||
})
|
||||
|
||||
it('revives through a list refresh when the host re-lists a previously disposed session', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const listed = [summary(S1), summary(S2)]
|
||||
api.onList = () => Promise.resolve(ok({ items: listed }))
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
await manager.refreshList()
|
||||
|
||||
const session = manager.get(S1)
|
||||
manager.handleHostEnvelope({
|
||||
rpcId: 'rm' as never,
|
||||
payload: { type: 'host/session-removed', sessionId: S1 } as never,
|
||||
})
|
||||
expect(session.getSnapshot().removed).toBe(true)
|
||||
|
||||
// The host never re-emits host/session-added for an existing id; the
|
||||
// durable session simply appears again on the next list pull.
|
||||
await manager.refreshList()
|
||||
expect(session.getSnapshot().removed).toBe(false)
|
||||
expect(manager.getListSnapshot().items.find(item => item.sessionId === S1)).toBeDefined()
|
||||
})
|
||||
|
||||
it('survives a subscribed durable-replay baseline when the value is at or before the baseline', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onList = () => Promise.resolve(ok({ items: [summary(S1)] }))
|
||||
const manager = new SessionManager(api, fakeRemote())
|
||||
await manager.refreshList()
|
||||
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'push' as never,
|
||||
payload: { type: 'session/projection', sessionId: S1, key: 'openRouterCost', value: COST, seq: 3 } as never,
|
||||
})
|
||||
|
||||
// The durable baseline is at seq 3, so the row is not phantom: it survives.
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'sub' as never,
|
||||
payload: { type: 'session/subscribed', sessionId: S1, lastSeq: 3 } as never,
|
||||
})
|
||||
expect(manager.getListSnapshot().items[0]?.projectionValues?.openRouterCost).toEqual(COST)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user