|
|
@@ -1,11 +1,12 @@
|
|
|
/**
|
|
|
* Projection value store (push model; session-projection subsystem page:
|
|
|
- * docs/subsystems/session-projection.md): higher-seq-wins across every source,
|
|
|
- * capability absence as undefined, generation truncation, and the
|
|
|
+ * docs/subsystems/session-projection.md): higher-seq-wins for ordinary inputs,
|
|
|
+ * exact opening replacement, capability absence as undefined, generation truncation, and the
|
|
|
* Session/manager wiring (tail-page seeding, control-stream projection routing
|
|
|
* pre- and post-instantiation, and list-row projection values).
|
|
|
*/
|
|
|
-import { describe, expect, it } from 'vitest'
|
|
|
+import { describe, expect, it, vi } from 'vitest'
|
|
|
+import { RemoteStreamCarrierError } from '@deepseek-ai/dsh-api-gateway/client'
|
|
|
import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
|
|
|
import { ProjectionValueStore } from '../src/client/sessions/projection-store.ts'
|
|
|
import { Session } from '../src/client/sessions/session.ts'
|
|
|
@@ -63,6 +64,17 @@ describe('Session projection value semantics', () => {
|
|
|
expect(store.get('newer')).toBe('newer')
|
|
|
})
|
|
|
|
|
|
+ it('an exact baseline replaces every prior row even when its cut is lower', () => {
|
|
|
+ const store = new ProjectionValueStore()
|
|
|
+ store.apply('test/marks', { marks: ['ghost'] }, 9)
|
|
|
+ store.apply('omitted', 'ghost', 9)
|
|
|
+ store.replace({ asOfSeq: 2, values: { 'test/marks': { marks: ['durable'] } } })
|
|
|
+ expect(store.get('test/marks')).toEqual({ marks: ['durable'] })
|
|
|
+ expect(store.get('omitted')).toBeUndefined()
|
|
|
+ store.apply('test/marks', { marks: ['live'] }, 3)
|
|
|
+ expect(store.get('test/marks')).toEqual({ marks: ['live'] })
|
|
|
+ })
|
|
|
+
|
|
|
it('truncate drops rows past the durable baseline and keeps the rest', () => {
|
|
|
const store = new ProjectionValueStore()
|
|
|
store.apply('test/marks', { marks: ['durable'] }, 5)
|
|
|
@@ -134,7 +146,7 @@ describe('Session tail-page seeding', () => {
|
|
|
expect(session.projections.get('test/marks')).toEqual({ marks: ['from-baseline'] })
|
|
|
})
|
|
|
|
|
|
- it('does not let an older opening baseline replace a newer cached value', async () => {
|
|
|
+ it('replaces a higher-sequence cache ghost with the exact opening baseline', async () => {
|
|
|
const api = new FakeApiClient()
|
|
|
const projections = new ProjectionValueStore()
|
|
|
projections.apply('test/marks', { marks: ['cached'] }, 9)
|
|
|
@@ -147,10 +159,10 @@ describe('Session tail-page seeding', () => {
|
|
|
await session.open()
|
|
|
|
|
|
expect(session.getSnapshot().openState).toBe('open')
|
|
|
- expect(session.projections.get('test/marks')).toEqual({ marks: ['cached'] })
|
|
|
+ expect(session.projections.get('test/marks')).toEqual({ marks: ['older-baseline'] })
|
|
|
})
|
|
|
|
|
|
- it('keeps the highest cut while opening and live frames interleave', async () => {
|
|
|
+ it('replays live control frames after replacing cache hints during opening', async () => {
|
|
|
const api = new FakeApiClient()
|
|
|
const history = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
|
|
|
api.onHistory = () => history.promise
|
|
|
@@ -159,17 +171,19 @@ describe('Session tail-page seeding', () => {
|
|
|
const session = new Session(SID, fakeRemote(api), { projections })
|
|
|
|
|
|
const opening = session.open()
|
|
|
- session.projections.apply('test/marks', { marks: ['live-3'] }, 3)
|
|
|
+ session.handleProjectionFrame({
|
|
|
+ type: 'projection', sessionId: SID, key: 'test/marks', value: { marks: ['live-3'] }, seq: 3,
|
|
|
+ })
|
|
|
history.resolve(ok({
|
|
|
records: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false,
|
|
|
projections: { asOfSeq: 2, values: { 'test/marks': { marks: ['baseline-2'] } } },
|
|
|
} as never))
|
|
|
await opening
|
|
|
|
|
|
- expect(session.projections.get('test/marks')).toEqual({ marks: ['cached-9'] })
|
|
|
+ expect(session.projections.get('test/marks')).toEqual({ marks: ['live-3'] })
|
|
|
})
|
|
|
|
|
|
- it('a resync serving a stale block keeps the newer pushed value (seq rule end to end)', async () => {
|
|
|
+ it('resync removes pre-operation high rows and replays only control operations that arrive during resync', async () => {
|
|
|
const api = new FakeApiClient()
|
|
|
const session = new Session(SID, fakeRemote(api))
|
|
|
api.onHistory = () => Promise.resolve(ok({
|
|
|
@@ -177,19 +191,108 @@ describe('Session tail-page seeding', () => {
|
|
|
projections: { asOfSeq: 5, values: { 'test/marks': { marks: ['baseline'] } } },
|
|
|
} as never))
|
|
|
await session.open()
|
|
|
- session.projections.apply('test/marks', { marks: ['pushed-9'] }, 9)
|
|
|
- await session.resync()
|
|
|
- expect(session.projections.get('test/marks')).toEqual({ marks: ['pushed-9'] })
|
|
|
+ session.handleProjectionFrame({
|
|
|
+ type: 'projection', sessionId: SID, key: 'test/marks', value: { marks: ['old-9'] }, seq: 9,
|
|
|
+ })
|
|
|
+ const history = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
|
|
|
+ api.onHistory = () => history.promise
|
|
|
+ const resyncing = session.resync()
|
|
|
+ session.handleProjectionFrame({
|
|
|
+ type: 'projection', sessionId: SID, key: 'test/marks', value: { marks: ['during-3'] }, seq: 3,
|
|
|
+ })
|
|
|
+ history.resolve(ok({
|
|
|
+ records: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false,
|
|
|
+ projections: { asOfSeq: 2, values: { 'test/marks': { marks: ['baseline-2'] } } },
|
|
|
+ } as never))
|
|
|
+ await resyncing
|
|
|
+ expect(session.projections.get('test/marks')).toEqual({ marks: ['during-3'] })
|
|
|
+ })
|
|
|
+
|
|
|
+ it('replays control baselines and frames in arrival order', async () => {
|
|
|
+ const api = new FakeApiClient()
|
|
|
+ const session = new Session(SID, fakeRemote(api))
|
|
|
+ const history = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
|
|
|
+ api.onHistory = () => history.promise
|
|
|
+ const opening = session.open()
|
|
|
+ session.handleProjectionFrame({
|
|
|
+ type: 'projection', sessionId: SID, key: 'test/marks', value: { marks: ['first-frame'] }, seq: 7,
|
|
|
+ })
|
|
|
+ session.replaceProjectionBaseline({
|
|
|
+ asOfSeq: 2, values: { 'test/marks': { marks: ['control-baseline'] } },
|
|
|
+ })
|
|
|
+ session.handleProjectionFrame({
|
|
|
+ type: 'projection', sessionId: SID, key: 'test/marks', value: { marks: ['last-frame'] }, seq: 3,
|
|
|
+ })
|
|
|
+ history.resolve(ok({
|
|
|
+ records: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false,
|
|
|
+ projections: { asOfSeq: 1, values: { 'test/marks': { marks: ['opening'] } } },
|
|
|
+ } as never))
|
|
|
+ await opening
|
|
|
+ expect(session.projections.get('test/marks')).toEqual({ marks: ['last-frame'] })
|
|
|
+ })
|
|
|
+
|
|
|
+ it('ignores a list hint after the exact baseline is installed but before open settles', async () => {
|
|
|
+ const api = new FakeApiClient()
|
|
|
+ const session = new Session(SID, fakeRemote(api))
|
|
|
+ api.onHistory = () => Promise.resolve(ok({
|
|
|
+ records: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false,
|
|
|
+ projections: { asOfSeq: 2, values: { 'test/marks': { marks: ['exact'] } } },
|
|
|
+ } as never))
|
|
|
+ const unsubscribe = session.eventSource.subscribe(() => {
|
|
|
+ expect(session.getSnapshot().openState).toBe('loading')
|
|
|
+ session.handleProjectionHint({
|
|
|
+ asOfSeq: 99, values: { 'test/marks': { marks: ['late-hint'] } },
|
|
|
+ })
|
|
|
+ })
|
|
|
+ await session.open()
|
|
|
+ unsubscribe()
|
|
|
+ expect(session.projections.get('test/marks')).toEqual({ marks: ['exact'] })
|
|
|
})
|
|
|
|
|
|
- it('treats a blockless response as no reset: pushed values survive', async () => {
|
|
|
+ it('keeps normally applied control state on failure without replaying the failed capture into a later open', async () => {
|
|
|
const api = new FakeApiClient()
|
|
|
+ const first = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
|
|
|
+ api.onHistory = () => first.promise
|
|
|
const session = new Session(SID, fakeRemote(api))
|
|
|
- api.onHistory = () => Promise.resolve(ok({ records: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false }))
|
|
|
+ const failedOpen = session.open()
|
|
|
+ session.handleProjectionFrame({
|
|
|
+ type: 'projection', sessionId: SID, key: 'test/marks', value: { marks: ['during-failure'] }, seq: 3,
|
|
|
+ })
|
|
|
+ first.resolve(err({ code: 'session-not-found', message: 'gone', details: { sessionId: SID } }))
|
|
|
+ await failedOpen
|
|
|
+ expect(session.projections.get('test/marks')).toEqual({ marks: ['during-failure'] })
|
|
|
+
|
|
|
+ api.onHistory = () => Promise.resolve(ok({
|
|
|
+ records: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false,
|
|
|
+ projections: { asOfSeq: 2, values: { 'test/marks': { marks: ['later-open'] } } },
|
|
|
+ } as never))
|
|
|
await session.open()
|
|
|
- session.projections.apply('test/marks', { marks: ['pushed'] }, 9)
|
|
|
- await session.resync()
|
|
|
- expect(session.projections.get('test/marks')).toEqual({ marks: ['pushed'] })
|
|
|
+ expect(session.projections.get('test/marks')).toEqual({ marks: ['later-open'] })
|
|
|
+ })
|
|
|
+
|
|
|
+ it('replays frames received while a carrier reconnect waits for its replacement snapshot', async () => {
|
|
|
+ const api = new FakeApiClient()
|
|
|
+ const session = new Session(SID, fakeRemote(api))
|
|
|
+ api.onHistory = () => Promise.resolve(ok({
|
|
|
+ records: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false,
|
|
|
+ projections: { asOfSeq: 2, values: { 'test/marks': { marks: ['first'] } } },
|
|
|
+ } as never))
|
|
|
+ await session.open()
|
|
|
+
|
|
|
+ const replacement = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
|
|
|
+ api.onHistory = () => replacement.promise
|
|
|
+ api.failStreams(new RemoteStreamCarrierError('carrier lost'))
|
|
|
+ await vi.waitFor(() => { expect(api.callsOf('session.follow')).toHaveLength(2) })
|
|
|
+ session.handleProjectionFrame({
|
|
|
+ type: 'projection', sessionId: SID, key: 'test/marks', value: { marks: ['during-retry'] }, seq: 3,
|
|
|
+ })
|
|
|
+ replacement.resolve(ok({
|
|
|
+ records: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false,
|
|
|
+ projections: { asOfSeq: 2, values: { 'test/marks': { marks: ['replacement'] } } },
|
|
|
+ } as never))
|
|
|
+ await vi.waitFor(() => {
|
|
|
+ expect(session.projections.get('test/marks')).toEqual({ marks: ['during-retry'] })
|
|
|
+ })
|
|
|
})
|
|
|
})
|
|
|
|
|
|
@@ -218,6 +321,7 @@ describe('manager frame routing', () => {
|
|
|
items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }],
|
|
|
}) as never)
|
|
|
await manager.refreshList()
|
|
|
+ manager.get(sid('s1'))
|
|
|
manager.handleControlFrame({
|
|
|
type: 'projection', sessionId: sid('s1'), key: 'title', value: 'Projected title', seq: 4,
|
|
|
})
|
|
|
@@ -263,6 +367,31 @@ describe('manager frame routing', () => {
|
|
|
expect(manager.getListSnapshot().items[0]?.projectionValues).not.toBe(baseline)
|
|
|
})
|
|
|
|
|
|
+ it('keeps late list and session-added cache hints out of an already opened Session', async () => {
|
|
|
+ const api = new FakeApiClient()
|
|
|
+ const manager = new SessionManager(fakeRemote(api))
|
|
|
+ const sessionId = sid('s1')
|
|
|
+ api.onHistory = () => Promise.resolve(ok({
|
|
|
+ records: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false,
|
|
|
+ projections: { asOfSeq: 2, values: { 'test/marks': { marks: ['exact'] } } },
|
|
|
+ } as never))
|
|
|
+ const session = manager.get(sessionId)
|
|
|
+ await session.open()
|
|
|
+
|
|
|
+ api.onList = () => Promise.resolve(ok({
|
|
|
+ items: [{
|
|
|
+ sessionId, updatedAt: 1, running: false, blank: false,
|
|
|
+ projections: { asOfSeq: 99, values: { 'test/marks': { marks: ['list-hint'] } } },
|
|
|
+ }],
|
|
|
+ }) as never)
|
|
|
+ await manager.refreshList()
|
|
|
+ manager.handleSessionAdded({
|
|
|
+ sessionId, updatedAt: 2, running: false, blank: false,
|
|
|
+ projections: { asOfSeq: 100, values: { 'test/marks': { marks: ['added-hint'] } } },
|
|
|
+ })
|
|
|
+ expect(session.projections.get('test/marks')).toEqual({ marks: ['exact'] })
|
|
|
+ })
|
|
|
+
|
|
|
it('drops the projection store with the removed session', async () => {
|
|
|
const api = new FakeApiClient()
|
|
|
const manager = new SessionManager(fakeRemote(api))
|