/** * 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 }) { 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) }) })