mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
The wire type now carries projections?: SessionProjectionsBlock (host-base landed), so the structural projectionsOf narrowing and its TODO(gui) go away — Session reads result.value.projections directly at all three installWindow sites. ProjectionsBaseline stays as the cell framework's structural twin (React-free layer keeps depending on the type table only) with values typed Partial<SessionProjectionMap>; the erased walk moves inside resetBaseline where per-key typing is re-established by schema.parse.
243 lines
11 KiB
TypeScript
243 lines
11 KiB
TypeScript
/**
|
|
* Projection cells (session-projection RFC): the one watermark rule shared by
|
|
* live and window paths (replayed pages never roll back), baseline reset
|
|
* semantics (late baseline never overwrites a newer commit), capability
|
|
* absence as undefined, and the Session/SessionsService dispatch wiring.
|
|
*/
|
|
import { Context } from 'cordis'
|
|
import { describe, expect, it } from 'vitest'
|
|
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
|
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
|
import { ProjectionCellSet } from '../src/client/sessions/projection-cell.ts'
|
|
import type { ProjectionCellSpec } from '../src/client/sessions/projection-cell.ts'
|
|
import { Session } from '../src/client/sessions/session.ts'
|
|
import { SessionsService } from '../src/client/sessions/service.ts'
|
|
import { FakeApiClient, ok } from './fake-api.ts'
|
|
import { entries, plainTurn } from './event-script.ts'
|
|
|
|
// Test-domain key merged into the projection map (the interface package's
|
|
// pure-type outlet): a whole-value marker list, the smallest last-wins shape.
|
|
declare module '@deepseek-ai/dsh-session-projection/types' {
|
|
interface SessionProjectionMap {
|
|
'test/marks': { marks: string[] }
|
|
}
|
|
}
|
|
|
|
const SID = 'fk-s1' as SessionId
|
|
|
|
/** Whole-value domain event carrying the complete post-change state. */
|
|
const markEvent = (seq: number, marks: string[]): SessionEvent =>
|
|
({ seq, time: 1_700_000_000_000 + seq, type: 'test/mark', data: { marks } }) as unknown as SessionEvent
|
|
|
|
/** Loose schema: passes objects with a marks array through, throws otherwise. */
|
|
const marksSpec = (): ProjectionCellSpec<'test/marks'> => ({
|
|
key: 'test/marks',
|
|
schema: {
|
|
parse: (value) => {
|
|
if (typeof value === 'object' && value !== null && Array.isArray((value as { marks?: unknown }).marks)) {
|
|
return value as { marks: string[] }
|
|
}
|
|
throw new Error('not a marks payload')
|
|
},
|
|
},
|
|
fromEvent: (event) => ((event.type as string) === 'test/mark'
|
|
? (event as unknown as { data: { marks: string[] } }).data
|
|
: undefined),
|
|
})
|
|
|
|
describe('ProjectionCellSet semantics', () => {
|
|
function bench() {
|
|
const set = new ProjectionCellSet()
|
|
const dispose = set.register(marksSpec())
|
|
const cell = set.cellOf('test/marks')
|
|
if (cell === undefined) throw new Error('cell missing after register')
|
|
return { set, cell, dispose }
|
|
}
|
|
|
|
it('starts absent (undefined) until any signal lands', () => {
|
|
const { cell } = bench()
|
|
expect(cell.getSnapshot()).toBeUndefined()
|
|
})
|
|
|
|
it('applies whole values last-wins by seq and never rolls back on replayed old events', () => {
|
|
const { set, cell } = bench()
|
|
set.offerEvent(markEvent(5, ['a']))
|
|
set.offerEvent(markEvent(9, ['a', 'b']))
|
|
expect(cell.getSnapshot()).toEqual({ marks: ['a', 'b'] })
|
|
// A replayed old page (window path) passes the same filter and drops.
|
|
set.offerWindow([markEvent(3, ['stale']), markEvent(9, ['a', 'b'])])
|
|
expect(cell.getSnapshot()).toEqual({ marks: ['a', 'b'] })
|
|
})
|
|
|
|
it('re-seeds value and watermark from a baseline, and events at or below asOfSeq drop after it', () => {
|
|
const { set, cell } = bench()
|
|
set.resetBaseline({ asOfSeq: 20, values: { 'test/marks': { marks: ['x'] } } })
|
|
expect(cell.getSnapshot()).toEqual({ marks: ['x'] })
|
|
set.offerEvent(markEvent(18, ['older-than-cut']))
|
|
expect(cell.getSnapshot()).toEqual({ marks: ['x'] })
|
|
set.offerEvent(markEvent(21, ['newer']))
|
|
expect(cell.getSnapshot()).toEqual({ marks: ['newer'] })
|
|
})
|
|
|
|
it('drops a late baseline whose cut predates an already-applied commit (seq rule)', () => {
|
|
const { set, cell } = bench()
|
|
set.offerEvent(markEvent(30, ['live-commit']))
|
|
set.resetBaseline({ asOfSeq: 25, values: { 'test/marks': { marks: ['stale-baseline'] } } })
|
|
expect(cell.getSnapshot()).toEqual({ marks: ['live-commit'] })
|
|
})
|
|
|
|
it('marks a key absent when the block omits it — capability absence is undefined', () => {
|
|
const { set, cell } = bench()
|
|
set.offerEvent(markEvent(5, ['a']))
|
|
set.resetBaseline({ asOfSeq: 10, values: {} })
|
|
expect(cell.getSnapshot()).toBeUndefined()
|
|
})
|
|
|
|
it('degrades a baseline payload failing schema validation to absent instead of poisoning the cell', () => {
|
|
const { set, cell } = bench()
|
|
// Deliberately malformed wire payload: the typed block cannot express it,
|
|
// which is exactly why the boundary schema exists.
|
|
set.resetBaseline({ asOfSeq: 10, values: { 'test/marks': 'not-an-object' as never } })
|
|
expect(cell.getSnapshot()).toBeUndefined()
|
|
// The watermark still advanced to the cut: pre-cut events stay dropped.
|
|
set.offerEvent(markEvent(8, ['pre-cut']))
|
|
expect(cell.getSnapshot()).toBeUndefined()
|
|
})
|
|
|
|
it('throws on duplicate key registration and frees the key through the disposer', () => {
|
|
const { set, dispose } = bench()
|
|
expect(() => set.register(marksSpec())).toThrow(/already registered/)
|
|
dispose()
|
|
expect(set.cellOf('test/marks')).toBeUndefined()
|
|
expect(() => set.register(marksSpec())).not.toThrow()
|
|
})
|
|
|
|
it('notifies subscribers on application (microtask-batched) and not on filtered events', async () => {
|
|
const { set, cell } = bench()
|
|
let ticks = 0
|
|
cell.subscribe(() => { ticks += 1 })
|
|
set.offerEvent(markEvent(5, ['a']))
|
|
await Promise.resolve()
|
|
expect(ticks).toBe(1)
|
|
set.offerEvent(markEvent(3, ['replay']))
|
|
set.offerEvent({ seq: 6, time: 6, type: 'unrelated/event', data: {} } as unknown as SessionEvent)
|
|
await Promise.resolve()
|
|
expect(ticks).toBe(1)
|
|
})
|
|
})
|
|
|
|
describe('Session dispatch wiring', () => {
|
|
function makeSession() {
|
|
const api = new FakeApiClient()
|
|
const session = new Session(SID, api)
|
|
const dispose = session.projections.register(marksSpec())
|
|
const cell = session.projections.cellOf('test/marks')
|
|
if (cell === undefined) throw new Error('cell missing after register')
|
|
return { api, session, cell, dispose }
|
|
}
|
|
|
|
it('feeds live appends through the cell filter', async () => {
|
|
const { api, session, cell } = makeSession()
|
|
api.onHistory = () => Promise.resolve(ok({ events: entries(plainTurn(0, 0, '问', '答')) as never[], hasMore: false }))
|
|
await session.open()
|
|
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: markEvent(6, ['live']) })
|
|
expect(cell.getSnapshot()).toEqual({ marks: ['live'] })
|
|
})
|
|
|
|
it('re-seeds from a history response carrying a projections block, then folds newer window events', async () => {
|
|
const { api, session, cell } = makeSession()
|
|
const window = [...plainTurn(0, 0, '问', '答'), markEvent(6, ['from-window'])]
|
|
api.onHistory = () => Promise.resolve(ok({
|
|
events: entries(window) as never[], hasMore: false,
|
|
projections: { asOfSeq: 4, values: { 'test/marks': { marks: ['from-baseline'] } } },
|
|
} as never))
|
|
await session.open()
|
|
// Baseline cut at 4; the window's seq-6 domain event is newer and wins.
|
|
expect(cell.getSnapshot()).toEqual({ marks: ['from-window'] })
|
|
})
|
|
|
|
it('treats a blockless response as event-only folding (no reset), and a resync repull cannot roll back', async () => {
|
|
const { api, session, cell } = makeSession()
|
|
api.onHistory = () => Promise.resolve(ok({ events: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false }))
|
|
await session.open()
|
|
expect(cell.getSnapshot()).toBeUndefined()
|
|
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: markEvent(6, ['live']) })
|
|
expect(cell.getSnapshot()).toEqual({ marks: ['live'] })
|
|
// Reconnect resync repulls the same window (no block, no domain events): state holds.
|
|
await session.resync()
|
|
expect(cell.getSnapshot()).toEqual({ marks: ['live'] })
|
|
})
|
|
|
|
it('applies the stale-baseline guard end to end: a resync whose block predates a live commit keeps the commit', async () => {
|
|
const { api, session, cell } = makeSession()
|
|
api.onHistory = () => Promise.resolve(ok({
|
|
events: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false,
|
|
projections: { asOfSeq: 5, values: { 'test/marks': { marks: ['baseline'] } } },
|
|
} as never))
|
|
await session.open()
|
|
expect(cell.getSnapshot()).toEqual({ marks: ['baseline'] })
|
|
// Contiguous live commit applies immediately (seq 6 = tail 5 + 1)…
|
|
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: markEvent(6, ['commit-6']) })
|
|
expect(cell.getSnapshot()).toEqual({ marks: ['commit-6'] })
|
|
// …then a resync repull serves the same stale block (cut 5 < applied 6):
|
|
// the baseline reset must not overwrite the newer commit (seq rule).
|
|
await session.resync()
|
|
expect(cell.getSnapshot()).toEqual({ marks: ['commit-6'] })
|
|
})
|
|
})
|
|
|
|
describe('SessionsService roster', () => {
|
|
const sid = (s: string): SessionId => s as SessionId
|
|
|
|
async function bench() {
|
|
const ctx = new Context()
|
|
const api = new FakeApiClient()
|
|
const svc = new SessionsService(ctx, api)
|
|
api.onList = () => Promise.resolve(ok({
|
|
items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }],
|
|
}) as never)
|
|
await svc.refresh()
|
|
await Promise.resolve()
|
|
return { ctx, api, svc }
|
|
}
|
|
|
|
it('materializes registered specs on already-live scopes and future scopes alike', async () => {
|
|
const b = await bench()
|
|
const binding1 = b.svc.binding(sid('s1'))
|
|
if (binding1 === undefined) throw new Error('no binding for s1')
|
|
b.svc.registerProjectionCell(marksSpec())
|
|
expect(binding1.session.projections.cellOf('test/marks')).toBeDefined()
|
|
// A session arriving later gets the roster at scope mint.
|
|
b.api.onList = () => Promise.resolve(ok({
|
|
items: [
|
|
{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false },
|
|
{ sessionId: sid('s2'), updatedAt: 2, running: false, blank: false },
|
|
],
|
|
}) as never)
|
|
await b.svc.refresh()
|
|
await Promise.resolve()
|
|
const binding2 = b.svc.binding(sid('s2'))
|
|
expect(binding2?.session.projections.cellOf('test/marks')).toBeDefined()
|
|
})
|
|
|
|
it('exposes the key-addressed cell face on provideInfo (the useProjection resolution path)', async () => {
|
|
const b = await bench()
|
|
b.svc.registerProjectionCell(marksSpec())
|
|
const info = b.svc.provideInfo('s1')
|
|
if (info === undefined) throw new Error('no provide info for s1')
|
|
expect(info.projections?.cellOf('test/marks')).toBeDefined()
|
|
expect(info.projections?.cellOf('test/ghost')).toBeUndefined()
|
|
// The no-session projection carries no face: every key reads absent.
|
|
expect(b.svc.maybeProvideInfo(undefined).projections).toBeUndefined()
|
|
})
|
|
|
|
it('removes the cell from every live session through the disposer (HMR semantics)', async () => {
|
|
const b = await bench()
|
|
const dispose = b.svc.registerProjectionCell(marksSpec())
|
|
const binding = b.svc.binding(sid('s1'))
|
|
expect(binding?.session.projections.cellOf('test/marks')).toBeDefined()
|
|
dispose()
|
|
expect(binding?.session.projections.cellOf('test/marks')).toBeUndefined()
|
|
})
|
|
})
|