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:
2026-08-20 22:10:43 +07:00
parent ed152416d5
commit d2ad46b8aa
8 changed files with 266 additions and 5 deletions

View File

@@ -476,6 +476,11 @@ export class SessionManager {
if (session === undefined) continue
session.handleBlank(s.blank)
session.handleRunning(s.running)
// A durable session the host disposed earlier being re-listed is
// alive again: lift the removed tombstone so the surviving
// instance renders normally (mutation replay already excluded a
// genuinely-removed session from this.summaries).
session.handleRevived()
}
// Seed each row's projection baseline into the per-session value
// store (cold titles surface without opening the session). Per-key
@@ -804,6 +809,10 @@ export class SessionManager {
...(frame.agentPreset !== undefined ? { agentPreset: frame.agentPreset } : {}),
})
this.sessions.get(frame.sessionId)?.handleBlank(frame.blank)
// A re-emitted id (host re-engaging a previously disposed session)
// revives the surviving instance: the removed tombstone must not keep
// the conversation (and its projection readouts) in the removed state.
this.sessions.get(frame.sessionId)?.handleRevived()
if (frame.origin === 'subagent' && frame.parentSessionId !== undefined) {
this.markCatalogParentExpandable(frame.parentSessionId)
}
@@ -834,7 +843,12 @@ export class SessionManager {
// no relative order. Clearing here makes a detached Activation's rows
// disappear whichever arrives first.
this.jobsBySession.delete(frame.sessionId)
if (!durableSubagent) this.projectionStores.delete(frame.sessionId)
// The projection store is retained: the Session instance survives
// removal (resident-instance rule — handleRemoved above only flags the
// snapshot), and a session the host re-lists resumes its projections
// through the same instance. Deleting the store here would leave that
// surviving instance with an empty projection face until a fresh
// baseline lands — and open() is idempotent, so no re-open re-pulls it.
// A pull already in flight was requested before this removal and can
// carry the pre-removal parentAvailable:true, which would resurrect
// the writable editor this invalidation just closed. Replay false over

View File

@@ -577,6 +577,18 @@ export class Session implements SessionFace {
this.notifier.markDirty()
}
/**
* host/session-added or host re-listing relay: the session is alive again,
* so lift the removed tombstone. The instance survives removal, and a
* removed-then-re-listed session must render its conversation again (the
* composer and its projection readouts) rather than the removed state.
*/
handleRevived(): void {
if (!this.removed) return
this.removed = false
this.notifier.markDirty()
}
/**
* host/agent-error relay: the only outlet for live failures with no turn position.
* @param message - the stringified error.

View File

@@ -190,7 +190,7 @@ describe('list lifecycle', () => {
expect(manager.getListSnapshot().items.map(i => i.sessionId)).toEqual([S2])
})
it('retains title projections before list arrival, keeps last-wins by seq, and clears them on removal', async () => {
it('retains title projections before list arrival, keeps last-wins by seq, and survives removal', async () => {
const api = new FakeApiClient()
const manager = new SessionManager(api, fakeRemote())
const titleFrame = (rpcId: string, title: string, seq: number) => {
@@ -212,9 +212,13 @@ describe('list lifecycle', () => {
expect(titled.items[0]?.title).toBe('Newest')
expect(titled.items[1]?.title).toBeUndefined()
// A removed-then-re-added session is the same durable lifecycle under the
// resident-instance rule: its projection face (here the title) survives
// the round-trip, so the row resurfacing shows the retained value rather
// than re-seeding from an empty store.
manager.handleHostEnvelope({ rpcId: 'removed' as never, payload: { type: 'host/session-removed', sessionId: S1 } })
manager.handleHostEnvelope({ rpcId: 'readded' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } })
expect(manager.getListSnapshot().items.find(item => item.sessionId === S1)?.title).toBeUndefined()
expect(manager.getListSnapshot().items.find(item => item.sessionId === S1)?.title).toBe('Newest')
})
it('seeds cold titles from the list rows\' projections block under higher-seq-wins', async () => {

View File

@@ -209,7 +209,7 @@ describe('manager frame routing', () => {
expect(manager.getListSnapshot().items[0]?.projectionValues).not.toBe(baseline)
})
it('drops the projection store with the removed session', async () => {
it('retains the projection store with the removed session (the instance survives removal)', async () => {
const api = new FakeApiClient()
const manager = new SessionManager(api, fakeRemote())
api.onList = () => Promise.resolve(ok({
@@ -224,6 +224,9 @@ describe('manager frame routing', () => {
rpcId: 'rm' as never,
payload: { type: 'host/session-removed', sessionId: sid('s1') } as never,
})
expect(manager.get(sid('s1')).projections.get('title')).toBeUndefined()
// The Session instance survives removal (resident-instance rule), so its
// projection face survives with it: a re-listed session resumes its
// values instead of re-seeding from an empty face.
expect(manager.get(sid('s1')).projections.get('title')).toBe('Doomed')
})
})

View 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)
})
})