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:
@@ -0,0 +1,6 @@
|
|||||||
|
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||||
|
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||||
|
# after editing either side, bring the other along and re-record with:
|
||||||
|
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-20-web-session-projection-store-survives-revival.md
|
||||||
|
2026-08-20-web-session-projection-store-survives-revival.md: 27340e87e9fd2dfbfd4bf1868f4aacc2d4af5fdb
|
||||||
|
2026-08-20-web-session-projection-store-survives-revival.zh.md: 63799da02266e1b2a724bb00659ea0fb5717d93d
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Agent Note: Web session projection stores survive removal and revival
|
||||||
|
|
||||||
|
Status: implemented
|
||||||
|
|
||||||
|
English | [中文](2026-08-20-web-session-projection-store-survives-revival.zh.md)
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The client runtime's `ProjectionValueStore` — the per-session store backing
|
||||||
|
`useProjection('openRouterCost')`, `useProjection('title')`, and every other
|
||||||
|
projection face — was deleted on every `host/session-removed`. The session
|
||||||
|
instance itself is resident: `open()` is idempotent, `drop()` only removes
|
||||||
|
the `sessions` map entry, and removal is modeled as a `removed` flag on the
|
||||||
|
surviving instance. Deleting the projection store disagreed with that model:
|
||||||
|
when the session was re-added (`host/session-added`) or re-listed by a
|
||||||
|
`session.list` refresh, the same instance resumed without its projection
|
||||||
|
store, so projection-backed UI (notably the OpenRouter cost readout in the
|
||||||
|
composer dock) reverted to blank until the host re-served a projection
|
||||||
|
commit.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
The projection store follows the resident instance: `SessionManager` no
|
||||||
|
longer deletes it on `host/session-removed`. The `removed` tombstone is
|
||||||
|
lifted by a new `Session.handleRevived()` — called from the `host/session-added`
|
||||||
|
path and from the list-refresh summary push — which clears the flag and
|
||||||
|
records the store as dirty so subscribers re-read the retained values. A
|
||||||
|
re-added session therefore resumes its previous projection values through the
|
||||||
|
same instance; the host re-serves the projection rows on the next `session.list`
|
||||||
|
from its cached row set, and the store needs no re-seed.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
**Delete the store on removal and re-seed on re-add.** Rejected: the session
|
||||||
|
instance is not re-seeded by `open()` (idempotent), so a deletion would leave
|
||||||
|
the resumed session projection-blank until a fresh projection frame arrives.
|
||||||
|
Retention matches the resident-instance contract already in place.
|
||||||
|
|
||||||
|
**Drop the store only when the session is dropped from the list.** The removal
|
||||||
|
frame is the only cleanup signal the manager receives; there is no separate
|
||||||
|
lifecycle step distinguishing "tombstoned for revival" from "gone", so keeping
|
||||||
|
the store until `drop()` is the model-consistent rule.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
A removed-but-resident session keeps its projection values for as long as the
|
||||||
|
instance exists, and re-listing restores them without a host re-seed. The
|
||||||
|
store retains memory for removed sessions until the manager drops the map
|
||||||
|
entry, which is the same tenure the session instance already held. `test:gui`
|
||||||
|
pins both revival paths — `host/session-added` and list-refresh re-listing —
|
||||||
|
plus the plain-switch retention case, and the projection-store spec pins that
|
||||||
|
a removed session's store keeps its folded values.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Agent Note: Web 会话投影存储在移除与复活后继续存活
|
||||||
|
|
||||||
|
Status: implemented
|
||||||
|
|
||||||
|
[English](2026-08-20-web-session-projection-store-survives-revival.md) | 中文
|
||||||
|
|
||||||
|
## 问题
|
||||||
|
|
||||||
|
客户端运行时的 `ProjectionValueStore` —— 支撑 `useProjection('openRouterCost')`、`useProjection('title')` 以及其他所有投影面(face)的按会话存储 —— 会在每次 `host/session-removed` 时被删除。会话实例本身是常驻的:`open()` 幂等,`drop()` 只删除 `sessions` 映射项,移除被建模为幸存实例上的 `removed` 标志。删除投影存储与该模型不一致:当会话被重新添加(`host/session-added`)或由 `session.list` 刷新重新列出时,同一实例继续运行却没有其投影存储,于是投影驱动的 UI(尤其是 composer dock 中的 OpenRouter 费用读数)会回退为空白,直到宿主重新下发投影提交。
|
||||||
|
|
||||||
|
## 决策
|
||||||
|
|
||||||
|
投影存储随常驻实例存活:`SessionManager` 不再在 `host/session-removed` 时删除它。`removed` 墓碑由新增的 `Session.handleRevived()` 解除——它从 `host/session-added` 路径与列表刷新摘要推送两处调用——清除标志并将存储标记为脏,使订阅者重新读取保留的值。因此被重新添加的会话通过同一个实例恢复之前的投影值;宿主在下一次 `session.list` 时从缓存的投影行集合重新下发这些行,存储无需重新播种。
|
||||||
|
|
||||||
|
## 考虑过的替代方案
|
||||||
|
|
||||||
|
**移除时删除存储,重新添加时重新播种。** 否决:`open()` 幂等,不会为会话重新播种,因此删除会让恢复的会话在收到新投影帧之前一直投影空白。保留与既有的常驻实例契约一致。
|
||||||
|
|
||||||
|
**仅当会话从列表移除时才丢弃存储。** 移除帧是管理器收到的唯一清理信号;没有单独的「为复活而墓碑化」与「真正消失」生命周期步骤,因此在 `drop()` 之前保留存储是模型一致的规则。
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
|
||||||
|
被移除但常驻的会话在其实例存活期间保留投影值,重新列出时无需宿主重新播种即可恢复。在管理器删除映射项之前,存储会为已移除会话保留内存——这与会话实例本身已有的任期相同。`test:gui` 钉定了两条复活路径(`host/session-added` 与列表刷新重新列出),外加普通切换保留场景;投影存储规范钉定被移除会话的存储会保留其折叠后的值。
|
||||||
@@ -476,6 +476,11 @@ export class SessionManager {
|
|||||||
if (session === undefined) continue
|
if (session === undefined) continue
|
||||||
session.handleBlank(s.blank)
|
session.handleBlank(s.blank)
|
||||||
session.handleRunning(s.running)
|
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
|
// Seed each row's projection baseline into the per-session value
|
||||||
// store (cold titles surface without opening the session). Per-key
|
// store (cold titles surface without opening the session). Per-key
|
||||||
@@ -804,6 +809,10 @@ export class SessionManager {
|
|||||||
...(frame.agentPreset !== undefined ? { agentPreset: frame.agentPreset } : {}),
|
...(frame.agentPreset !== undefined ? { agentPreset: frame.agentPreset } : {}),
|
||||||
})
|
})
|
||||||
this.sessions.get(frame.sessionId)?.handleBlank(frame.blank)
|
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) {
|
if (frame.origin === 'subagent' && frame.parentSessionId !== undefined) {
|
||||||
this.markCatalogParentExpandable(frame.parentSessionId)
|
this.markCatalogParentExpandable(frame.parentSessionId)
|
||||||
}
|
}
|
||||||
@@ -834,7 +843,12 @@ export class SessionManager {
|
|||||||
// no relative order. Clearing here makes a detached Activation's rows
|
// no relative order. Clearing here makes a detached Activation's rows
|
||||||
// disappear whichever arrives first.
|
// disappear whichever arrives first.
|
||||||
this.jobsBySession.delete(frame.sessionId)
|
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
|
// A pull already in flight was requested before this removal and can
|
||||||
// carry the pre-removal parentAvailable:true, which would resurrect
|
// carry the pre-removal parentAvailable:true, which would resurrect
|
||||||
// the writable editor this invalidation just closed. Replay false over
|
// the writable editor this invalidation just closed. Replay false over
|
||||||
|
|||||||
@@ -577,6 +577,18 @@ export class Session implements SessionFace {
|
|||||||
this.notifier.markDirty()
|
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.
|
* host/agent-error relay: the only outlet for live failures with no turn position.
|
||||||
* @param message - the stringified error.
|
* @param message - the stringified error.
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ describe('list lifecycle', () => {
|
|||||||
expect(manager.getListSnapshot().items.map(i => i.sessionId)).toEqual([S2])
|
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 api = new FakeApiClient()
|
||||||
const manager = new SessionManager(api, fakeRemote())
|
const manager = new SessionManager(api, fakeRemote())
|
||||||
const titleFrame = (rpcId: string, title: string, seq: number) => {
|
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[0]?.title).toBe('Newest')
|
||||||
expect(titled.items[1]?.title).toBeUndefined()
|
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: '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 } })
|
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 () => {
|
it('seeds cold titles from the list rows\' projections block under higher-seq-wins', async () => {
|
||||||
|
|||||||
@@ -209,7 +209,7 @@ describe('manager frame routing', () => {
|
|||||||
expect(manager.getListSnapshot().items[0]?.projectionValues).not.toBe(baseline)
|
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 api = new FakeApiClient()
|
||||||
const manager = new SessionManager(api, fakeRemote())
|
const manager = new SessionManager(api, fakeRemote())
|
||||||
api.onList = () => Promise.resolve(ok({
|
api.onList = () => Promise.resolve(ok({
|
||||||
@@ -224,6 +224,9 @@ describe('manager frame routing', () => {
|
|||||||
rpcId: 'rm' as never,
|
rpcId: 'rm' as never,
|
||||||
payload: { type: 'host/session-removed', sessionId: sid('s1') } 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')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
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