Merge origin/master into goal-ui: adopt the rewritten client core and apiproxy carrier

Conflict rulings follow the projection-reattach plan:
- host/runtime package (deleted on master): take master; the PR's boot
  composition moves to the cordis.yml roster and its goals handlers will be
  re-landed in dsh-host-apiproxy; the session.prompt slash interception and
  its spec are dropped entirely (superseded by command.execute + command/run
  logging).
- client core (rewritten on master): take master; the PR's Session goal
  fields/methods, ConversationSnapshot.goal, goalActions injection, and the
  hard-mounted GoalBar are all superseded by the 'goal' session projection
  (useProjection) and will return as the ui-goal plugin.
- wire contract: union of master's workspace/command/skill domains and the
  PR's goal domain, minus goal.get (the read side is the projection block +
  session/projection frames; six mutation RPCs stay).
- GoalBar component and spec leave ui-conversation (they re-land in the new
  ui-goal package); IconSparkle16 stays in ui-conversation chat.
- The web-slash-command-dispatch note documents the dropped interception and
  is removed; the goal-bar note will be rewritten for the projection model.
- pnpm-lock.yaml taken from master (reinstall recomputes).
This commit is contained in:
imccyu
2026-07-28 20:55:36 +08:00
3694 changed files with 207510 additions and 63299 deletions

View File

@@ -1,5 +1,5 @@
/**
* Runtime plugin browser-half apply: slots + sessions mounting over the
* Runtime plugin browser-half apply: slots + object services mounting over the
* connection handle, stream-loop sink wiring into the object layer, and the
* fiber-scoped loop teardown.
*/
@@ -8,7 +8,9 @@ import { describe, expect, it } from 'vitest'
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
import type { ConnectionSinks } from '@deepseek-ai/dsh-client-connection/client'
import * as RuntimeClient from '../src/client/index.ts'
import { FakeApiClient } from './fake-api.ts'
import type { SessionsService } from '../src/client/sessions/service.ts'
import type { WorkspacesService } from '../src/client/workspaces/service.ts'
import { FakeApiClient, ok } from './fake-api.ts'
interface Bench {
ctx: Context
@@ -33,26 +35,73 @@ async function mount(): Promise<Bench> {
return bench
}
async function flushMicrotasks(): Promise<void> {
for (let i = 0; i < 12; i++) await Promise.resolve()
}
describe('runtime client apply', () => {
it('mounts ctx.slots + ctx.sessions and wires the stream sinks into the manager', async () => {
it('mounts slots, Sessions, and Workspaces and fans host frames into both managers', async () => {
const bench = await mount()
expect(bench.ctx.get('slots') !== undefined).toBe(true)
// The built-in 'root' declaration ships with this package's SlotsService
// (the SlotMap 'root' merge lives here since the slot-parity rework).
expect(bench.ctx.slots.spec('root')).toEqual({ kind: 'single', scope: 'root' })
const sessions = bench.ctx.get('sessions')
const workspaces = bench.ctx.get('workspaces')
expect(sessions !== undefined).toBe(true)
expect(workspaces !== undefined).toBe(true)
if (workspaces === undefined) throw new Error('WorkspacesService missing after runtime apply')
expect(bench.sinks).toBeDefined()
// Frame sinks reach the object layer: a host session-added lands in the list store.
bench.sinks?.onHostEnvelope?.({
rpcId: 'r1' as never,
payload: { type: 'host/session-added', sessionId: 's-new' } as never,
payload: { type: 'host/session-added', blank: true, sessionId: 's-new' } as never,
})
await Promise.resolve()
expect((sessions as { list: { getSnapshot(): { ids: string[] } } }).list.getSnapshot().ids).toContain('s-new')
bench.sinks?.onHostEnvelope?.({
rpcId: 'r-workspace' as never,
payload: {
type: 'host/workspace-changed',
workspace: {
workspaceId: 'w-new', path: '/w/new', title: 'new', sessionIds: [],
createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
},
} as never,
})
await Promise.resolve()
expect(workspaces.list.getSnapshot().items[0]?.workspaceId).toBe('w-new')
// Mux sink and onConnected route without throwing (manager semantics own the behavior).
bench.sinks?.onMuxEnvelope?.({ rpcId: 'r2' as never, payload: { type: 'stream/error', message: 'x' } as never })
bench.sinks?.onConnected?.()
})
it('selects the recent Workspace once when the first baselines have no current session', async () => {
const bench = await mount()
bench.api.onWorkspaceList = () => Promise.resolve(ok({
items: [{
workspaceId: 'w-recent', path: '/w/recent', title: 'recent', sessionIds: [],
createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
}] as never[],
}))
bench.api.onList = () => Promise.resolve(ok({ items: [] }))
bench.sinks?.onConnected?.()
await flushMicrotasks()
const sessions = bench.ctx.get('sessions') as SessionsService
const workspaces = bench.ctx.get('workspaces') as WorkspacesService
expect(bench.api.callsOf('session.create')).toEqual([{ workspaceId: 'w-recent' }])
expect(sessions.list.getSnapshot().current).toBe('fk-new')
sessions.clear()
await workspaces.refresh()
await flushMicrotasks()
expect(sessions.list.getSnapshot().current).toBeUndefined()
expect(bench.api.callsOf('session.create')).toHaveLength(1)
})
it('stops the stream loop when the plugin fiber unloads', async () => {
const bench = await mount()
const fiber = [...bench.ctx.registry.values()].find(f => f.name?.includes('client'))

View File

@@ -1,77 +0,0 @@
/**
* Real-bundle smoke: the actual tsdown client bundle of ui-layout runs
* through the loader chain (execute → handoff → factory(require) → apply →
* export re-registration). Skips when the bundle is not built (lib/client.js is a
* build product; `pnpm --filter @deepseek-ai/dsh-client-ui-layout build`).
*/
import { readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { Context } from 'cordis'
import { afterEach, describe, expect, it } from 'vitest'
import * as uiSlots from '@deepseek-ai/dsh-client-ui-slots'
import * as webReact from '@deepseek-ai/dsh-client-web-react'
import { createClientLoader } from '../src/client/loader/index.ts'
import type { ClientPluginHandoff } from '../src/client/loader/index.ts'
import { SessionsService } from '../src/client/sessions/service.ts'
import { SlotsService } from '../src/client/slots.ts'
import { FakeApiClient } from './fake-api.ts'
const LAYOUT_ID = '@deepseek-ai/dsh-client-ui-layout'
type Win = { DSHClientProxy?: { loadPlugin(h: ClientPluginHandoff): void }; window?: unknown }
afterEach(() => {
delete (globalThis as Win).DSHClientProxy
delete (globalThis as Win).window
})
function readLayoutBundle(): string | undefined {
try {
const require = createRequire(import.meta.url)
return readFileSync(require.resolve(`${LAYOUT_ID}/client`), 'utf8')
} catch {
return undefined
}
}
describe('real tsdown bundle through the loader', () => {
const code = readLayoutBundle()
it.skipIf(code === undefined)('loads ui-layout lib/client.js: handoff, DI require, apply, export surface', async () => {
// The bundle banner addresses window.DSHClientProxy; node has no window —
// alias it to globalThis so the loader-installed proxy is reachable.
;(globalThis as Win).window = globalThis
const ctx = new Context()
// The layout apply consumes the slots + sessions services; the real chain
// loads the runtime bundle first — stand both up directly here.
ctx.plugin(SlotsService)
await ctx.fiber.await()
new SessionsService(ctx, new FakeApiClient())
const loader = createClientLoader({
ctx,
// The real bundle externals resolved from the seeded table. React is a
// type-only import in the layout bundle today, but jsx-runtime is real.
modules: {
'react': await import('react'),
'react/jsx-runtime': await import('react/jsx-runtime'),
'@deepseek-ai/dsh-client-ui-slots': uiSlots,
'@deepseek-ai/dsh-client-web-react': webReact,
},
boot: { plugins: [{ id: LAYOUT_ID, url: `/plugins/${LAYOUT_ID}/client.js`, inject: [] }] },
fetchBundle: () => Promise.resolve(code as string),
// node has no DOM: evaluate the bundle body directly (same synchronous
// handoff contract as the <script> path).
executeBundle: (bundleCode) => {
// Node has no <script>: Function-evaluating the built bundle IS the
// system under test (same synchronous handoff as the browser path).
// eslint-disable-next-line @typescript-eslint/no-implied-eval, @typescript-eslint/no-unsafe-call
new Function(bundleCode)()
},
})
loader.start()
await loader.settled()
expect(loader.status.getSnapshot()[LAYOUT_ID]).toBe('active')
const surface = loader.requireModule(LAYOUT_ID) as Record<string, unknown>
expect(typeof surface.apply).toBe('function')
})
})

View File

@@ -1,289 +0,0 @@
/**
* ClientLoader: handoff protocol (single slot, id reconciliation), DI require
* with export-surface re-registration, immediately-group barrier (parallel
* fetch / topology execution / full-group barrier), status store, settled,
* failure modes (missing handoff, unknown dep, cycle, unload stub).
*/
import { Context } from 'cordis'
import { afterEach, describe, expect, it } from 'vitest'
import { createClientLoader } from '../src/client/loader/index.ts'
import type { BootPluginEntry, ClientPluginHandoff } from '../src/client/loader/index.ts'
type Win = { DSHClientProxy?: { loadPlugin(h: ClientPluginHandoff): void }; __DSH_BOOT__?: { plugins: BootPluginEntry[] } }
const win = globalThis as Win
afterEach(() => {
delete win.DSHClientProxy
delete win.__DSH_BOOT__
})
interface FakeBundle {
handoff: ClientPluginHandoff | null | ((require: (spec: string) => unknown) => Record<string, unknown>)
}
interface Bench {
loader: ReturnType<typeof createClientLoader>
fetched: string[]
executed: string[]
fetchGate: Map<string, () => void>
}
/** Build a loader over scripted fake bundles keyed by url; fetches resolve when released (or immediately). */
function bench(
plugins: BootPluginEntry[],
bundles: Record<string, FakeBundle>,
opts: { modules?: Record<string, unknown>; gated?: string[] } = {},
): Bench {
const ctx = new Context()
const fetched: string[] = []
const executed: string[] = []
const fetchGate = new Map<string, () => void>()
const loader = createClientLoader({
ctx,
modules: opts.modules ?? { react: { marker: 'react' } },
boot: { plugins },
fetchBundle: (url) => {
fetched.push(url)
if (opts.gated?.includes(url) === true) {
return new Promise<string>((resolve) => { fetchGate.set(url, () => { resolve(url) }) })
}
return Promise.resolve(url)
},
executeBundle: (code) => {
executed.push(code)
const bundle = bundles[code]
if (bundle === undefined) throw new Error(`no fake bundle for ${code}`)
if (bundle.handoff === null) return // simulates a bundle that never calls loadPlugin
if (typeof bundle.handoff === 'function') {
win.DSHClientProxy?.loadPlugin({ id: code.replace('/client.js', '').replace('/plugins/', ''), factory: bundle.handoff })
return
}
win.DSHClientProxy?.loadPlugin(bundle.handoff)
},
})
return { loader, fetched, executed, fetchGate }
}
const entry = (id: string, inject: string[] = [], immediately?: boolean): BootPluginEntry =>
({ id, url: `/plugins/${id}/client.js`, inject, ...(immediately === true ? { immediately: true } : {}) })
const okBundle = (applied?: string[], exports: Record<string, unknown> = {}): FakeBundle => ({
handoff: require => ({
apply: (pluginCtx: Context) => { void pluginCtx; applied?.push('applied') },
require,
...exports,
}),
})
describe('load chain', () => {
it('runs fetch→execute→handoff→factory(require)→apply→export re-registration→status active', async () => {
const applied: string[] = []
const b = bench(
[entry('fake-base', [], true), entry('feature', ['fake-base'])],
{
'/plugins/fake-base/client.js': { handoff: () => ({ apply: () => { applied.push('fake-base') }, helper: 'base-helper' }) },
'/plugins/feature/client.js': {
handoff: (require) => {
// Later loader requires the earlier one's export surface (inject topology guarantee).
const fakeBase = ['fake','base'].join('-') // assembled so knip's static require() scan skips the fake id
const base = require(fakeBase) as { helper: string }
expect(base.helper).toBe('base-helper')
expect((require('react') as { marker: string }).marker).toBe('react')
return { apply: () => { applied.push('feature') } }
},
},
},
)
b.loader.start()
await b.loader.settled()
expect(applied).toEqual(['fake-base', 'feature'])
expect(b.loader.status.getSnapshot()).toEqual({ 'fake-base': 'active', feature: 'active' })
expect((b.loader.requireModule('fake-base') as { helper: string }).helper).toBe('base-helper')
expect(() => b.loader.requireModule('ghost')).toThrow(/not available/)
})
it('fetches the immediately group in parallel and holds the barrier before the rest', async () => {
const b = bench(
[entry('a', [], true), entry('b', ['a'], true), entry('later')],
{
'/plugins/a/client.js': okBundle(),
'/plugins/b/client.js': okBundle(),
'/plugins/later/client.js': okBundle(),
},
{ gated: ['/plugins/a/client.js'] },
)
b.loader.start()
await Promise.resolve()
// Both early fetches are in flight before any execution; the late plugin is not fetched yet.
expect(b.fetched).toEqual(['/plugins/a/client.js', '/plugins/b/client.js'])
expect(b.executed).toEqual([])
b.fetchGate.get('/plugins/a/client.js')?.()
await b.loader.settled()
expect(b.executed).toEqual(['/plugins/a/client.js', '/plugins/b/client.js', '/plugins/later/client.js'])
})
it('orders execution by inject topology within each group', async () => {
const b = bench(
[entry('z-ui', ['a-base']), entry('a-base')],
{ '/plugins/a-base/client.js': okBundle(), '/plugins/z-ui/client.js': okBundle() },
)
b.loader.start()
await b.loader.settled()
expect(b.executed).toEqual(['/plugins/a-base/client.js', '/plugins/z-ui/client.js'])
})
})
describe('failure modes (fail loud)', () => {
it('rejects settled and marks failed when a bundle never calls loadPlugin', async () => {
const b = bench([entry('silent')], { '/plugins/silent/client.js': { handoff: null } })
b.loader.start()
await expect(b.loader.settled()).rejects.toThrow(/without calling DSHClientProxy.loadPlugin/)
expect(b.loader.status.getSnapshot().silent).toBe('failed')
})
it('rejects on manifest/handoff id mismatch', async () => {
const b = bench([entry('expected')], {
'/plugins/expected/client.js': { handoff: { id: 'imposter', factory: () => ({ apply: () => {} }) } },
})
b.loader.start()
await expect(b.loader.settled()).rejects.toThrow(/id mismatch/)
})
it('rejects unknown inject targets, cycles, missing apply, unknown load ids, duplicate manifest ids', async () => {
// Sequential benches: each loader owns the window proxy, so release it between them.
const fresh = <T>(build: () => T): T => {
delete win.DSHClientProxy
return build()
}
const missing = fresh(() => bench([entry('x', ['nope'])], { '/plugins/x/client.js': okBundle() }))
missing.loader.start()
await expect(missing.loader.settled()).rejects.toThrow(/injects unknown plugin "nope"/)
const cyclic = fresh(() => bench(
[entry('p', ['q']), entry('q', ['p'])],
{ '/plugins/p/client.js': okBundle(), '/plugins/q/client.js': okBundle() },
))
cyclic.loader.start()
await expect(cyclic.loader.settled()).rejects.toThrow(/inject cycle/)
const applyless = fresh(() => bench([entry('noap')], { '/plugins/noap/client.js': { handoff: { id: 'noap', factory: () => ({}) } } }))
applyless.loader.start()
await expect(applyless.loader.settled()).rejects.toThrow(/exports no apply/)
const b = fresh(() => bench([entry('a')], { '/plugins/a/client.js': okBundle() }))
await expect(b.loader.load('ghost')).rejects.toThrow(/unknown plugin "ghost"/)
expect(() => fresh(() => bench([entry('dup'), entry('dup')], {}))).toThrow(/duplicate manifest id/)
})
it('throws on missing boot manifest, double proxy install, and pre-start settled', () => {
expect(() => createClientLoader({ ctx: new Context(), modules: {} })).toThrow(/no boot manifest/)
const b = bench([], {})
expect(() => b.loader.settled()).toThrow(/settled\(\) before start\(\)/)
// First bench installed the proxy; a second loader must refuse.
expect(() => createClientLoader({ ctx: new Context(), modules: {}, boot: { plugins: [] } })).toThrow(/already installed/)
})
it('direct load() before a dependency is active fails loud (same check start() sequences)', async () => {
const b = bench(
[entry('dep', [], true), entry('needy', ['dep'])],
{ '/plugins/dep/client.js': okBundle(), '/plugins/needy/client.js': okBundle() },
)
await expect(b.loader.load('needy')).rejects.toThrow(/loaded before its dependency "dep" is active/)
})
it('direct load() naming an unknown inject target fails loud', async () => {
const b = bench([entry('solo', ['phantom'])], { '/plugins/solo/client.js': okBundle() })
await expect(b.loader.load('solo')).rejects.toThrow(/injects unknown plugin "phantom"/)
})
it('an immediately-group fetch failure surfaces through settled, not as an unhandled prefetch rejection', async () => {
// The fire-and-forget prefetch swallow arm must absorb the early
// rejection; the awaited load surfaces the same failure via settled().
const ctx = new Context()
delete win.DSHClientProxy
const loader = createClientLoader({
ctx,
modules: {},
boot: { plugins: [{ id: 'kaboom', url: '/plugins/kaboom/client.js', inject: [], immediately: true }] },
fetchBundle: () => Promise.reject(new Error('bundle fetch exploded')),
executeBundle: () => {},
})
loader.start()
await expect(loader.settled()).rejects.toThrow(/bundle fetch exploded/)
})
it('unload is the P-I stub', async () => {
const b = bench([], {})
await expect(b.loader.unload('x')).rejects.toThrow(/not implemented/)
})
})
describe('DOM default seams (stubbed globals)', () => {
it('default fetchBundle uses fetch, rejects non-OK; default executeBundle injects an inline script; claimStyles tags orphans', async () => {
const origFetch = globalThis.fetch
const appended: { textContent?: string | null }[] = []
const styleTag = {
attrs: {} as Record<string, string>,
setAttribute(k: string, v: string) { this.attrs[k] = v },
}
const fakeDoc = {
createElement: () => {
const el = { textContent: null as string | null }
return el
},
head: { appendChild: (el: { textContent?: string | null }) => { appended.push(el) } },
querySelectorAll: () => [styleTag],
}
const g = globalThis as { document?: unknown; fetch: typeof fetch }
g.document = fakeDoc
g.fetch = (url: URL | RequestInfo) => Promise.resolve(
(typeof url === 'string' ? url : url instanceof URL ? url.href : url.url).includes('bad')
? new Response('x', { status: 500 })
: new Response('window.DSHClientProxy.loadPlugin(globalThis.__seamHandoff)', { status: 200 }),
)
try {
delete win.DSHClientProxy
const ctx = new Context()
const loader = createClientLoader({
ctx,
modules: {},
boot: { plugins: [
{ id: 'seam-ok', url: '/plugins/seam-ok/client.js', inject: [] },
{ id: 'seam-bad', url: '/plugins/bad/client.js', inject: [] },
] },
// NO seams injected (keys omitted, not undefined — exactOptional):
// the DOM defaults are under test.
})
const seamHandoff: ClientPluginHandoff = {
id: 'seam-ok',
factory: () => ({ apply: () => {} }),
}
// Default executeBundle only APPENDS the script element (no execution in
// our fake DOM), so drive the handoff manually before load resolves it.
const loadOk = loader.load('seam-ok')
await Promise.resolve()
;(globalThis as Win).DSHClientProxy?.loadPlugin(seamHandoff)
await loadOk
expect(appended).toHaveLength(1)
expect(appended[0]?.textContent).toContain('sourceURL=/plugins/seam-ok/client.js')
expect(styleTag.attrs['data-plugin']).toBe('seam-ok')
await expect(loader.load('seam-bad')).rejects.toThrow(/answered 500/)
} finally {
g.fetch = origFetch
delete (globalThis as { document?: unknown }).document
}
})
})
describe('handoff slot protocol', () => {
it('rejects an overlapping loadPlugin before the loader claims the pending handoff', () => {
delete win.DSHClientProxy
createClientLoader({ ctx: new Context(), modules: {}, boot: { plugins: [] } })
const proxy = (globalThis as Win).DSHClientProxy
proxy?.loadPlugin({ id: 'first', factory: () => ({ apply: () => {} }) })
expect(() => proxy?.loadPlugin({ id: 'second', factory: () => ({ apply: () => {} }) }))
.toThrow(/overlapping loadPlugin handoff/)
})
})

View File

@@ -1,3 +1,4 @@
import { createUserMessage, createMessage, createToolResultMessage, CallId } from '@deepseek-ai/dsh-llm'
// Minimal SessionEvent builders for orchestration tests (shape mirrors what the
// host emits; only the fields the object layer reads).
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
@@ -13,7 +14,9 @@ export const ev = {
turnStart: (seq: number, turn: number): SessionEvent =>
at(seq, { type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } }),
user: (seq: number, body: string): SessionEvent =>
at(seq, { type: 'user/message', surfaceOp: 'append', data: { content: text(body), source: { kind: 'user' } } }),
at(seq, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
content: text(body), source: { kind: 'user' },
}) }),
stepStart: (seq: number, turn: number, step = 0): SessionEvent =>
at(seq, { type: 'step/start', data: { turn, step } }),
chunkStart: (seq: number, turn: number, step = 0, index = 0): SessionEvent =>
@@ -21,15 +24,51 @@ export const ev = {
chunkText: (seq: number, turn: number, piece: string, step = 0, index = 0): SessionEvent =>
at(seq, { type: 'assistant/chunk', data: { turn, step, chunk: { type: 'text-delta', index, text: piece } } }),
assistant: (seq: number, turn: number, body: string, step = 0): SessionEvent =>
at(seq, { type: 'assistant/message', surfaceOp: 'append', data: { turn, step, content: text(body), provenance: { provider: 'fake', model: 'fk-1' } } }),
at(seq, { type: 'assistant/message', surfaceOp: 'append', data: {
turn, step,
message: createMessage({
role: 'assistant',
content: text(body),
source: {
kind: 'model',
...{ provider: 'fake', model: 'fk-1' },
},
}),
} }),
toolCall: (seq: number, turn: number, callId: string, name: string, args: string, step = 0): SessionEvent =>
at(seq, { type: 'tool/call', data: { turn, step, callId, name, arguments: args } }),
toolResult: (seq: number, turn: number, callId: string, body: string, step = 0): SessionEvent =>
at(seq, { type: 'tool/result', surfaceOp: 'append', data: { turn, step, callId, content: text(body), isError: false } }),
at(seq, {
type: 'tool/result',
surfaceOp: 'append',
data: {
turn,
step,
message: createToolResultMessage({
callId: CallId(callId),
content: text(body),
isError: false,
}),
},
}),
codeDispatchStart: (seq: number, parentCallId: string, n: number, name: string, args: unknown): SessionEvent =>
at(seq, {
type: 'tool/code-dispatch-start',
data: { parentCallId, subCallId: `${parentCallId}:code:${n}`, name, arguments: args },
}),
codeDispatch: (seq: number, parentCallId: string, n: number, name: string, args: unknown, body: string, isError = false): SessionEvent =>
at(seq, {
type: 'tool/code-dispatch',
data: { parentCallId, subCallId: `${parentCallId}:code:${n}`, name, arguments: args, isError, content: text(body) },
}),
stepEnd: (seq: number, turn: number, step = 0): SessionEvent =>
at(seq, { type: 'step/end', data: { turn, step } }),
turnEnd: (seq: number, turn: number, reason: 'completed' | 'cancelled' = 'completed'): SessionEvent =>
at(seq, { type: 'turn/end', data: { turn, reason: { kind: reason } } }),
commandRun: (seq: number, commandId: string, name: string, args = ''): SessionEvent =>
at(seq, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } }),
commandDone: (seq: number, commandId: string, kind: 'success' | 'error' = 'success', text?: string): SessionEvent =>
at(seq, { type: 'command/done', data: { commandId, kind, ...text === undefined ? {} : { text } } }),
}
/** One complete plain turn (turn/start → user → step → assistant → turn/end), 6 events from startSeq. */

View File

@@ -1,11 +1,27 @@
// Test-local programmable IApiClient fake (NOT the fixture: fixture is a demo
// data source on a real clock; behavior tests need per-case responses and
// deferred-controlled timing). Streams are hand pumps: pushMux/pushHost.
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import type {
HostFrame, IApiClient, MuxFrame, RpcError, RpcRequest, RpcResponse, SessionId,
ClientResponse, CommandDescriptor, HostFrame, IApiClient, ModelTarget, MuxFrame,
RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SessionModels, SkillEntry,
WorkspaceId, WorkspaceView,
} from '@deepseek-ai/dsh-client-connection/client'
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
/** Programmable-default workspace row (branded id, ISO-ish times). */
function fakeWorkspace(id: string, over: Partial<WorkspaceView> = {}): WorkspaceView {
return {
workspaceId: id as WorkspaceId,
path: '/f/ws',
title: 'ws',
sessionIds: [],
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
...over,
}
}
export interface Deferred<T> {
promise: Promise<T>
resolve(value: T): void
@@ -46,14 +62,31 @@ export class FakeApiClient implements IApiClient {
// Programmable slots (defaults answer OK-empty); reassign per case.
onList: (payload: unknown) => Promise<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
readonly defaultModel: ModelTarget = { provider: 'deepseek', model: 'deepseek-v4-flash' }
onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number })
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean }>> =
() => Promise.resolve(ok({ events: [], hasMore: false }))
onModels: (payload: unknown) => Promise<RpcResponse<SessionModels>> = () => Promise.resolve(ok({
current: this.defaultModel,
groups: [{
id: 'deepseek',
name: 'DeepSeek',
models: [{ id: 'deepseek-v4-flash', name: 'DeepSeek V4 Flash' }],
}],
failures: [],
}))
onSelectModel: (payload: { provider: string; model: string }) =>
Promise<RpcResponse<{ selected: ModelTarget }>> =
payload => Promise.resolve(ok({ selected: { provider: payload.provider, model: payload.model } }))
onPrompt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onDescribe: (payload: unknown) => Promise<RpcResponse<{ version: string; cwd: string; attachedSessions: number }>> =
() => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 }))
onPickDirectory: (payload: unknown) => Promise<RpcResponse<{ path: string | null }>> =
() => Promise.resolve(ok({ path: null }))
onOpenPath: (payload: unknown) => Promise<RpcResponse<{ opened: true }>> =
() => Promise.resolve(ok({ opened: true as const }))
private readonly muxConns: StreamConn<MuxFrame>[] = []
private readonly hostConns: StreamConn<HostFrame>[] = []
@@ -66,16 +99,61 @@ export class FakeApiClient implements IApiClient {
create: (payload: unknown) => this.record('session.create', payload, this.onCreate(payload)),
history: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) =>
this.record('session.history', payload, this.onHistory(payload)),
models: (payload: unknown) => this.record('session.models', payload, this.onModels(payload)),
selectModel: (payload: { provider: string; model: string }) =>
this.record('session.selectModel', payload, this.onSelectModel(payload)),
prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)),
cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),
}
readonly host: IApiClient['host'] = {
describe: (payload: unknown) => this.record('host.describe', payload, this.onDescribe(payload)),
pickDirectory: (payload: unknown) => this.record('host.pickDirectory', payload, this.onPickDirectory(payload)),
openPath: (payload: unknown) => this.record('host.openPath', payload, this.onOpenPath(payload)),
}
onWorkspaceList: (payload: unknown) => Promise<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
onWorkspaceCreate: (payload: unknown) => Promise<RpcResponse<{ workspace: WorkspaceView; created: boolean }>> =
() => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws'), created: true }))
onWorkspaceRename: (payload: unknown) => Promise<RpcResponse<{ workspace: WorkspaceView }>> =
() => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws') }))
onWorkspaceDelete: (payload: unknown) => Promise<RpcResponse<{ deleted: true }>> =
() => Promise.resolve(ok({ deleted: true }))
onWorkspaceInsertSessionBefore: (payload: unknown) => Promise<RpcResponse<{ workspace: WorkspaceView }>> =
() => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws') }))
readonly workspace: IApiClient['workspace'] = {
list: (payload: unknown) => this.record('workspace.list', payload, this.onWorkspaceList(payload)),
create: (payload: unknown) => this.record('workspace.create', payload, this.onWorkspaceCreate(payload)),
rename: (payload: unknown) => this.record('workspace.rename', payload, this.onWorkspaceRename(payload)),
delete: (payload: unknown) => this.record('workspace.delete', payload, this.onWorkspaceDelete(payload)),
insertSessionBefore: (payload: unknown) =>
this.record('workspace.insertSessionBefore', payload, this.onWorkspaceInsertSessionBefore(payload)),
}
// Payloads stay `unknown` (lint-lane note above); response rows are the real
// wire shapes so cases can program requires-bearing catalogs and dual-address
// skill lists without casts.
onCommandList: (payload: unknown) => Promise<RpcResponse<{ commands: CommandDescriptor[] }>>
= () => Promise.resolve(ok({ commands: [] }))
onCommandExecute: (payload: unknown) => Promise<RpcResponse<{ matched: boolean; commandId?: CommandId }>>
= () => Promise.resolve(ok({ matched: false }))
onSkillList: (payload: unknown) => Promise<RpcResponse<{ skills: SkillEntry[] }>>
= () => Promise.resolve(ok({ skills: [] }))
readonly commands: IApiClient['commands'] = {
list: (payload: unknown) => this.record('command.list', payload, this.onCommandList(payload)),
execute: (payload: unknown) => this.record('command.execute', payload, this.onCommandExecute(payload)),
}
readonly skills: IApiClient['skills'] = {
list: (payload: unknown) => this.record('skill.list', payload, this.onSkillList(payload)),
}
readonly goals: IApiClient['goals'] = {
get: payload => this.record('goal.get', payload, Promise.resolve(ok({ goal: null }))),
create: payload => this.record('goal.create', payload, Promise.resolve(ok({ goal: null as unknown as never }))),
edit: payload => this.record('goal.edit', payload, Promise.resolve(ok({ goal: null as unknown as never }))),
pause: payload => this.record('goal.pause', payload, Promise.resolve(ok({ goal: null as unknown as never }))),
@@ -103,8 +181,10 @@ export class FakeApiClient implements IApiClient {
host: (_payload: unknown, signal: AbortSignal, onOpen?: () => void) => this.openStream(this.hostConns, signal, onOpen),
}
respond(): Promise<{ accepted: false; reason: 'not-pending' }> {
return Promise.resolve({ accepted: false, reason: 'not-pending' })
onRespond: (message: ClientResponse) => Promise<RpcReceipt> = () => Promise.resolve({ accepted: true })
respond(message: ClientResponse): Promise<RpcReceipt> {
return this.record('respond', message, this.onRespond(message))
}
/** Push one mux frame to every open mux stream (rpcId minted unless pinned by the case). */

View File

@@ -1,3 +1,4 @@
import { createUserMessage, CallId, createMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm'
/**
* FoldAdapter over the real core SurfaceManager: padding sentinels for paged
* windows, incremental append with node-cache identity, six-variant
@@ -39,8 +40,16 @@ describe('FoldAdapter', () => {
const events = [
ev.user(0, '用户'),
ev.assistant(1, 0, '助手'),
at(2, { type: 'steering/message', surfaceOp: 'append', data: { turn: 0, content: [{ type: 'text', text: '插话' }], source: { kind: 'user' } } }),
at(3, { type: 'context/message', surfaceOp: 'append', data: { content: [{ type: 'text', text: '上下文' }], source: { kind: 'plugin', plugin: 'p' } } }),
at(2, { type: 'steering/message', surfaceOp: 'append', data: {
turn: 0,
message: createUserMessage({
content: [{ type: 'text', text: '插话' }],
source: { kind: 'user' },
}),
} }),
at(3, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
content: [{ type: 'text', text: '上下文' }], source: { kind: 'plugin', plugin: 'p' },
}) }),
ev.toolCall(4, 0, 'c1', 'echo', '{"x":1}'),
ev.toolResult(5, 0, 'c1', '结果'),
]
@@ -76,7 +85,17 @@ describe('FoldAdapter', () => {
// An invalid surfaceOp on a surface-eligible event deterministically throws in the core fold.
const window = [
ev.user(10, '正常'),
at(11, { type: 'assistant/message', surfaceOp: 'bogus-op', data: { turn: 0, step: 0, content: [{ type: 'text', text: '坏 op' }], provenance: { provider: 'x', model: 'y' } } }),
at(11, { type: 'assistant/message', surfaceOp: 'bogus-op', data: {
turn: 0, step: 0,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: '坏 op' }],
source: {
kind: 'model',
...{ provider: 'x', model: 'y' },
},
}),
} }),
]
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
try {
@@ -98,7 +117,15 @@ describe('FoldAdapter', () => {
it('materializes a tool-result error field when present', () => {
const adapter = new FoldAdapter()
adapter.reset([
at(0, { type: 'tool/result', surfaceOp: 'append', data: { turn: 0, step: 0, callId: 'c1', content: [], isError: true, error: { name: 'Boom', code: 'boom' } } }),
at(0, { type: 'tool/result', surfaceOp: 'append', data: {
turn: 0, step: 0,
message: createToolResultMessage({
callId: CallId('c1'),
content: [],
isError: true,
}),
error: { name: 'Boom', code: 'boom' },
} }),
], 0)
expect(adapter.nodes().nodes[0]).toMatchObject({ kind: 'tool-result', isError: true, error: { code: 'boom' } })
})
@@ -142,4 +169,83 @@ describe('FoldAdapter', () => {
const node = adapter.nodes().nodes[0]
expect(node).toMatchObject({ kind: 'tool-result', call: null, callView: null, resultView: { title: '孤儿' } })
})
describe('command lifecycle nodes', () => {
it('folds a run/done pair into one settled node merged into flow order by seq', () => {
const adapter = new FoldAdapter()
adapter.reset([
ev.user(0, '先说话'),
ev.commandRun(1, 'cmd-1', 'plan'),
ev.commandDone(2, 'cmd-1', 'success', '已进入 plan mode'),
ev.assistant(3, 0, '然后回答'),
], 0)
const { nodes } = adapter.nodes()
expect(nodes.map(n => [n.kind, n.seq])).toEqual([['user', 0], ['command', 1], ['assistant', 3]])
expect(nodes[1]).toMatchObject({
kind: 'command', commandId: 'cmd-1', name: 'plan', args: '',
outcome: { kind: 'success', text: '已进入 plan mode' },
})
})
it('renders a run with no done as still executing (outcome null)', () => {
const adapter = new FoldAdapter()
adapter.reset([ev.commandRun(0, 'cmd-2', 'goal', ' ship it')], 0)
expect(adapter.nodes().nodes[0]).toMatchObject({
kind: 'command', name: 'goal', args: ' ship it', outcome: null,
})
})
it('soft-falls a done-only window into a node built from the done (cross-window cut)', () => {
const adapter = new FoldAdapter()
adapter.reset([ev.commandDone(80, 'cmd-3', 'error', '失败了')], 80)
expect(adapter.nodes().nodes[0]).toMatchObject({
kind: 'command', seq: 80, commandId: 'cmd-3', name: null, args: null,
outcome: { kind: 'error', text: '失败了' },
})
})
it('settles a live-appended done in place, keeping the node at the run seq', () => {
const adapter = new FoldAdapter()
adapter.reset(plainTurn(0, 0, 'q', 'a'), 0)
adapter.append(ev.commandRun(6, 'cmd-4', 'clear'))
const running = adapter.nodes().nodes.find(n => n.kind === 'command')
expect(running).toMatchObject({ outcome: null })
adapter.append(ev.commandDone(7, 'cmd-4'))
const settled = adapter.nodes().nodes.find(n => n.kind === 'command')
expect(settled).toMatchObject({ seq: 6, outcome: { kind: 'success' } })
// Settlement replaced the node object rather than mutating the published one.
expect(settled).not.toBe(running)
})
it('tails command nodes whose seq is past every surface node', () => {
const adapter = new FoldAdapter()
adapter.reset([ev.user(0, '问'), ev.commandRun(1, 'cmd-tail', 'plan')], 0)
expect(adapter.nodes().nodes.map(n => n.kind)).toEqual(['user', 'command'])
})
it('command nodes survive the degraded linear-scan branch', () => {
const adapter = new FoldAdapter()
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
try {
adapter.reset([
ev.commandRun(0, 'cmd-5', 'plan'),
ev.commandDone(1, 'cmd-5'),
at(2, { type: 'assistant/message', surfaceOp: 'bogus-op', data: {
turn: 0,
step: 0,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: '坏 op' }],
source: { kind: 'model', provider: 'x', model: 'y' },
}),
} }),
], 0)
const { nodes, degraded } = adapter.nodes()
expect(degraded).toBe(true)
expect(nodes.some(n => n.kind === 'command')).toBe(true)
} finally {
errorSpy.mockRestore()
}
})
})
})

View File

@@ -25,9 +25,11 @@ describe('runtime slots/changed invariant', () => {
const ctx = await setup()
expect(() => { emit(ctx, 'unrelated/event', 'x') }).not.toThrow()
await ctx.plugin(SlotsService).await() // fiber must reach ACTIVE — the audit reads strict ctx.get
// A real define bumps the version first and re-emits through onMutate —
// the audit sees version > 0 and stays quiet.
expect(() => ctx.slots.define('t-single', { kind: 'single', scope: 'root' })).not.toThrow()
// A real registration bumps the version first and re-emits through
// onMutate — the audit sees version > 0 and stays quiet. (Erased call:
// the typed register face rides the wave-1 ui-slots types.)
const slots = ctx.slots as unknown as { register(options: object, component: unknown): () => void }
expect(() => slots.register({ name: 'root' }, () => null)).not.toThrow()
})
it('fails loud on a missing key and on an emission with no applied mutation', async () => {

View File

@@ -8,12 +8,12 @@ import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-client-connecti
import { flattenLineage } from '../src/client/sessions/lineage.ts'
const s = (id: string, updatedAt: number, parent?: string): SessionSummary => ({
sessionId: id as SessionId, updatedAt, running: false,
sessionId: id as SessionId, updatedAt, running: false, blank: false,
...(parent !== undefined ? { parentSessionId: parent as SessionId } : {}),
})
describe('flattenLineage', () => {
it('sorts roots by updatedAt desc and expands children DFS with depth, children sorted too', () => {
it('keeps established root and sibling order while expanding children DFS with depth', () => {
const out = flattenLineage([
s('old-root', 10),
s('new-root', 30),
@@ -22,7 +22,7 @@ describe('flattenLineage', () => {
s('grandkid', 5, 'kid-new'),
])
expect(out.map(e => [e.sessionId, e.depth])).toEqual([
['new-root', 0], ['kid-new', 1], ['grandkid', 2], ['kid-old', 1], ['old-root', 0],
['old-root', 0], ['new-root', 0], ['kid-old', 1], ['kid-new', 1], ['grandkid', 2],
])
})

View File

@@ -12,8 +12,10 @@ import { entries, plainTurn } from './event-script.ts'
const S1 = 'fk-m1' as SessionId
const S2 = 'fk-m2' as SessionId
function summary(sessionId: SessionId, over: Partial<{ updatedAt: number; running: boolean; parentSessionId: SessionId }> = {}) {
return { sessionId, updatedAt: 100, running: false, ...over }
type SummaryOver = Partial<{ updatedAt: number; running: boolean; blank: boolean; parentSessionId: SessionId }>
function summary(sessionId: SessionId, over: SummaryOver = {}) {
return { sessionId, updatedAt: 100, running: false, blank: false, ...over }
}
describe('instances', () => {
@@ -34,7 +36,7 @@ describe('instances', () => {
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
manager.handleMuxEnvelope({ rpcId: 're' as never, payload: { type: 'session/event', sessionId: S1, event: plainTurn(0, 0, 'x', 'y')[0] as never } })
const session = manager.get(S1)
expect(session.getSnapshot().pending).toMatchObject([{ kind: 'approval', approvalId: 'ap1' }])
expect(session.getSnapshot().pending).toMatchObject([{ kind: 'approval', payload: { approvalId: 'ap1' } }])
// Buffer cleared: a second instantiation of another id gets nothing.
expect(manager.get(S2).getSnapshot().pending).toEqual([])
})
@@ -48,7 +50,7 @@ describe('instances', () => {
}
const pending = manager.get(S1).getSnapshot().pending
expect(pending).toHaveLength(32)
expect(pending.map(p => p.rpcId)).toEqual(Array.from({ length: 32 }, (_, i) => `q${i + 8}`)) // oldest 8 dropped
expect(pending.map(p => p.key)).toEqual(Array.from({ length: 32 }, (_, i) => `q:q${i + 8}`)) // oldest 8 dropped
// Removed session: buffered frames must not replay on a future instantiation.
manager.handleMuxEnvelope({ rpcId: 'qz' as never, payload: { type: 'question/requested', sessionId: S2, questions: [] } })
manager.handleHostEnvelope({ rpcId: 'hz' as never, payload: { type: 'host/session-removed', sessionId: S2 } })
@@ -57,7 +59,7 @@ describe('instances', () => {
})
describe('list lifecycle', () => {
it('single-flights refreshList and lands items sorted through lineage flattening', async () => {
it('single-flights refreshList and preserves the Host baseline order', async () => {
const api = new FakeApiClient()
const gate = deferred<Awaited<ReturnType<FakeApiClient['onList']>>>()
api.onList = () => gate.promise
@@ -65,12 +67,33 @@ describe('list lifecycle', () => {
const first = manager.refreshList()
const second = manager.refreshList()
expect(manager.getListSnapshot().state).toBe('loading')
gate.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] }))
gate.resolve(ok({ items: [summary(S2, { updatedAt: 200 }), summary(S1)] as never[] }))
await Promise.all([first, second])
expect(api.callsOf('session.list')).toHaveLength(1)
const snapshot = manager.getListSnapshot()
expect(snapshot.state).toBe('idle')
expect(snapshot.items.map(i => i.sessionId)).toEqual([S2, S1]) // updatedAt desc
expect(snapshot.items.map(i => i.sessionId)).toEqual([S2, S1])
})
it('replays incremental frames over hydration and never batch-reorders established ids', async () => {
const api = new FakeApiClient()
const first = deferred<Awaited<ReturnType<FakeApiClient['onList']>>>()
api.onList = () => first.promise
const manager = new SessionManager(api)
const hydration = manager.refreshList()
manager.handleHostEnvelope({
rpcId: 'during-first' as never,
payload: { type: 'host/session-added', blank: true, sessionId: S2 },
})
first.resolve(ok({ items: [summary(S1)] as never[] }))
await hydration
expect(manager.getListSnapshot().items.map(item => item.sessionId)).toEqual([S2, S1])
api.onList = () => Promise.resolve(ok({
items: [summary(S1, { updatedAt: 900 }), summary(S2, { updatedAt: 800 })] as never[],
}))
await manager.refreshList()
expect(manager.getListSnapshot().items.map(item => item.sessionId)).toEqual([S2, S1])
})
it('keeps the error in the list snapshot on failure', async () => {
@@ -79,6 +102,26 @@ describe('list lifecycle', () => {
const manager = new SessionManager(api)
await manager.refreshList()
expect(manager.getListSnapshot()).toMatchObject({ state: 'error', error: { code: 'internal' } })
// A failed pull does not step the arrival phase: still pending.
expect(manager.getListSnapshot().phase).toBe('pending')
})
it('phase steps pending → ready on the first successful pull and never returns', async () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)
expect(manager.getListSnapshot().phase).toBe('pending')
await manager.refreshList()
expect(manager.getListSnapshot().phase).toBe('ready')
// Sticky across later failures: the pull-activity axis reports the error,
// the arrival phase holds.
api.onList = () => Promise.resolve(err({ code: 'internal', message: 'down', details: {} }))
await manager.refreshList()
expect(manager.getListSnapshot()).toMatchObject({ state: 'error', phase: 'ready' })
// And across an empty re-pull (empty-with-ready = truly no sessions).
api.onList = () => Promise.resolve(ok({ items: [] as never[] }))
await manager.refreshList()
expect(manager.getListSnapshot()).toMatchObject({ state: 'idle', phase: 'ready' })
expect(manager.getListSnapshot().items).toEqual([])
})
it('merges create into the list immediately without waiting for a refresh', async () => {
@@ -89,14 +132,64 @@ describe('list lifecycle', () => {
expect(result).toMatchObject({ ok: true, value: { sessionId: 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 () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)
const titleFrame = (rpcId: string, title: string, seq: number) => {
manager.handleMuxEnvelope({
rpcId: rpcId as never,
payload: { type: 'session/projection', sessionId: S1, key: 'title', value: title, seq } as never,
})
}
titleFrame('title-new', 'Newest', 4)
titleFrame('title-stale', 'Stale', 3)
titleFrame('title-equal', 'Equal', 4)
api.onList = () => Promise.resolve(ok({
items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[],
}))
await manager.refreshList()
const titled = manager.getListSnapshot()
expect(titled.items.map(item => item.sessionId)).toEqual([S1, S2])
expect(titled.items[0]?.title).toBe('Newest')
expect(titled.items[1]?.title).toBeUndefined()
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()
})
it('drops a projection row beyond the subscription baseline before accepting its durable replay', async () => {
const api = new FakeApiClient()
api.onList = () => Promise.resolve(ok({ items: [summary(S1)] as never[] }))
const manager = new SessionManager(api)
await manager.refreshList()
const frame = (rpcId: string, payload: object) => {
manager.handleMuxEnvelope({ rpcId: rpcId as never, payload: payload as never })
}
frame('title-unflushed', { type: 'session/projection', sessionId: S1, key: 'title', value: 'Unflushed', seq: 4 })
// The durable baseline says the host only knows up to seq 2: the phantom
// row rode lost state and must drop, or last-wins pins it forever.
frame('subscribed-recovered', { type: 'session/subscribed', sessionId: S1, lastSeq: 2 })
expect(manager.getListSnapshot().items[0]?.title).toBeUndefined()
frame('title-durable', { type: 'session/projection', sessionId: S1, key: 'title', value: 'Durable', seq: 2 })
expect(manager.getListSnapshot().items[0]?.title).toBe('Durable')
// A baseline at or past the row's seq keeps it (nothing phantom to drop).
frame('subscribed-current', { type: 'session/subscribed', sessionId: S1, lastSeq: 2 })
expect(manager.getListSnapshot().items[0]?.title).toBe('Durable')
})
})
describe('host frame routing', () => {
it('adds/removes/flips sessions from host frames and keeps removed instances resident', async () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1 } })
manager.handleHostEnvelope({ rpcId: 'h2' as never, payload: { type: 'host/session-added', sessionId: S1 } }) // dup: ignored
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } })
manager.handleHostEnvelope({ rpcId: 'h2' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } }) // dup: ignored
expect(manager.getListSnapshot().items).toHaveLength(1)
const session = manager.get(S1)
@@ -132,14 +225,14 @@ describe('remaining branches', () => {
expect(session.getSnapshot().running).toBe(true)
})
it('create passes cwd through, folds transport throws, and skips the merge when already listed', async () => {
it('create passes cwd and a preallocated id, folds transport throws, and deduplicates the echo', async () => {
const api = new FakeApiClient()
api.onCreate = () => Promise.resolve(ok({ sessionId: S1 }))
const manager = new SessionManager(api)
await manager.create('/tmp/w')
expect(api.callsOf('session.create')).toEqual([{ cwd: '/tmp/w' }])
await manager.create({ cwd: '/tmp/w', sessionId: S1 })
expect(api.callsOf('session.create')).toEqual([{ cwd: '/tmp/w', sessionId: S1 }])
expect(manager.getListSnapshot().items[0]).toMatchObject({ sessionId: S1, cwd: '/tmp/w' })
await manager.create('/tmp/w') // same id returned: no duplicate row
await manager.create({ cwd: '/tmp/w' }) // same id returned: no duplicate row
expect(manager.getListSnapshot().items).toHaveLength(1)
api.onCreate = () => Promise.reject(new Error('create wire down'))
expect(await manager.create()).toMatchObject({ ok: false, error: { code: 'internal' } })
@@ -148,6 +241,42 @@ describe('remaining branches', () => {
expect(await manager.create()).toMatchObject({ ok: false })
})
it('publishes a real Ungrouped summary from workspace-attach-failed', async () => {
const api = new FakeApiClient()
api.onCreate = () => Promise.resolve(err({
code: 'workspace-attach-failed',
message: 'published but unattached',
details: { sessionId: S1, workspaceId: 'w1' },
} as never))
const manager = new SessionManager(api)
const result = await manager.create({ workspaceId: 'w1' as never, sessionId: S1 })
expect(result).toMatchObject({ ok: false, error: { code: 'workspace-attach-failed' } })
expect(manager.getListSnapshot().items).toEqual([expect.objectContaining({ sessionId: S1 })])
expect(manager.getListSnapshot().items[0]).not.toHaveProperty('cwd')
})
it('reconciles a preallocated id after an ordinary transport failure', async () => {
const api = new FakeApiClient()
api.onCreate = () => Promise.reject(new Error('response lost'))
const manager = new SessionManager(api)
const failed = await manager.create({ workspaceId: 'w1' as never, sessionId: S1 })
expect(failed).toMatchObject({ ok: false, error: { message: 'response lost' } })
expect(manager.getListSnapshot().items).toEqual([])
manager.handleHostEnvelope({
rpcId: 'published-later' as never,
payload: { type: 'host/session-added', blank: true, sessionId: S1, cwd: '/w/one' },
})
expect(manager.getListSnapshot().items).toEqual([
expect.objectContaining({ sessionId: S1, cwd: '/w/one' }),
])
manager.handleHostEnvelope({
rpcId: 'duplicate-frame' as never,
payload: { type: 'host/session-added', blank: true, sessionId: S1, cwd: '/w/one' },
})
expect(manager.getListSnapshot().items).toHaveLength(1)
})
it('subscribe notifies on list changes and stops after unsubscribe', async () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)
@@ -158,7 +287,7 @@ describe('remaining branches', () => {
expect(notified).toBeGreaterThan(0)
const seen = notified
unsubscribe()
manager.handleHostEnvelope({ rpcId: 'h' as never, payload: { type: 'host/session-added', sessionId: S1 } })
manager.handleHostEnvelope({ rpcId: 'h' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } })
await new Promise(resolve => setTimeout(resolve, 0))
expect(notified).toBe(seen)
})
@@ -197,8 +326,8 @@ describe('remaining branches', () => {
it('carries parentSessionId from host/session-added into the lineage row', () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1 } })
manager.handleHostEnvelope({ rpcId: 'h2' as never, payload: { type: 'host/session-added', sessionId: S2, parentSessionId: S1 } })
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } })
manager.handleHostEnvelope({ rpcId: 'h2' as never, payload: { type: 'host/session-added', blank: true, sessionId: S2, parentSessionId: S1 } })
const items = manager.getListSnapshot().items
expect(items.find(e => e.sessionId === S2)).toMatchObject({ parentSessionId: S1, depth: 1 })
})
@@ -207,7 +336,11 @@ describe('remaining branches', () => {
describe('connected generation', () => {
it('refreshes the list and resyncs only opened instances', async () => {
const api = new FakeApiClient()
api.onHistory = () => Promise.resolve(ok({ events: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false }))
api.onHistory = () => Promise.resolve(ok({
events: entries(plainTurn(0, 0, 'a', 'b')) as never[],
hasMore: false,
modelTarget: { provider: 'deepseek', model: 'deepseek-chat' },
}))
const manager = new SessionManager(api)
const openedSession = manager.get(S1)
await openedSession.open()

View File

@@ -0,0 +1,187 @@
/**
* Projection value store (session-projection RFC, push model): the single
* higher-seq-wins rule on both paths (a stale baseline cannot overwrite a
* newer push frame; a replayed frame cannot regress), capability absence as
* undefined, generation truncation, and the Session/manager wiring (tail-page
* seeding, session/projection frame routing pre- and post-instantiation, the
* list rows' title projection).
*/
import { describe, expect, it } from 'vitest'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import { ProjectionValueStore } from '../src/client/sessions/projection-store.ts'
import { Session } from '../src/client/sessions/session.ts'
import { SessionManager } from '../src/client/sessions/manager.ts'
import { FakeApiClient, ok } from './fake-api.ts'
import { entries, plainTurn } from './event-script.ts'
// Test-domain keys merged into the projection map (the interface package's
// pure-type outlet), the same way domain host plugins merge theirs.
declare module '@deepseek-ai/dsh-session-projection/types' {
interface SessionProjectionMap {
'test/marks': { marks: string[] }
}
}
const SID = 'fk-s1' as SessionId
describe('ProjectionValueStore semantics', () => {
it('reads undefined until a value lands (capability absence)', () => {
const store = new ProjectionValueStore()
expect(store.get('test/marks')).toBeUndefined()
expect(store.faceOf('test/marks').getSnapshot()).toBeUndefined()
})
it('applies frames last-wins by seq: replayed and stale frames drop', () => {
const store = new ProjectionValueStore()
store.apply('test/marks', { marks: ['a'] }, 5)
store.apply('test/marks', { marks: ['a', 'b'] }, 9)
expect(store.get('test/marks')).toEqual({ marks: ['a', 'b'] })
store.apply('test/marks', { marks: ['stale'] }, 5)
store.apply('test/marks', { marks: ['equal'] }, 9)
expect(store.get('test/marks')).toEqual({ marks: ['a', 'b'] })
})
it('a stale baseline can neither overwrite nor clear a newer frame; a fresh one reseeds and clears', () => {
const store = new ProjectionValueStore()
store.apply('test/marks', { marks: ['frame-20'] }, 20)
// Stale cut: carried key loses to the newer frame; omitted key survives.
store.seed({ asOfSeq: 10, values: { 'test/marks': { marks: ['baseline-10'] } } })
expect(store.get('test/marks')).toEqual({ marks: ['frame-20'] })
store.seed({ asOfSeq: 15, values: {} })
expect(store.get('test/marks')).toEqual({ marks: ['frame-20'] })
// Fresh cut: carried key reseeds…
store.seed({ asOfSeq: 30, values: { 'test/marks': { marks: ['baseline-30'] } } })
expect(store.get('test/marks')).toEqual({ marks: ['baseline-30'] })
// …and an omitting fresh cut clears (capability absent as of the cut).
store.seed({ asOfSeq: 40, values: {} })
expect(store.get('test/marks')).toBeUndefined()
})
it('truncate drops rows past the durable baseline and keeps the rest', () => {
const store = new ProjectionValueStore()
store.apply('test/marks', { marks: ['durable'] }, 5)
store.apply('other', 'phantom', 50)
store.truncate(10)
expect(store.get('test/marks')).toEqual({ marks: ['durable'] })
expect(store.get('other')).toBeUndefined()
})
it('notifies the key face on change (batched) and not on dropped applications', async () => {
const store = new ProjectionValueStore()
let keyTicks = 0
let anyTicks = 0
store.faceOf('test/marks').subscribe(() => { keyTicks += 1 })
store.subscribeAny(() => { anyTicks += 1 })
store.apply('test/marks', { marks: ['a'] }, 5)
await Promise.resolve()
expect(keyTicks).toBe(1)
expect(anyTicks).toBe(1)
store.apply('test/marks', { marks: ['replay'] }, 3)
await Promise.resolve()
expect(keyTicks).toBe(1)
expect(anyTicks).toBe(1)
})
it('faces are identity-stable per key (the React binding cache premise)', () => {
const store = new ProjectionValueStore()
expect(store.faceOf('test/marks')).toBe(store.faceOf('test/marks'))
})
})
describe('Session tail-page seeding', () => {
it('seeds the store from a history response carrying a projections block', async () => {
const api = new FakeApiClient()
const session = new Session(SID, api)
api.onHistory = () => Promise.resolve(ok({
events: entries(plainTurn(0, 0, '问', '答')) as never[], hasMore: false,
projections: { asOfSeq: 5, values: { 'test/marks': { marks: ['from-baseline'] } } },
} as never))
await session.open()
expect(session.projections.get('test/marks')).toEqual({ marks: ['from-baseline'] })
})
it('a resync serving a stale block keeps the newer pushed value (seq rule end to end)', async () => {
const api = new FakeApiClient()
const session = new Session(SID, api)
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()
session.projections.apply('test/marks', { marks: ['pushed-9'] }, 9)
await session.resync()
expect(session.projections.get('test/marks')).toEqual({ marks: ['pushed-9'] })
})
it('treats a blockless response as no reset: pushed values survive', async () => {
const api = new FakeApiClient()
const session = new Session(SID, api)
api.onHistory = () => Promise.resolve(ok({ events: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false }))
await session.open()
session.projections.apply('test/marks', { marks: ['pushed'] }, 9)
await session.resync()
expect(session.projections.get('test/marks')).toEqual({ marks: ['pushed'] })
})
})
describe('manager frame routing', () => {
const sid = (s: string): SessionId => s as SessionId
it('lands session/projection frames before instantiation and the Session adopts the same store', async () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)
manager.handleMuxEnvelope({
rpcId: 'p1' as never,
payload: { type: 'session/projection', sessionId: sid('s1'), key: 'test/marks', value: { marks: ['early'] }, seq: 7 } as never,
})
const session = manager.get(sid('s1'))
expect(session.projections.get('test/marks')).toEqual({ marks: ['early'] })
// Frames after instantiation land in the same store.
manager.handleMuxEnvelope({
rpcId: 'p2' as never,
payload: { type: 'session/projection', sessionId: sid('s1'), key: 'test/marks', value: { marks: ['later'] }, seq: 9 } as never,
})
expect(session.projections.get('test/marks')).toEqual({ marks: ['later'] })
})
it('projects the title key into list rows and truncates phantom rows on the subscribed baseline', async () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)
api.onList = () => Promise.resolve(ok({
items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }],
}) as never)
await manager.refreshList()
manager.handleMuxEnvelope({
rpcId: 't1' as never,
payload: { type: 'session/projection', sessionId: sid('s1'), key: 'title', value: 'Projected title', seq: 4 } as never,
})
await Promise.resolve()
expect(manager.getListSnapshot().items[0]?.title).toBe('Projected title')
// The durable baseline says the host only knows up to seq 2: the row rode
// lost state and must drop (the un-flushed title precedent).
manager.handleMuxEnvelope({
rpcId: 'sub' as never,
payload: { type: 'session/subscribed', sessionId: sid('s1'), lastSeq: 2 } as never,
})
await Promise.resolve()
expect(manager.getListSnapshot().items[0]?.title).toBeUndefined()
})
it('drops the projection store with the removed session', async () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)
api.onList = () => Promise.resolve(ok({
items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }],
}) as never)
await manager.refreshList()
manager.handleMuxEnvelope({
rpcId: 't1' as never,
payload: { type: 'session/projection', sessionId: sid('s1'), key: 'title', value: 'Doomed', seq: 4 } as never,
})
manager.handleHostEnvelope({
rpcId: 'rm' as never,
payload: { type: 'host/session-removed', sessionId: sid('s1') } as never,
})
expect(manager.get(sid('s1')).projections.get('title')).toBeUndefined()
})
})

View File

@@ -0,0 +1,234 @@
/**
* Queue mirror semantics (web input-triggers queue cut 1): session/queued
* intake, host-rule retirement (message turn/start claims oldest non-steering;
* steering/message drains by source), leave-running sweep, reconnect reset,
* pre-instantiation buffering, and snapshot reference stability.
*/
import { describe, expect, it } from 'vitest'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { MuxFrame, RpcId, SessionId } from '@deepseek-ai/dsh-client-connection/client'
import { Session } from '../src/client/sessions/session.ts'
import { SessionManager } from '../src/client/sessions/manager.ts'
import { FakeApiClient } from './fake-api.ts'
import { ev } from './event-script.ts'
const SID = 'fk-q1' as SessionId
const text = (t: string): ContentBlock[] => [{ type: 'text', text: t }]
const rid = (id: string): RpcId => id as RpcId
/** session/queued frame with the wire-sourced rpcId key (the host prompt path). */
function queuedFrame(body: string, rpcId: string, steering = false): MuxFrame {
return {
type: 'session/queued',
sessionId: SID,
message: createUserMessage({
content: text(body),
source: { kind: 'user', rpcId: rid(rpcId) } as never,
}),
steering,
}
}
function makeSession(): Session {
return new Session(SID, new FakeApiClient())
}
describe('queue intake', () => {
it('lands a queued frame as a row keyed by the source rpcId with a flat preview', () => {
const session = makeSession()
session.handleMuxEnvelope(rid('env-1'), queuedFrame('第一条 排队\n消息', 'p-1'))
const queue = session.getSnapshot().queue
expect(queue).toEqual([{ key: 'p-1', preview: '第一条 排队 消息' }])
})
it('falls back to the envelope rpcId when the source carries none, and tags non-text blocks', () => {
const session = makeSession()
session.handleMuxEnvelope(rid('env-2'), {
type: 'session/queued',
sessionId: SID,
message: createUserMessage({
content: [{ type: 'text', text: 'hi' }, { type: 'image', data: 'x' } as never],
source: { kind: 'plugin', plugin: 'loop' },
}),
steering: false,
})
expect(session.getSnapshot().queue).toEqual([{ key: 'f:env-2', preview: 'hi [image]' }])
})
it('caps the preview at 200 code points with an ellipsis', () => {
const session = makeSession()
session.handleMuxEnvelope(rid('env-3'), queuedFrame('长'.repeat(201), 'p-cap'))
const preview = session.getSnapshot().queue[0]?.preview ?? ''
expect(Array.from(preview)).toHaveLength(201) // 200 + …
expect(preview.endsWith('…')).toBe(true)
})
it('keeps the queue array reference stable across unrelated snapshot swaps', () => {
const session = makeSession()
session.handleMuxEnvelope(rid('env-4'), queuedFrame('稳定', 'p-s'))
const before = session.getSnapshot().queue
session.handleAgentError('unrelated') // dirties the snapshot without touching the queue
expect(session.getSnapshot().queue).toBe(before)
})
})
describe('queue retirement (host queuedMirror rules)', () => {
it('a message-triggered turn/start claims the oldest non-steering row', () => {
const session = makeSession()
session.handleMuxEnvelope(rid('e1'), queuedFrame('先', 'p-1'))
session.handleMuxEnvelope(rid('e2'), queuedFrame('后', 'p-2'))
session.handleMuxEnvelope(rid('e3'), { type: 'session/event', sessionId: SID, event: ev.turnStart(0, 0) })
expect(session.getSnapshot().queue.map(r => r.key)).toEqual(['p-2'])
})
it('an injection-triggered turn/start claims nothing', () => {
const session = makeSession()
session.handleMuxEnvelope(rid('e1'), queuedFrame('留', 'p-1'))
const injection = {
...ev.turnStart(0, 0),
data: { turn: 0, trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'x' } } },
} as never
session.handleMuxEnvelope(rid('e2'), { type: 'session/event', sessionId: SID, event: injection })
expect(session.getSnapshot().queue).toHaveLength(1)
})
it('steering/message drains the source-matched steering row only', () => {
const session = makeSession()
session.handleMuxEnvelope(rid('e1'), queuedFrame('普通', 'p-1')) // idle → non-steering
session.handleMuxEnvelope(rid('e3'), queuedFrame('插话', 'p-2', true))
// Loop-authored steering (different source) must not consume the user entry.
const foreignSteering = {
seq: 0, time: 1,
type: 'steering/message', surfaceOp: 'append',
data: {
turn: 0,
message: createUserMessage({
content: text('loop'),
source: { kind: 'plugin', plugin: 'loop' },
}),
},
} as never
session.handleMuxEnvelope(rid('e4'), { type: 'session/event', sessionId: SID, event: foreignSteering })
expect(session.getSnapshot().queue).toHaveLength(2)
const matchedSteering = {
seq: 1, time: 2,
type: 'steering/message', surfaceOp: 'append',
data: {
turn: 0,
message: createUserMessage({
content: text('插话'),
source: { kind: 'user', rpcId: rid('p-2') },
}),
},
} as never
session.handleMuxEnvelope(rid('e5'), { type: 'session/event', sessionId: SID, event: matchedSteering })
expect(session.getSnapshot().queue.map(r => r.key)).toEqual(['p-1'])
})
it('a leave-running flip sweeps the whole mirror (cancel/terminal-drop cover)', () => {
const session = makeSession()
session.handleRunning(true)
session.handleMuxEnvelope(rid('e1'), queuedFrame('一', 'p-1'))
session.handleMuxEnvelope(rid('e2'), queuedFrame('二', 'p-2'))
session.handleRunning(false)
expect(session.getSnapshot().queue).toEqual([])
})
it('a stale not-running relay on an idle session still sweeps replayed rows', () => {
const session = makeSession()
session.handleMuxEnvelope(rid('e1'), queuedFrame('孤儿', 'p-1'))
session.handleRunning(false) // running already false: equality path must not skip the sweep
expect(session.getSnapshot().queue).toEqual([])
})
})
describe('queue reconnect semantics', () => {
it('session/subscribed re-baselines the mirror: stale rows drop, the following snapshot lands fresh', () => {
const session = makeSession()
session.handleMuxEnvelope(rid('e1'), queuedFrame('旧连接', 'p-old'))
// New mux generation: subscribed arrives first on the same stream...
session.handleMuxEnvelope(rid('e2'), { type: 'session/subscribed', sessionId: SID, lastSeq: 10 })
expect(session.getSnapshot().queue).toEqual([])
// ...then the queue snapshot replays the live inbox.
session.handleMuxEnvelope(rid('e3'), queuedFrame('新基线', 'p-new'))
expect(session.getSnapshot().queue.map(r => r.key)).toEqual(['p-new'])
})
it('resync must NOT clear the mirror (regression: onConnected races the mux baseline)', async () => {
const session = makeSession()
// Reconnect ordering that broke: mux opened first and already delivered
// the fresh generation's baseline; host stream (and with it onConnected →
// resync) lands after. The host never resends — clearing here left the
// dock empty until the next enqueue.
session.handleMuxEnvelope(rid('e1'), { type: 'session/subscribed', sessionId: SID, lastSeq: 5 })
session.handleMuxEnvelope(rid('e2'), queuedFrame('新基线', 'p-fresh'))
await session.resync()
expect(session.getSnapshot().queue.map(r => r.key)).toEqual(['p-fresh'])
})
it('replayed steering retires without a replayed turn/start', () => {
const session = makeSession()
session.handleMuxEnvelope(rid('e1'), { type: 'session/subscribed', sessionId: SID, lastSeq: 5 })
session.handleMuxEnvelope(rid('e2'), queuedFrame('重连插话', 'p-steer', true))
const committed = {
seq: 6, time: 2,
type: 'steering/message', surfaceOp: 'append',
data: {
turn: 1,
message: createUserMessage({
content: text('重连插话'),
source: { kind: 'user', rpcId: rid('p-steer') },
}),
},
} as never
session.handleMuxEnvelope(rid('e3'), { type: 'session/event', sessionId: SID, event: committed })
expect(session.getSnapshot().queue).toEqual([])
})
})
describe('manager buffering of queued frames', () => {
it('buffers session/queued for uninstantiated sessions and replays before the running sync', () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)
manager.handleMuxEnvelope({ rpcId: rid('b1'), payload: queuedFrame('预热', 'p-b1') })
// Instantiation replays the buffer; no summary exists, so no running sweep runs.
const session = manager.get(SID)
expect(session.getSnapshot().queue.map(r => r.key)).toEqual(['p-b1'])
// The buffer is consumed: a second get must not double-replay.
expect(manager.get(SID).getSnapshot().queue).toHaveLength(1)
})
it('a not-running list summary sweeps replayed rows at instantiation', async () => {
const api = new FakeApiClient()
api.onList = () => Promise.resolve(ok([{ sessionId: SID, updatedAt: 1, running: false }]))
const manager = new SessionManager(api)
await manager.refreshList()
manager.handleMuxEnvelope({ rpcId: rid('b2'), payload: queuedFrame('该扫掉', 'p-b2') })
expect(manager.get(SID).getSnapshot().queue).toEqual([])
})
it('subscribed re-baselines the uninstantiated buffer: stale queued frames drop, non-queue frames survive (regression: reconnect duplication)', () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)
// Generation 1 baseline lands while the session is uninstantiated, along
// with a pending approval (never re-derivable from history).
manager.handleMuxEnvelope({ rpcId: rid('g1a'), payload: queuedFrame('第一代', 'p-g1') })
manager.handleMuxEnvelope({
rpcId: rid('g1b'),
payload: { type: 'approval/requested', sessionId: SID, approvalId: 'ap-1' as never, toolName: 'bash' },
})
// Reconnect: generation 2 replays subscribed + the SAME live queue entry.
manager.handleMuxEnvelope({ rpcId: rid('g2a'), payload: { type: 'session/subscribed', sessionId: SID, lastSeq: 3 } })
manager.handleMuxEnvelope({ rpcId: rid('g2b'), payload: queuedFrame('第一代', 'p-g1') })
const snapshot = manager.get(SID).getSnapshot()
// One queue row (no duplicate batch); the approval survived the re-baseline.
expect(snapshot.queue.map(r => r.key)).toEqual(['p-g1'])
expect(snapshot.pending.map(p => p.kind)).toEqual(['approval'])
})
})
/** ok wrapper with a typed items payload (the shared helper pins value to never[]). */
function ok(items: { sessionId: SessionId; updatedAt: number; running: boolean }[]) {
return { rpcId: rid(`ok-${items.length}`), result: { ok: true as const, value: { items: items as never[] } } }
}

View File

@@ -0,0 +1,84 @@
/**
* Agent-scope primitive spec: the actx minted by createScope carries the
* tag and the dispatch filter itself, so plain cordis dispatch with the actx
* as subject routes by agent — same-agent tagged listeners receive,
* foreign-agent ones are filtered out, untagged listeners hear everything,
* and a subject-less root dispatch stays unfiltered. Scope-owned listeners
* dispose with the fiber.
*/
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import { createScope, scopeOf } from '../src/client/agents/scope.ts'
const sid = (k: string): SessionId => k as SessionId
declare module 'cordis' {
interface Events {
/**
* Test-only routed probe event.
* @param payload - marker payload.
* @mode bail
*/
'test/scope-probe'(payload: { from: string }): true | undefined
}
}
function bench() {
const root = new Context()
const a = createScope(root, sid('a'))
const b = createScope(root, sid('b'))
const seen: string[] = []
const listen = (label: string, ctx: Context, answer?: true) => {
ctx.on('test/scope-probe', (payload) => {
seen.push(`${label}:${payload.from}`)
return answer
})
}
return { root, a, b, seen, listen }
}
describe('createScope', () => {
it('tags the ctx (scopeOf) and leaves the root untagged', () => {
const { root, a } = bench()
expect(scopeOf(a.ctx)).toBe(sid('a'))
expect(scopeOf(root)).toBeUndefined()
})
it('scoped dispatch reaches same-session and untagged listeners, never a foreign session', () => {
const { root, a, b, seen, listen } = bench()
listen('a', a.ctx)
listen('b', b.ctx)
listen('root', root)
a.ctx.bail(a.ctx, 'test/scope-probe', { from: 'a' })
expect(seen).toEqual(['a:a', 'root:a'])
seen.length = 0
b.ctx.emit(b.ctx, 'test/scope-probe', { from: 'b' })
expect(seen).toEqual(['b:b', 'root:b'])
})
it('bail answers the first same-scope listener and skips filtered foreign ones', () => {
const { a, b, listen } = bench()
listen('b', b.ctx, true) // registered first, but foreign → filtered out
expect(a.ctx.bail(a.ctx, 'test/scope-probe', { from: 'a' })).toBeUndefined()
listen('a', a.ctx, true)
expect(a.ctx.bail(a.ctx, 'test/scope-probe', { from: 'a' })).toBe(true)
})
it('a subject-less root dispatch is unfiltered (every listener hears it)', () => {
const { root, a, b, seen, listen } = bench()
listen('a', a.ctx)
listen('b', b.ctx)
listen('root', root)
root.emit('test/scope-probe', { from: 'root' })
expect(seen).toEqual(['a:root', 'b:root', 'root:root'])
})
it('fiber disposal removes scope-owned listeners', async () => {
const { a, seen, listen } = bench()
listen('a', a.ctx)
await a.fiber.dispose()
a.ctx.emit(a.ctx, 'test/scope-probe', { from: 'late' })
expect(seen).toEqual([])
})
})

View File

@@ -7,7 +7,6 @@
*/
import { describe, expect, it, vi } from 'vitest'
import type { GoalView } from '@deepseek-ai/dsh-client-connection/client'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import { Session } from '../src/client/sessions/session.ts'
@@ -76,7 +75,11 @@ describe('open', () => {
const page = plainTurn(10, 0, '早', '安')
session.handleMuxEnvelope('r1' as never, { type: 'session/event', sessionId: SID, event: ev.turnStart(15, 1) })
session.handleMuxEnvelope('r2' as never, { type: 'session/event', sessionId: SID, event: ev.user(16, '插进来的') })
gate.resolve(ok({ events: entries(page) as never[], hasMore: false }))
gate.resolve(ok({
events: entries(page) as never[],
hasMore: false,
modelTarget: { provider: 'deepseek', model: 'deepseek-v4-flash' },
}))
await opening
const seqs = session.getSnapshot().nodes.map(n => n.seq)
// Overlapping seq-15 frame (== page tail turn/end) was dropped; 16 appended once.
@@ -84,6 +87,7 @@ describe('open', () => {
})
})
describe('live event path', () => {
async function opened(events: SessionEvent[] = plainTurn(0, 0, 'a', 'b')) {
const { api, session } = makeSession()
@@ -100,6 +104,28 @@ describe('live event path', () => {
expect(session.getSnapshot().nodes).toEqual(before.nodes)
})
it('materializes a command node from live lifecycle frames and reproduces it from a history window', async () => {
// Live path: run mints an executing node, done settles it in the flow.
const { session } = await opened()
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
feed(ev.commandRun(6, 'cmd-live', 'plan'))
let command = session.getSnapshot().nodes.at(-1)
expect(command).toMatchObject({ kind: 'command', name: 'plan', args: '', outcome: null })
feed(ev.commandDone(7, 'cmd-live', 'success', '已进入 plan mode'))
command = session.getSnapshot().nodes.at(-1)
expect(command).toMatchObject({ kind: 'command', seq: 6, outcome: { kind: 'success', text: '已进入 plan mode' } })
// Replay path (refresh): the same pair inside the history window folds identically.
const replayed = await opened([
...plainTurn(0, 0, 'a', 'b'),
ev.commandRun(6, 'cmd-live', 'plan'),
ev.commandDone(7, 'cmd-live', 'success', '已进入 plan mode'),
])
expect(replayed.session.getSnapshot().nodes.at(-1)).toMatchObject({
kind: 'command', seq: 6, name: 'plan', outcome: { kind: 'success', text: '已进入 plan mode' },
})
})
it('accumulates chunks into partial, then finalize swaps partial out as the node lands', async () => {
const { session } = await opened()
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
@@ -211,26 +237,44 @@ describe('paging', () => {
api.onHistory = () => gate.promise
const first = session.loadOlder()
const second = session.loadOlder()
gate.resolve(ok({ events: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false }))
gate.resolve(ok({
events: entries(plainTurn(0, 0, 'a', 'b')) as never[],
hasMore: false,
modelTarget: { provider: 'deepseek', model: 'deepseek-v4-flash' },
}))
await Promise.all([first, second])
expect(api.callsOf('session.history')).toHaveLength(2) // open + one page, not two
})
})
describe('prompt and cancel errors', () => {
it('sends content through session.prompt with the mode passed through', async () => {
it('sends content through session.prompt; composerPhase steps blank → engaging synchronously at send entry', async () => {
const { api, session } = makeSession()
const result = await session.prompt([{ type: 'text', text: '要发的' }], 'queue')
// The blank → engaging edge fires before the RPC settles: the first-send
// flow reads the phase on the session area's first frame to keep the
// guidance hero from flashing back in.
expect(session.getSnapshot().composerPhase).toBe('blank')
const inFlight = session.prompt([{ type: 'text', text: '要发的' }], 'queue')
expect(session.getSnapshot().composerPhase).toBe('engaging')
const result = await inFlight
expect(result.ok).toBe(true)
// Monotone: settlement alone does not step the phase anywhere.
expect(session.getSnapshot().composerPhase).toBe('engaging')
expect(api.callsOf('session.prompt')).toMatchObject([{ sessionId: SID, mode: 'queue', content: [{ type: 'text', text: '要发的' }] }])
// First content lands (running turn): engaging → active.
session.handleRunning(true)
expect(session.getSnapshot().composerPhase).toBe('active')
})
it('business failure lands in promptError with op=send', async () => {
it('business failure lands in promptError with op=send; the phase stays engaging (retry, no hero bounce)', async () => {
const { api, session } = makeSession()
api.onPrompt = () => Promise.resolve(err({ code: 'agent-busy', message: 'busy', details: { reason: 'x' } }))
const result = await session.prompt([{ type: 'text', text: '失败的' }], 'queue')
expect(result.ok).toBe(false)
expect(session.getSnapshot().promptError).toMatchObject({ op: 'send', error: { code: 'agent-busy' } })
// Failed first prompt: composer + error strip is the retry surface —
// blank is unreachable once a send was initiated.
expect(session.getSnapshot().composerPhase).toBe('engaging')
})
it('lands cancel failures in promptError with op=stop', async () => {
@@ -252,6 +296,36 @@ describe('pending interactions', () => {
session.handleMuxEnvelope('ry' as never, { type: 'question/resolved', sessionId: SID, questionRpcId: 'rq' as never, outcome: 'answered' })
expect(session.getSnapshot().pending).toEqual([])
})
it('mints waits whose respond() backfills the requested rpcId into the client-response envelope', async () => {
const { api, session } = makeSession()
session.handleMuxEnvelope('rq-answer' as never, { type: 'question/requested', sessionId: SID, questions: [] })
const wait = session.getSnapshot().pending[0]!
expect(wait).toMatchObject({ kind: 'question', key: 'q:rq-answer', sessionId: SID, payload: { questions: [] } })
const receipt = await wait.respond({
ok: true,
value: { sessionId: SID, answer: { answers: [{ id: 'mode', selected: ['Fast'] }] } },
})
expect(receipt).toEqual({ accepted: true })
expect(api.callsOf('respond')).toEqual([{
type: 'client-response', rpcId: 'rq-answer',
result: {
ok: true,
value: { sessionId: SID, answer: { answers: [{ id: 'mode', selected: ['Fast'] }] } },
},
}])
})
it('settles the wait on the authoritative resolved frame: respond() then throws synchronously', async () => {
const { api, session } = makeSession()
session.handleMuxEnvelope('rq1' as never, { type: 'question/requested', sessionId: SID, questions: [] })
const wait = session.getSnapshot().pending[0]!
session.handleMuxEnvelope('ry' as never, { type: 'question/resolved', sessionId: SID, questionRpcId: 'rq1' as never, outcome: 'answered' })
expect(session.getSnapshot().pending).toEqual([])
expect(() => wait.respond({ ok: false, error: { code: 'internal', message: 'x', details: {} } }))
.toThrow('already settled')
expect(api.callsOf('respond')).toEqual([])
})
})
describe('remaining branches', () => {
@@ -356,7 +430,7 @@ describe('remaining branches', () => {
session.handleMuxEnvelope('ra' as never, {
type: 'approval/requested', sessionId: SID, approvalId: 'ap2' as never, toolName: 'rm', callId: 'c1' as never, reason: '危险',
})
expect(session.getSnapshot().pending[0]).toMatchObject({ kind: 'approval', callId: 'c1', reason: '危险' })
expect(session.getSnapshot().pending[0]).toMatchObject({ kind: 'approval', payload: { callId: 'c1', reason: '危险' } })
session.handleMuxEnvelope('rx' as never, { type: 'approval/resolved', sessionId: SID, approvalId: 'ap2' as never, outcome: 'approved' as never })
session.handleMuxEnvelope('rx2' as never, { type: 'approval/resolved', sessionId: SID, approvalId: 'ap2' as never, outcome: 'approved' as never })
session.handleMuxEnvelope('ry2' as never, { type: 'question/resolved', sessionId: SID, questionRpcId: 'never-was' as never, outcome: 'cancelled' })
@@ -453,7 +527,11 @@ describe('remaining branches', () => {
const opening = session.open()
api.onHistory = () => histResponse(plainTurn(6, 1, '新', '代'))
const resynced = session.resync()
stale.resolve(ok({ events: entries(plainTurn(0, 0, '旧', '代')) as never[], hasMore: false })) // success, but its generation is gone
stale.resolve(ok({
events: entries(plainTurn(0, 0, '旧', '代')) as never[],
hasMore: false,
modelTarget: { provider: 'deepseek', model: 'stale' },
})) // success, but its generation is gone
await Promise.all([opening, resynced])
expect(session.getSnapshot().nodes.map(n => n.seq)).toEqual([7, 9]) // only the fresh generation's window
})
@@ -472,7 +550,11 @@ describe('remaining branches', () => {
const opening = session.open() // triggers the second pull, which parks
await vi.waitFor(() => { expect(call).toBe(2) })
const resynced = session.resync()
secondPull.resolve(ok({ events: entries([...plainTurn(0, 0, 'a', 'b'), ...plainTurn(6, 1, 'c', 'd')]) as never[], hasMore: false }))
secondPull.resolve(ok({
events: entries([...plainTurn(0, 0, 'a', 'b'), ...plainTurn(6, 1, 'c', 'd')]) as never[],
hasMore: false,
modelTarget: { provider: 'deepseek', model: 'stale' },
}))
await Promise.all([opening, resynced])
expect(session.getSnapshot().openState).toBe('open')
})
@@ -486,7 +568,11 @@ describe('remaining branches', () => {
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: ev.user(9, '洞') }) // starts repairGap
api.onHistory = () => histResponse(plainTurn(6, 1, 'c', 'd'))
const resynced = session.resync() // bumps the generation
repairPull.resolve(ok({ events: entries(plainTurn(0, 0, '旧', '页')) as never[], hasMore: false })) // repair result: stale, dropped
repairPull.resolve(ok({
events: entries(plainTurn(0, 0, '旧', '页')) as never[],
hasMore: false,
modelTarget: { provider: 'deepseek', model: 'stale' },
})) // repair result: stale, dropped
await resynced
expect(session.getSnapshot().nodes.map(n => n.seq)).toEqual([7, 9])
})
@@ -530,6 +616,7 @@ describe('remaining branches', () => {
{ event: ev.toolResult(7, 1, 'h1', 'done'), view: { for: 'result', view: { card: 'generic', title: '历史果' } } },
] as never[],
hasMore: false,
modelTarget: { provider: 'deepseek', model: 'deepseek-v4-flash' },
}))
await session.open()
expect(session.getSnapshot().nodes.at(-1)).toMatchObject({
@@ -569,6 +656,22 @@ describe('resync', () => {
expect(cold.api.calls).toEqual([]) // never opened: no traffic
})
it('re-mints a replayed requested frame as a fresh wait with the same key (old reference superseded)', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
await session.open()
session.handleMuxEnvelope('rq-replay' as never, { type: 'question/requested', sessionId: SID, questions: [] })
const before = session.getSnapshot().pending[0]!
await session.resync()
session.handleMuxEnvelope('rq-replay' as never, { type: 'question/requested', sessionId: SID, questions: [] })
const after = session.getSnapshot().pending[0]!
expect(after).not.toBe(before)
expect(after.key).toBe(before.key)
// Superseded ≠ settled: an in-flight respond on the stale reference still reaches the host.
await before.respond({ ok: false, error: { code: 'internal', message: 'x', details: {} } })
expect(api.callsOf('respond')).toMatchObject([{ rpcId: 'rq-replay' }])
})
it('drops a stale in-flight open superseded by resync (generation guard)', async () => {
const { api, session } = makeSession()
const stale = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
@@ -585,6 +688,95 @@ describe('resync', () => {
})
})
describe('run_code sub-dispatch indexing', () => {
it('a start event lands as a running-shaped sub-call and its settle replaces it in place', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, '问', '答'))
await session.open()
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
feed(ev.turnStart(6, 1))
feed(ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"1","description":"d"}'))
feed(ev.codeDispatchStart(8, 'p1', 1, 'bash', { command: 'sleep' }))
feed(ev.codeDispatchStart(9, 'p1', 2, 'read', { path: 'a.txt' }))
const live = session.getSnapshot().codeDispatches.get('p1')
expect(live).toHaveLength(2)
// Running shape (no 'kind'): the exact RunningToolCall form native rows use.
expect(live?.[0]).toMatchObject({ callId: 'p1:code:1', name: 'bash', argsRaw: '{"command":"sleep"}' })
expect(live?.[0] !== undefined && 'kind' in live[0]).toBe(false)
// Settle out of order (parallel run): #2 first — replaces in place, keeping start order.
feed(ev.codeDispatch(10, 'p1', 2, 'read', { path: 'a.txt' }, 'alpha'))
const mixed = session.getSnapshot().codeDispatches.get('p1')
expect(mixed?.map(sub => 'kind' in sub)).toEqual([false, true])
expect(mixed?.[1]).toMatchObject({ callId: 'p1:code:2', content: [{ type: 'text', text: 'alpha' }] })
// The settle carries the paired start's time as callTime (duration source).
feed(ev.codeDispatch(11, 'p1', 1, 'bash', { command: 'sleep' }, 'done'))
const settled = session.getSnapshot().codeDispatches.get('p1')
expect(settled?.map(sub => 'kind' in sub)).toEqual([true, true])
expect(settled?.[0]).toMatchObject({ callId: 'p1:code:1', callTime: 1_700_000_000_008 })
})
it('indexes live tool/code-dispatch events under their parent as native-shaped result nodes', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, '问', '答'))
await session.open()
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
feed(ev.turnStart(6, 1))
feed(ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"return 1","description":"跑一个程序"}'))
feed(ev.codeDispatch(8, 'p1', 1, 'bash', { command: 'ls', description: '列目录' }, 'demo.txt'))
feed(ev.codeDispatch(9, 'p1', 2, 'read', { path: 'a.txt' }, 'Error: ENOENT', true))
const subs = session.getSnapshot().codeDispatches.get('p1')
expect(subs).toHaveLength(2)
expect(subs?.[0]).toMatchObject({
kind: 'tool-result', callId: 'p1:code:1',
call: { name: 'bash', argsRaw: '{"command":"ls","description":"列目录"}' },
// The settle event carries no start time: callTime stays null (never a
// fabricated zero-duration).
callTime: null,
isError: false, content: [{ type: 'text', text: 'demo.txt' }],
})
expect(subs?.[1]).toMatchObject({ callId: 'p1:code:2', isError: true })
// No paired start in the window: duration is UNKNOWN (null), never a
// fabricated zero-duration span.
expect(subs?.[0]).toMatchObject({ callTime: null })
// Sub-dispatches never join the surface flow.
expect(session.getSnapshot().nodes.some(n => n.kind === 'tool-result' && n.callId.includes(':code:'))).toBe(false)
})
it('rebuilds the same index from a history window (replay parity)', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse([
...plainTurn(0, 0, '问', '答'),
ev.turnStart(6, 1),
ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"return 1","description":"跑一个程序"}'),
ev.codeDispatch(8, 'p1', 1, 'bash', { command: 'ls' }, 'demo.txt'),
ev.toolResult(9, 1, 'p1', '{"done":true}'),
ev.turnEnd(10, 1),
])
await session.open()
const subs = session.getSnapshot().codeDispatches.get('p1')
expect(subs).toHaveLength(1)
expect(subs?.[0]).toMatchObject({ callId: 'p1:code:1', call: { name: 'bash' } })
})
it('keeps the dispatch map reference across unrelated changes and swaps it on a new dispatch', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, '稳', '定'))
await session.open()
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
feed(ev.turnStart(6, 1))
feed(ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"1","description":"d"}'))
feed(ev.codeDispatch(8, 'p1', 1, 'bash', { command: 'ls' }, 'x'))
const before = session.getSnapshot()
feed(ev.chunkStart(9, 1))
feed(ev.chunkText(10, 1, '流式'))
const after = session.getSnapshot()
expect(after.codeDispatches).toBe(before.codeDispatches)
feed(ev.codeDispatch(11, 'p1', 2, 'read', { path: 'a' }, 'y'))
expect(session.getSnapshot().codeDispatches).not.toBe(after.codeDispatches)
expect(session.getSnapshot().codeDispatches.get('p1')).toHaveLength(2)
})
})
describe('reference stability (the memo contract)', () => {
it('keeps unchanged node references across an append and swaps the snapshot object', async () => {
const { api, session } = makeSession()
@@ -624,275 +816,3 @@ describe('reference stability (the memo contract)', () => {
expect(resolved.pending).toBe(after.pending)
})
})
describe('goal session methods', () => {
const GID = 'g-1' as never
function makeGoal(overrides: Partial<GoalView> = {}): GoalView {
return {
id: GID, revision: 1, objective: 'test-goal', phase: 'active',
maxGoalRounds: 256, roundsStarted: 0, createdAt: 100, updatedAt: 100,
activation: 'armed',
...overrides,
}
}
it('createGoal calls the API and updates snapshot.goal', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
await session.open()
const goal = makeGoal()
api.goals.create = () => Promise.resolve(ok({ goal }))
const r = await session.createGoal('test-goal')
expect(r).toEqual({ ok: true, value: { goal } })
expect(session.getSnapshot().goal).toEqual(goal)
})
it('createGoal forwards an explicit round cap', async () => {
const { api, session } = makeSession()
await session.createGoal('bounded', 7)
expect(api.callsOf('goal.create')).toEqual([{ sessionId: SID, objective: 'bounded', maxGoalRounds: 7 }])
})
it('editGoal sends the current ref and updates snapshot', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
await session.open()
const goal = makeGoal()
api.goals.create = () => Promise.resolve(ok({ goal }))
await session.createGoal('test-goal')
const edited = makeGoal({ revision: 2, objective: 'edited' })
api.goals.edit = () => Promise.resolve(ok({ goal: edited }))
const r = await session.editGoal('edited')
expect(r).toEqual({ ok: true, value: { goal: edited } })
expect(session.getSnapshot().goal).toEqual(edited)
})
it('editGoal can replace only the round cap', async () => {
const { api, session } = makeSession()
const goal = makeGoal()
api.goals.create = () => Promise.resolve(ok({ goal }))
await session.createGoal('test-goal')
const edited = makeGoal({ revision: 2, maxGoalRounds: 9 })
const edit = vi.fn(() => Promise.resolve(ok({ goal: edited })))
api.goals.edit = edit
await session.editGoal(undefined, 9)
expect(edit).toHaveBeenCalledWith({ sessionId: SID, ref: { id: goal.id, revision: 1 }, maxGoalRounds: 9 })
})
it('editGoal returns an error when no goal exists', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
await session.open()
const r = await session.editGoal('nothing')
expect(r.ok).toBe(false)
expect((r as { error: { code: string } }).error.code).toBe('internal')
})
it('phase mutations and clear return an error when no goal exists', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
await session.open()
for (const mutate of [() => session.pauseGoal(), () => session.resumeGoal(), () => session.completeGoal(), () => session.clearGoal()]) {
expect((await mutate()).ok).toBe(false)
}
})
it('pauseGoal pauses and updates snapshot', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
await session.open()
const goal = makeGoal()
api.goals.create = () => Promise.resolve(ok({ goal }))
await session.createGoal('test-goal')
const paused = makeGoal({ revision: 2, phase: 'paused', activation: 'disarmed' })
api.goals.pause = () => Promise.resolve(ok({ goal: paused }))
const r = await session.pauseGoal()
expect(r).toEqual({ ok: true, value: { goal: paused } })
expect(session.getSnapshot().goal).toEqual(paused)
})
it('resumeGoal resumes a paused goal', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
await session.open()
const goal = makeGoal({ phase: 'paused', activation: 'disarmed' })
api.goals.create = () => Promise.resolve(ok({ goal }))
await session.createGoal('test-goal')
const resumed = makeGoal({ revision: 2, phase: 'active', activation: 'armed' })
api.goals.resume = () => Promise.resolve(ok({ goal: resumed }))
const r = await session.resumeGoal()
expect(r).toEqual({ ok: true, value: { goal: resumed } })
expect(session.getSnapshot().goal).toEqual(resumed)
})
it('completeGoal marks the goal complete', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
await session.open()
const goal = makeGoal()
api.goals.create = () => Promise.resolve(ok({ goal }))
await session.createGoal('test-goal')
const completed = makeGoal({ revision: 2, phase: 'complete', activation: 'disarmed' })
api.goals.complete = () => Promise.resolve(ok({ goal: completed }))
const r = await session.completeGoal()
expect(r).toEqual({ ok: true, value: { goal: completed } })
expect(session.getSnapshot().goal).toEqual(completed)
})
it('clearGoal removes the goal from snapshot', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
await session.open()
const goal = makeGoal()
api.goals.create = () => Promise.resolve(ok({ goal }))
await session.createGoal('test-goal')
api.goals.clear = () => Promise.resolve(ok({ cleared: true as const }))
const r = await session.clearGoal()
expect(r).toEqual({ ok: true, value: { cleared: true } })
expect(session.getSnapshot().goal).toBeNull()
})
it('error responses from the API do not mutate local state', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
await session.open()
const goal = makeGoal()
api.goals.create = () => Promise.resolve(ok({ goal }))
await session.createGoal('test-goal')
const before = session.getSnapshot().goal
api.goals.pause = () => Promise.resolve(err({ code: 'agent-busy', message: 'stale revision', details: { reason: 'stale revision' } }))
const r = await session.pauseGoal()
expect(r.ok).toBe(false)
expect(session.getSnapshot().goal).toBe(before)
})
it('fetchGoal runs on session open and populates goal', async () => {
const goal = makeGoal()
const { api, session } = makeSession()
api.onHistory = () => histResponse([])
api.goals.get = () => Promise.resolve(ok({ goal }))
await session.open()
expect(session.getSnapshot().goal).toEqual(goal)
})
it('keeps the goal unresolved when the eager fetch returns an RPC error', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse([])
api.goals.get = () => Promise.resolve(err({ code: 'internal', message: 'unavailable', details: {} }))
await session.open()
expect(session.getSnapshot().goal).toBeUndefined()
})
const goalChangeEvent = (seq: number, operation: string): SessionEvent =>
at(seq, {
type: 'context/message', surfaceOp: 'append',
data: {
content: [{ type: 'text', text: `goal ${operation}` }],
source: { kind: 'goal', goalId: 'g-1', revision: 1, round: 0 },
meta: {
kind: 'goal/change', version: 1, operation,
goal: { id: 'g-1', revision: 1, objective: 'x', phase: 'active', maxGoalRounds: 256 },
roundsStarted: 0, createdAt: 100, updatedAt: 100,
},
},
})
// Clear tombstones carry no goal key — the trigger matches the meta kind, not a goal field.
const goalClearEvent = (seq: number): SessionEvent =>
at(seq, {
type: 'context/message', surfaceOp: 'append',
data: {
content: [{ type: 'text', text: 'goal cleared' }],
source: { kind: 'goal', goalId: 'g-1', revision: 2, round: 0 },
meta: { kind: 'goal/change', version: 1, operation: 'clear', cleared: { id: 'g-1', revision: 2 }, clearedAt: 100 },
},
})
it('live goal-change meta coalesces to one in-flight read plus one trailing read; window replays never refetch', async () => {
const { api, session } = makeSession()
// The history window replays goal-change meta (a snapshot change AND a clear tombstone).
api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), goalChangeEvent(6, 'create'), goalClearEvent(7)])
await session.open()
await Promise.resolve()
expect(api.callsOf('goal.get')).toHaveLength(1) // the eager open fetch only — no replay storm
// A plain live context message is not a goal change.
let refetches = 0
const gate = deferred<Awaited<ReturnType<FakeApiClient['goals']['get']>>>()
api.goals.get = () => { refetches++; return gate.promise } // replacement bypasses record(): count locally
session.handleMuxEnvelope('r0' as never, {
type: 'session/event', sessionId: SID,
event: at(8, { type: 'context/message', surfaceOp: 'append', data: { content: [{ type: 'text', text: 'plain' }], source: { kind: 'system' } } }),
})
expect(refetches).toBe(0)
// Two live goal events (change + clear tombstone) share the in-flight read, then the
// second trigger schedules a trailing read so an independently ordered GET cannot win.
session.handleMuxEnvelope('r1' as never, { type: 'session/event', sessionId: SID, event: goalChangeEvent(9, 'edit') })
session.handleMuxEnvelope('r2' as never, { type: 'session/event', sessionId: SID, event: goalClearEvent(10) })
expect(refetches).toBe(1)
const goal = makeGoal({ revision: 3 })
gate.resolve(ok({ goal }))
await vi.waitFor(() => { expect(refetches).toBe(2) })
await vi.waitFor(() => { expect(session.getSnapshot().goal).toEqual(goal) })
})
it('drops a goal.get result older than a mutation response that landed mid-flight', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
await session.open()
const goal = makeGoal()
api.goals.create = () => Promise.resolve(ok({ goal }))
await session.createGoal('test-goal')
let refetches = 0
const gate = deferred<Awaited<ReturnType<FakeApiClient['goals']['get']>>>()
api.goals.get = () => { refetches++; return gate.promise } // replacement bypasses record(): count locally
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: goalChangeEvent(6, 'edit') })
expect(refetches).toBe(1) // the live refetch is parked on the gate
const completed = makeGoal({ revision: 2, phase: 'complete', activation: 'disarmed' })
api.goals.complete = () => Promise.resolve(ok({ goal: completed }))
await session.completeGoal() // newer write lands while the get is in flight
gate.resolve(ok({ goal: makeGoal({ objective: 'stale-read' }) }))
await new Promise(resolve => setTimeout(resolve, 0))
expect(session.getSnapshot().goal).toEqual(completed) // the stale read never overwrote it
})
it('folds transport rejections from goal mutations into { ok: false } without rejecting', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
await session.open()
api.goals.create = () => Promise.reject(new Error('goal wire down'))
const created = await session.createGoal('test-goal')
expect(created).toMatchObject({ ok: false, error: { code: 'internal', message: 'goal wire down' } })
expect(session.getSnapshot().goal).toBeNull()
const goal = makeGoal()
api.goals.create = () => Promise.resolve(ok({ goal }))
await session.createGoal('test-goal')
api.goals.pause = () => Promise.reject(new Error('pause wire down'))
const paused = await session.pauseGoal()
expect(paused).toMatchObject({ ok: false, error: { code: 'internal', message: 'pause wire down' } })
expect(session.getSnapshot().goal).toEqual(goal) // local state untouched
api.goals.clear = () => Promise.reject(new Error('clear wire down'))
const cleared = await session.clearGoal()
expect(cleared).toMatchObject({ ok: false, error: { code: 'internal', message: 'clear wire down' } })
expect(session.getSnapshot().goal).toEqual(goal)
})
it('a goal.get transport rejection on a live refetch is logged and swallowed', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
await session.open()
api.goals.get = () => Promise.reject(new Error('get wire down'))
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
try {
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: goalChangeEvent(6, 'create') })
await vi.waitFor(() => { expect(errorSpy).toHaveBeenCalled() })
expect(session.getSnapshot().goal).toBeNull() // fail-soft: state untouched
} finally {
errorSpy.mockRestore()
}
})
})

View File

@@ -1,13 +1,16 @@
/**
* SessionsService: list store projection (manager → {ids, byId} with derived
* titles), scope-tree lifecycle (lazy mint / frozen survival / removed
* teardown with watch deferral), binding identity, ancestry walk, create.
* SessionsService: list store projection (manager → {ids, byId, current}
* with derived titles), the migrated current-selection account (open
* validation, persisted mask semantics, cell resolution), scope-tree
* lifecycle (lazy mint / frozen survival / removed teardown with staged
* deferral — the stage follows list.current), binding identity, ancestry
* walk, create.
*/
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import { SessionsService, scopeOf } from '../src/client/sessions/service.ts'
import { FakeApiClient, ok } from './fake-api.ts'
import { SessionCreateError, SessionsService, scopeOf } from '../src/client/sessions/service.ts'
import { FakeApiClient, deferred, ok } from './fake-api.ts'
const sid = (s: string): SessionId => s as SessionId
@@ -25,35 +28,42 @@ function bench(): Bench {
}
/** Refresh the manager list from programmable rows and flush the microtask batch. */
async function feedList(b: Bench, rows: { id: string; cwd?: string; parentId?: string; running?: boolean }[]): Promise<void> {
type FeedRow = { id: string; cwd?: string; parentId?: string; running?: boolean; blank?: boolean }
async function feedList(b: Bench, rows: FeedRow[]): Promise<void> {
b.api.onList = () => Promise.resolve(ok({
items: rows.map(r => ({
sessionId: sid(r.id), updatedAt: 1, running: r.running ?? false,
sessionId: sid(r.id), updatedAt: 1, running: r.running ?? false, blank: r.blank ?? false,
...(r.cwd !== undefined ? { cwd: r.cwd } : {}),
...(r.parentId !== undefined ? { parentSessionId: sid(r.parentId) } : {}),
})),
}) as never)
await b.svc.manager.refreshList()
await b.svc.refresh()
await Promise.resolve() // manager notifier flush
}
describe('list store projection', () => {
it('projects ids/byId with cwd-basename titles (id fallback) and parent links', async () => {
it('projects durable titles separately from cwd/id display fallbacks and parent links', async () => {
const b = bench()
b.svc.handleMuxEnvelope({
rpcId: 'title' as never,
payload: { type: 'session/projection', sessionId: sid('s1'), key: 'title', value: 'Durable title', seq: 2 } as never,
})
await feedList(b, [
{ id: 's1', cwd: '/home/u/proj-a/' },
{ id: 's2', parentId: 's1', running: true },
])
const state = b.svc.list.getSnapshot()
expect(state.ids).toEqual(['s1', 's2'])
expect(state.byId[sid('s1')]).toMatchObject({ title: 'proj-a', cwd: '/home/u/proj-a/' })
expect(state.byId[sid('s2')]).toMatchObject({ title: 's2', parentId: 's1', running: true })
expect(state.byId[sid('s1')]).toMatchObject({ title: 'Durable title', displayTitle: 'Durable title', cwd: '/home/u/proj-a/' })
expect(state.byId[sid('s2')]).toMatchObject({ displayTitle: 's2', parentId: 's1', running: true })
expect(state.byId[sid('s2')]?.title).toBeUndefined()
})
it('reflects live increments (host stream via manager) into the store', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }])
b.svc.manager.handleHostEnvelope({ rpcId: 'r1' as never, payload: { type: 'host/session-added', sessionId: sid('s2') } as never })
b.svc.handleHostEnvelope({ rpcId: 'r1' as never, payload: { type: 'host/session-added', blank: true, sessionId: sid('s2') } as never })
await Promise.resolve()
expect(b.svc.list.getSnapshot().ids).toContain('s2')
})
@@ -69,26 +79,27 @@ describe('scope tree', () => {
expect(scopeOf(scoped as Context)).toBe('s1')
expect(scopeOf(b.ctx)).toBeUndefined()
const binding = b.svc.binding(sid('s1'))
expect(binding?.session).toBe(b.svc.manager.get(sid('s1')))
b.svc.open(sid('s1'))
expect(binding?.session).toBe(b.svc.currentProvideInfo.getSnapshot().hooks['session'])
expect(b.svc.binding(sid('s1'))).toBe(binding)
expect(binding?.ctx).toBe(scoped)
})
it('tears down an unwatched removed session but defers the watched one until the watch moves', async () => {
it('tears down an off-stage removed session but defers the staged one until the stage moves', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }, { id: 's2' }])
const ctx1 = b.svc.scope(sid('s1'))
b.svc.binding(sid('s1')) // s1 is watched
b.svc.scope(sid('s2')) // s2 scoped but not watched
b.svc.open(sid('s1')) // s1 staged (current)
b.svc.scope(sid('s2')) // s2 scoped but off stage
await feedList(b, [{ id: 's1' }]) // s2 removed, unwatched: torn down
await feedList(b, [{ id: 's1' }]) // s2 removed, off stage: torn down
expect(b.svc.scope(sid('s2'))).toBeUndefined()
await feedList(b, []) // s1 removed while watched: deferred, scope survives
await feedList(b, []) // s1 removed while staged (current masks): deferred, scope survives
expect(b.svc.scope(sid('s1'))).toBe(ctx1)
await feedList(b, [{ id: 's3' }])
b.svc.binding(sid('s3')) // watch moves: deferred teardown sweeps s1
b.svc.open(sid('s3')) // stage moves: deferred teardown sweeps s1
expect(b.svc.scope(sid('s1'))).toBeUndefined()
})
@@ -104,14 +115,219 @@ describe('scope tree', () => {
const b = bench()
await feedList(b, [{ id: 's1' }])
const scoped = b.svc.scope(sid('s1'))
b.svc.binding(sid('s1'))
await feedList(b, []) // removed while watched → deferred
await feedList(b, [{ id: 's1' }, { id: 's2' }]) // reappears
b.svc.binding(sid('s2')) // watch moves; sweep must NOT tear down the re-listed s1
b.svc.open(sid('s1'))
await feedList(b, []) // removed while staged → deferred
await feedList(b, [{ id: 's1' }, { id: 's2' }]) // reappears (current resurfaces, stage unchanged)
b.svc.open(sid('s2')) // stage moves; sweep must NOT tear down the re-listed s1
expect(b.svc.scope(sid('s1'))).toBe(scoped)
})
})
describe('current selection (migrated from ui-layout, arbitrated into the list snapshot)', () => {
afterEach(() => { vi.unstubAllGlobals() })
it('open() writes list.current; unknown ids fail loud', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }])
expect(b.svc.list.getSnapshot().current).toBeUndefined()
b.svc.open(sid('s1'))
expect(b.svc.list.getSnapshot().current).toBe('s1')
expect(() => { b.svc.open(sid('ghost')) }).toThrow(/unknown session ghost/)
expect(b.svc.list.getSnapshot().current).toBe('s1') // failed open leaves the selection alone
})
it('clear() blanks list.current and the persisted selection', async () => {
const storage = new Map<string, string>()
vi.stubGlobal('localStorage', {
getItem: (k: string) => storage.get(k) ?? null,
setItem: (k: string, v: string) => { storage.set(k, v) },
removeItem: (k: string) => { storage.delete(k) },
clear: () => { storage.clear() },
})
const b = bench()
await feedList(b, [{ id: 's1' }])
b.svc.open(sid('s1'))
expect(storage.get('dsh.sessions.current')).toContain('s1')
b.svc.clear()
expect(b.svc.list.getSnapshot().current).toBeUndefined()
// Persisted wipe: a fresh service with the same storage stays on empty.
const again = bench()
await feedList(again, [{ id: 's1' }])
expect(again.svc.list.getSnapshot().current).toBeUndefined()
})
it('masks (not destroys) the selection while its session is off the list', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }, { id: 's2' }])
b.svc.open(sid('s1'))
await feedList(b, [{ id: 's2' }]) // s1 removed → current falls to the empty state
expect(b.svc.list.getSnapshot().current).toBeUndefined()
await feedList(b, [{ id: 's1' }, { id: 's2' }]) // s1 returns → selection resurfaces
expect(b.svc.list.getSnapshot().current).toBe('s1')
})
it('persists the selection under dsh.sessions.current and rehydrates it into a fresh service', async () => {
const storage = new Map<string, string>()
vi.stubGlobal('localStorage', {
getItem: (k: string) => storage.get(k) ?? null,
setItem: (k: string, v: string) => { storage.set(k, v) },
})
const first = bench()
await feedList(first, [{ id: 's1' }])
first.svc.open(sid('s1'))
expect(storage.get('dsh.sessions.current')).toContain('s1')
// A fresh boot (same storage) recovers the selection once the list holds the session.
const second = bench()
await feedList(second, [{ id: 's1' }])
expect(second.svc.list.getSnapshot().current).toBe('s1')
})
})
describe('cell (render-layer session kit)', () => {
it('resolves an identity-stable {sessionId, session} cell through the current projection', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }])
b.svc.open(sid('s1'))
const info = b.svc.currentProvideInfo.getSnapshot()
expect(info.sessionId).toBe('s1')
// The bundle carries bare observables; hook binding happens in React.
expect(info.hooks['session']).toBe(b.svc.binding(sid('s1'))?.session)
// Re-staging the same id republishes nothing: identity holds.
b.svc.open(sid('s1'))
expect(b.svc.currentProvideInfo.getSnapshot()).toBe(info)
})
it('currentProvideInfo follows selection: absent projection ↔ definite bundle, notified on each move', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }, { id: 's2' }])
const absent = b.svc.currentProvideInfo.getSnapshot()
expect(absent.sessionId).toBeUndefined()
expect(Object.hasOwn(absent.hooks, 'session')).toBe(true)
const notified = vi.fn()
b.svc.currentProvideInfo.subscribe(notified)
b.svc.open(sid('s1'))
const s1Bundle = b.svc.currentProvideInfo.getSnapshot()
expect(s1Bundle.sessionId).toBe('s1')
expect(s1Bundle.hooks['session']).toBe(b.svc.binding(sid('s1'))?.session)
expect(notified).toHaveBeenCalledTimes(1)
b.svc.open(sid('s2'))
const s2Bundle = b.svc.currentProvideInfo.getSnapshot()
expect(s2Bundle.sessionId).toBe('s2')
expect(s2Bundle).not.toBe(s1Bundle)
expect(notified).toHaveBeenCalledTimes(2)
b.svc.clear()
await Promise.resolve() // clearSelection projects through the manager notifier
expect(b.svc.currentProvideInfo.getSnapshot().sessionId).toBeUndefined()
})
it('a provider roster change under a stable current id republishes the bundle', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }])
b.svc.open(sid('s1'))
const before = b.svc.currentProvideInfo.getSnapshot()
const notified = vi.fn()
b.svc.currentProvideInfo.subscribe(notified)
const source = { getSnapshot: () => 'live', subscribe: () => () => {} }
const dispose = b.svc.provide({
hooks: ['extra'],
props: ['marker'],
resolve: () => ({ hooks: { extra: source }, props: { marker: 7 } }),
})
const added = b.svc.currentProvideInfo.getSnapshot()
expect(added).not.toBe(before)
expect(added).toMatchObject({ sessionId: 's1', props: { marker: 7 } })
expect(added.hooks['extra']).toBe(source)
expect(notified).toHaveBeenCalledTimes(1)
dispose()
const removed = b.svc.currentProvideInfo.getSnapshot()
expect(removed).not.toBe(added)
expect(Object.hasOwn(removed.hooks, 'extra')).toBe(false)
expect(notified).toHaveBeenCalledTimes(2)
})
it('an unsubscribed currentProvideInfo listener stops receiving notifications', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }])
const notified = vi.fn()
const off = b.svc.currentProvideInfo.subscribe(notified)
off()
b.svc.open(sid('s1'))
expect(notified).not.toHaveBeenCalled()
})
it('binding() is pure resolution: no staging, no deferred sweep', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }, { id: 's2' }])
b.svc.open(sid('s1')) // staged
b.svc.binding(sid('s2')) // resolution only — must NOT move the stage
await feedList(b, [{ id: 's2' }]) // s1 removed: still staged → deferred, scope survives
expect(b.svc.scope(sid('s1'))).toBeDefined()
})
it('staging (current write) opens the session event window; resolution and re-staging do not re-pull', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }, { id: 's2' }])
const historyCalls = () => b.api.calls.filter(c => c.method === 'session.history')
// Resolution is addressing, not staging: no window pull.
b.svc.scope(sid('s1'))
b.svc.binding(sid('s1'))
expect(historyCalls()).toHaveLength(0)
b.svc.open(sid('s1'))
expect(historyCalls().map(c => (c.payload as { sessionId: string }).sessionId)).toEqual(['s1'])
// Same current again: no second pull.
b.svc.open(sid('s1'))
expect(historyCalls()).toHaveLength(1)
// Stage moves: the new occupant opens.
b.svc.open(sid('s2'))
expect(historyCalls().map(c => (c.payload as { sessionId: string }).sessionId)).toEqual(['s1', 's2'])
})
it('startup restore: a persisted selection validated by the first projection opens its window unprompted', async () => {
const storage = new Map<string, string>([
['dsh.sessions.current', JSON.stringify({ sessionId: 's1' })],
])
vi.stubGlobal('localStorage', {
getItem: (k: string) => storage.get(k) ?? null,
setItem: (k: string, v: string) => { storage.set(k, v) },
})
try {
const b = bench()
expect(b.api.calls.filter(c => c.method === 'session.history')).toHaveLength(0)
await feedList(b, [{ id: 's1' }]) // projection validates the persisted id → current lands → stage follows
const historyCalls = b.api.calls.filter(c => c.method === 'session.history')
expect(historyCalls.map(c => (c.payload as { sessionId: string }).sessionId)).toEqual(['s1'])
} finally {
vi.unstubAllGlobals()
}
})
})
describe('slot-store scope prune hook', () => {
it('notifies ctx.slots.pruneStoreScope when a scope dies (both teardown paths)', async () => {
const b = bench()
const pruneStoreScope = vi.fn()
b.ctx.reflect.provide('slots', { pruneStoreScope })
await feedList(b, [{ id: 's1' }, { id: 's2' }])
b.svc.scope(sid('s1'))
b.svc.scope(sid('s2'))
b.svc.open(sid('s2')) // s2 staged
await feedList(b, []) // s1 off stage → immediate drop; s2 staged → deferred
expect(pruneStoreScope).toHaveBeenCalledWith('s1')
expect(pruneStoreScope).not.toHaveBeenCalledWith('s2')
await feedList(b, [{ id: 's3' }])
b.svc.open(sid('s3')) // stage moves → deferred sweep drops s2
expect(pruneStoreScope).toHaveBeenCalledWith('s2')
})
it('tolerates a slots-less boot (object-layer benches carry no slot service)', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }])
b.svc.scope(sid('s1'))
await feedList(b, []) // teardown without ctx.slots must not throw
expect(b.svc.scope(sid('s1'))).toBeUndefined()
})
})
describe('ancestry', () => {
it('walks parentId links root-first including self; broken links stop the walk', async () => {
const b = bench()
@@ -128,65 +344,210 @@ describe('ancestry', () => {
})
describe('create', () => {
it('returns the new id on ok and throws a coded error on failure', async () => {
it('passes a preallocated id and preserves it on ordinary failure', async () => {
const b = bench()
b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('fresh') }))
await expect(b.svc.create({ cwd: '/w' })).resolves.toBe('fresh')
await expect(b.svc.create({ cwd: '/w', sessionId: sid('fresh') })).resolves.toBe('fresh')
expect(b.api.callsOf('session.create')).toEqual([{ cwd: '/w', sessionId: 'fresh' }])
b.api.onCreate = () => Promise.resolve({
rpcId: 'e' as never,
result: { ok: false as const, error: { code: 'internal' as const, message: '爆了', details: {} } },
} as never)
await expect(b.svc.create()).rejects.toThrow(/internal: 爆了/)
const failure = await b.svc.create({ sessionId: sid('candidate') }).catch((error: unknown) => error)
expect(failure).toBeInstanceOf(SessionCreateError)
expect(failure).toMatchObject({
requestedSessionId: 'candidate',
rpcError: { code: 'internal', message: '爆了' },
})
})
it('resolves with the session already listed and binding-resolvable (no flush wait)', async () => {
const b = bench()
b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('born') }))
const born = await b.svc.create({ workspaceId: 'ws' as never })
// Synchronously after resolution — the draft hand-off contract: the
// create echo IS the entity entering the client's view (blank row +
// resolvable scope/binding), no notifier flush in between.
expect(b.svc.list.getSnapshot().byId[born]).toMatchObject({ id: 'born', blank: true })
expect(b.svc.binding(born)).toBeDefined()
expect(b.svc.scope(born)).toBeDefined()
})
it('lists the published id after Workspace attachment fails (publication precedes attachment)', async () => {
const b = bench()
b.api.onCreate = () => Promise.resolve({
rpcId: 'attach' as never,
result: {
ok: false,
error: {
code: 'workspace-attach-failed', message: 'ledger unavailable',
details: { sessionId: sid('published'), workspaceId: 'ws' },
},
},
} as never)
const failure = await b.svc.create({
workspaceId: 'ws' as never,
sessionId: sid('published'),
}).catch((error: unknown) => error)
await Promise.resolve()
expect(failure).toBeInstanceOf(SessionCreateError)
expect(failure).toMatchObject({
requestedSessionId: 'published',
rpcError: { code: 'workspace-attach-failed' },
})
expect(b.svc.list.getSnapshot().byId[sid('published')]).toMatchObject({ id: 'published', blank: true })
})
})
describe('scope lifecycle rides the list mirror (entity parity: no client-side pre-birth)', () => {
it('a session-added frame births the row (blank) and makes the scope resolvable; removal prunes it', async () => {
const b = bench()
await feedList(b, [])
expect(b.svc.scope(sid('s-new'))).toBeUndefined() // not in view: no scope, no exceptions
b.svc.handleHostEnvelope({
rpcId: 'add' as never,
payload: { type: 'host/session-added', sessionId: sid('s-new'), blank: true, cwd: '/w/a' } as never,
})
await Promise.resolve()
const scoped = b.svc.scope(sid('s-new'))
expect(scoped).toBeDefined()
expect(scopeOf(scoped as Context)).toBe('s-new')
b.svc.handleHostEnvelope({
rpcId: 'rm' as never,
payload: { type: 'host/session-removed', sessionId: sid('s-new') },
})
await Promise.resolve()
expect(b.svc.scope(sid('s-new'))).toBeUndefined()
})
})
describe('blank mirror', () => {
it('flips blank=false from the running:true status frame (cross-client conversion)', async () => {
const b = bench()
await feedList(b, [{ id: 's1', blank: true }])
expect(b.svc.list.getSnapshot().byId[sid('s1')]).toMatchObject({ blank: true })
b.svc.handleHostEnvelope({
rpcId: 'st' as never,
payload: { type: 'host/session-status', sessionId: sid('s1'), running: true },
})
await Promise.resolve()
expect(b.svc.list.getSnapshot().byId[sid('s1')]).toMatchObject({ blank: false, running: true })
// The instantiated Session mirrors the same flip.
expect(b.svc.binding(sid('s1'))?.session.getSnapshot().blank).toBe(false)
})
it('flips blank=false on prompt ACCEPTANCE, not on the attempt', async () => {
const b = bench()
await feedList(b, [{ id: 's1', blank: true, cwd: '/w/a' }])
const session = b.svc.binding(sid('s1'))!.session
expect(session.getSnapshot().blank).toBe(true)
const gate = deferred<Awaited<ReturnType<FakeApiClient['onPrompt']>>>()
b.api.onPrompt = () => gate.promise
const send = session.prompt([{ type: 'text', text: 'hi' }], 'queue')
// In flight: still blank (the flip point is the success response, which
// proves the user message reached the host log).
expect(session.getSnapshot().blank).toBe(true)
gate.resolve(ok({ accepted: true as const }))
await send
expect(session.getSnapshot().blank).toBe(false)
await Promise.resolve()
expect(b.svc.list.getSnapshot().byId[sid('s1')]).toMatchObject({ blank: false })
})
it('keeps a rejected first prompt blank: hidden and still reusable', async () => {
const b = bench()
await feedList(b, [{ id: 's1', blank: true, cwd: '/w/a' }])
const session = b.svc.binding(sid('s1'))!.session
b.api.onPrompt = () => Promise.resolve({
rpcId: 'busy' as never,
result: { ok: false as const, error: { code: 'internal' as const, message: 'agent busy', details: {} } },
} as never)
const result = await session.prompt([{ type: 'text', text: 'hi' }], 'queue')
expect(result.ok).toBe(false)
// No flip on failure: local stays aligned with the host authority
// (events.length still 0), so the session stays hidden and reusable.
expect(session.getSnapshot().blank).toBe(true)
await Promise.resolve()
expect(b.svc.list.getSnapshot().byId[sid('s1')]).toMatchObject({ blank: true })
})
it('takes session-added blank=true as the hidden birth and list blank as reconnect authority', async () => {
const b = bench()
await feedList(b, [])
b.svc.handleHostEnvelope({
rpcId: 'add' as never,
payload: { type: 'host/session-added', sessionId: sid('s-new'), blank: true, cwd: '/w/a' } as never,
})
await Promise.resolve()
expect(b.svc.list.getSnapshot().byId[sid('s-new')]).toMatchObject({ blank: true })
// Reconnect re-pull: the summary's blank=false wins (authoritative alignment).
await feedList(b, [{ id: 's-new', blank: false, cwd: '/w/a' }])
expect(b.svc.list.getSnapshot().byId[sid('s-new')]).toMatchObject({ blank: false })
})
it('never re-blanks: a stale blank=true summary cannot hide an engaged session', async () => {
const b = bench()
await feedList(b, [{ id: 's1', blank: true }])
const session = b.svc.binding(sid('s1'))!.session
await session.prompt([{ type: 'text', text: 'hi' }], 'queue')
await Promise.resolve()
expect(b.svc.list.getSnapshot().byId[sid('s1')]).toMatchObject({ blank: false })
// The next list pull still claims blank (host hasn't logged the message yet).
await feedList(b, [{ id: 's1', blank: true }])
expect(b.svc.binding(sid('s1'))?.session.getSnapshot().blank).toBe(false)
})
})
describe('coverage tails (branch duals)', () => {
it('titleOf falls back to the id for empty and separator-only cwd', async () => {
it('displayTitleOf falls back to the id for empty and separator-only cwd', async () => {
const b = bench()
await feedList(b, [{ id: 'no-base', cwd: '///' }, { id: 'empty-cwd', cwd: '' }])
const { byId } = b.svc.list.getSnapshot()
expect(byId[sid('no-base')]?.title).toBe('no-base')
expect(byId[sid('empty-cwd')]?.title).toBe('empty-cwd')
expect(byId[sid('no-base')]?.displayTitle).toBe('no-base')
expect(byId[sid('empty-cwd')]?.displayTitle).toBe('empty-cwd')
expect(byId[sid('no-base')]?.title).toBeUndefined()
})
it('binding for an unknown session returns undefined without moving the watch', async () => {
it('binding for an unknown session returns undefined and leaves the staged scope intact', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }])
b.svc.binding(sid('s1'))
b.svc.open(sid('s1'))
expect(b.svc.binding(sid('ghost'))).toBeUndefined()
// Watch unchanged: removing s1 defers (still watched), proving the ghost lookup did not steal the watch.
// Stage unchanged: removing s1 defers (still staged), proving the ghost lookup touched nothing.
await feedList(b, [])
expect(b.svc.scope(sid('s1'))).toBeDefined()
})
it('sweep skips the id that is itself still watched and tolerates a scope record already gone', async () => {
it('a masked current gap holds the stage (no teardown, no re-open) until the stage moves', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }])
b.svc.binding(sid('s1'))
await feedList(b, []) // deferred removal of the watched id
// Re-resolving the SAME watched id: sweep runs but must skip it (watched-continue branch).
expect(b.svc.binding(sid('s1'))).toBeDefined()
b.svc.open(sid('s1'))
const historyCalls = () => b.api.calls.filter(c => c.method === 'session.history')
expect(historyCalls()).toHaveLength(1)
await feedList(b, []) // removed while staged: current masks to undefined, stage holds → deferred
expect(b.svc.scope(sid('s1'))).toBeDefined()
// Resurfacing re-projects current = s1: same stage occupant, no second pull.
await feedList(b, [{ id: 's1' }])
expect(historyCalls()).toHaveLength(1)
expect(b.svc.list.getSnapshot().current).toBe('s1')
})
it('sweep hits both deferral edges: watched-id skip and an already-vacated scope record', async () => {
it('sweep hits both deferral edges: staged-id skip and an already-vacated scope record', async () => {
const b = bench()
await feedList(b, [{ id: 'a' }, { id: 'b' }])
b.svc.binding(sid('a'))
b.svc.binding(sid('b')) // watch: b; both scoped
await feedList(b, []) // a removed unwatched → torn immediately; b removed watched → deferred
// Move the watch to a THIRD id while b stays deferred: sweep now walks a
// set containing b (torn) — and the watched-continue branch fires when the
// deferral set still holds the current watch target.
b.svc.scope(sid('a'))
b.svc.open(sid('b')) // stage: b; both scoped
await feedList(b, []) // a removed off stage → torn immediately; b removed staged → deferred
// Move the stage to a THIRD id while b stays deferred: sweep walks a set
// containing b (torn).
await feedList(b, [{ id: 'c' }])
b.svc.binding(sid('c'))
b.svc.open(sid('c'))
expect(b.svc.scope(sid('b'))).toBeUndefined()
// Deferral for an id whose record was never minted: force-add via removed
// list state (scope teardown raced) — sweep must tolerate the missing record.
await feedList(b, [])
b.svc.binding(sid('c')) // c now watched+removed → deferred
// Deferral for an id whose record was never minted: force the deferral
// via removed list state — sweep must tolerate the missing record.
await feedList(b, []) // c removed while staged → deferred (scope exists)
await feedList(b, [{ id: 'd' }])
b.svc.binding(sid('d')) // sweep tears c
b.svc.open(sid('d')) // sweep tears c
expect(b.svc.scope(sid('c'))).toBeUndefined()
})

View File

@@ -1,80 +1,405 @@
/**
* SlotsService: cordis Service wrapper semantics — core delegation, the
* 'slots/changed' event bridge, and fiber-scoped registration disposal.
* SlotsService terminal-design account (design.md §11-3 main landing):
* built-in 'root', the three load-time throws (duplicate declaration /
* undeclared contribution / cross-scope store handle), the renderer install
* seam (double install / not installed / non-root key), store instance
* resolution and lifecycle on the ledger axis, and the entry-unload cascade.
*/
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import type { FC } from 'react'
import type { SlotRendererHost } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotsService } from '../src/client/slots.ts'
// Test-only slot keys (SlotMap is empty in this package; the service is generic over it).
// Test-only slot keys (merged so the typed entries/spec faces accept them).
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
't-single': { kind: 'single'; scope: 'root'; props: object }
't-list': { kind: 'list'; scope: 'root'; props: object }
't.host': { kind: 'single'; scope: 'root' }
't.panel': { kind: 'single'; scope: 'session' }
't.rows': { kind: 'list'; scope: 'root' }
}
}
const C: FC<object> = () => null
async function boot(): Promise<Context> {
/**
* Register/install/renderSlot through a type-erased view: the typed register
* face rides wave-1 ui-slots types (red until that wave lands); the runtime
* semantics under test are final.
*/
interface ErasedService {
register(options: object, component: unknown): () => void
install(renderer: object): void
renderSlot(key: string, owner: object): unknown
}
interface Bench {
ctx: Context
svc: SlotsService
erased: ErasedService
}
async function boot(): Promise<Bench> {
const ctx = new Context()
ctx.plugin(SlotsService)
await ctx.fiber.await()
return ctx
// Service accessor (ctx.get reads the reflect store, which Service-class
// plugins do not write; the accessor is the product path).
const svc = ctx.slots
return { ctx, svc, erased: svc as unknown as ErasedService }
}
describe('SlotsService', () => {
it('proxies define/register/entries/spec/getVersion to the core', async () => {
const ctx = await boot()
ctx.slots.define('t-single', { kind: 'single', scope: 'root' })
expect(ctx.slots.spec('t-single')).toEqual({ kind: 'single', scope: 'root' })
const v0 = ctx.slots.getVersion('t-single')
ctx.slots.register('t-single', C)
expect(ctx.slots.entries('t-single')).toHaveLength(1)
expect(ctx.slots.getVersion('t-single')).toBeGreaterThan(v0)
expect(ctx.slots.core.spec('t-single')).toBeDefined()
/** Engine-shaped instance stub (bare-source form: subscribe/getSnapshot + baked actions + clearPersisted). */
interface FakeInstance {
getSnapshot: () => undefined
subscribe: () => () => void
actions: Record<string, never>
clearPersisted: ReturnType<typeof vi.fn>
}
/** Fake store handle factory (create-count and clearPersisted observable). */
function fakeHandle() {
const created: FakeInstance[] = []
const handle = {
create: vi.fn((_scopeKey?: string): FakeInstance => {
const instance: FakeInstance = {
getSnapshot: () => undefined, subscribe: () => () => undefined,
actions: {}, clearPersisted: vi.fn(),
}
created.push(instance)
return instance
}),
}
return { handle, created }
}
/**
* Install a capturing renderer, occupy 'root' (declaring `children` in the
* same call — 'root' is single, so the one occupant is also the declarer),
* and pull the host face out through renderSlot('root').
*/
function captureHost(bench: Bench, children?: object): SlotRendererHost {
let host: SlotRendererHost | undefined
bench.erased.install({
renderRoot: (h: SlotRendererHost) => { host = h; return 'rendered' },
})
bench.erased.register({ name: 'root', ...(children !== undefined ? { children } : {}) }, C)
bench.ctx.reflect.provide('sessions', fakeSessions())
bench.ctx.reflect.provide('workspaces', fakeWorkspaces())
bench.erased.renderSlot('root', {})
if (host === undefined) throw new Error('renderer never received the host')
return host
}
/** Minimal independent Workspace list source for the renderer host seam. */
function fakeWorkspaces() {
const state = { items: [], phase: 'ready' as const }
return { list: { getSnapshot: () => state, subscribe: () => () => undefined } }
}
/** Minimal sessions face for the host seam (list observable + current provide projection). */
function fakeSessions() {
const state = { ids: [], byId: {}, current: undefined as string | undefined }
const absentInfo = { sessionId: undefined, hooks: { session: undefined }, props: {} }
return {
list: { getSnapshot: () => state, subscribe: () => () => undefined },
currentProvideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => undefined },
}
}
describe("built-in 'root'", () => {
it('is declared at construction: spec readable, occupancy open, no plugin needed', async () => {
const bench = await boot()
expect(bench.svc.spec('root')).toEqual({ kind: 'single', scope: 'root' })
expect(() => bench.erased.register({ name: 'root' }, C)).not.toThrow()
expect(bench.svc.entries('root')).toHaveLength(1)
})
it("re-emits every mutation as 'slots/changed' with the key", async () => {
const ctx = await boot()
const seen: string[] = []
ctx.on('slots/changed', (key) => { seen.push(key) })
ctx.slots.define('t-list', { kind: 'list', scope: 'root' })
ctx.slots.register('t-list', C, { id: 'a' })
expect(seen).toEqual(['t-list', 't-list'])
it('rejects a second declaration of root, attributing the built-in row', async () => {
const bench = await boot()
expect(() => bench.erased.register({
name: 'root', children: { 'root': { kind: 'single', scope: 'root' } },
}, C)).toThrow(/already declared.*built-in/)
})
})
describe('load-time validation', () => {
it('throws on contributing into an undeclared slot', async () => {
const bench = await boot()
expect(() => bench.erased.register({ name: 't.host' }, C)).toThrow(/slot "t.host" is not declared/)
})
it('collects a plugin fiber\'s registrations when the fiber unloads (cascade)', async () => {
const ctx = await boot()
ctx.slots.define('t-single', { kind: 'single', scope: 'root' })
const fiber = ctx.plugin({
it('throws on a duplicate declaration, naming the slot and the prior declarant', async () => {
const bench = await boot()
bench.erased.register({ name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } } }, C)
bench.erased.register({
name: 't.host', children: { 't.rows': { kind: 'list', scope: 'root' } },
}, C)
expect(() => bench.erased.register({
name: 't.rows', id: 'r1', children: { 't.rows': { kind: 'list', scope: 'root' } },
}, C)).toThrow(/slot "t.rows" is already declared.*"t.host"/)
})
it('throws when one store handle is bound to two scopes', async () => {
const bench = await boot()
bench.erased.register({
name: 'root',
children: {
't.host': { kind: 'single', scope: 'root' },
't.panel': { kind: 'single', scope: 'session' },
},
}, C)
const { handle } = fakeHandle()
bench.erased.register({ name: 't.host', store: handle }, C)
expect(() => bench.erased.register({ name: 't.panel', store: handle }, C))
.toThrow(/one handle, one scope/)
})
it('commits nothing when the core rejects the entry (children stay undeclared)', async () => {
const bench = await boot()
bench.erased.register({ name: 'root' }, C) // 'root' single slot now occupied
expect(() => bench.erased.register({
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
}, C)).toThrow(/already has a registration/)
// The failing call's declaration must not have landed.
expect(() => bench.erased.register({ name: 't.host' }, C)).toThrow(/is not declared/)
})
})
describe('renderer install seam', () => {
it('throws on renderSlot before install (boot-order guidance)', async () => {
const bench = await boot()
expect(() => bench.erased.renderSlot('root', {})).toThrow(/renderer not installed/)
})
it('throws on double install', async () => {
const bench = await boot()
bench.erased.install({ renderRoot: () => null })
expect(() => { bench.erased.install({ renderRoot: () => null }) }).toThrow(/already installed/)
})
it('throws on any non-root key (single ctx-level entry)', async () => {
const bench = await boot()
bench.erased.install({ renderRoot: () => null })
expect(() => bench.erased.renderSlot('t.host', {})).toThrow(/only renders 'root'/)
})
it("throws on renderSlot('root') before any root registration", async () => {
const bench = await boot()
bench.erased.install({ renderRoot: () => null })
expect(() => bench.erased.renderSlot('root', {})).toThrow(/no registration/)
})
it('renders through the installed renderer and returns its product', async () => {
const bench = await boot()
const renderRoot = vi.fn(() => 'tree')
bench.erased.install({ renderRoot })
bench.erased.register({ name: 'root' }, C)
bench.ctx.reflect.provide('sessions', fakeSessions())
bench.ctx.reflect.provide('workspaces', fakeWorkspaces())
expect(bench.erased.renderSlot('root', {})).toBe('tree')
expect(renderRoot).toHaveBeenCalledTimes(1)
})
it('fails before rendering when the Workspace object layer is absent', async () => {
const bench = await boot()
bench.erased.install({ renderRoot: () => null })
bench.erased.register({ name: 'root' }, C)
bench.ctx.reflect.provide('sessions', fakeSessions())
expect(() => bench.erased.renderSlot('root', {})).toThrow(/workspaces service mounted/)
})
})
describe('host face', () => {
it('serves entriesOf/specOf/isLive off the ledger and flips isLive on disposal', async () => {
const bench = await boot()
const host = captureHost(bench, { 't.host': { kind: 'single', scope: 'root' } })
const dispose = bench.erased.register({ name: 't.host' }, C)
const rootEntry = host.entriesOf('root')[0]
expect(rootEntry).toBeDefined()
expect(rootEntry?.component).toBe(C)
expect(host.specOf('root')).toEqual({ kind: 'single', scope: 'root' })
expect(host.specOf('t.host')).toEqual({ kind: 'single', scope: 'root' })
const childEntry = host.entriesOf('t.host')[0]
expect(host.isLive(childEntry as never)).toBe(true)
dispose()
expect(host.isLive(childEntry as never)).toBe(false)
expect(host.entriesOf('t.host')).toHaveLength(0)
})
it('exposes the session list and the atomic current provide projection', async () => {
const bench = await boot()
const host = captureHost(bench)
expect(host.sessions.list.getSnapshot()).toMatchObject({ ids: [] })
expect(host.sessions.provideInfo.getSnapshot()).toMatchObject({ sessionId: undefined })
})
it('exposes the independent Workspace list source', async () => {
const bench = await boot()
const host = captureHost(bench)
expect(host.workspaces.list.getSnapshot()).toEqual({ items: [], phase: 'ready' })
})
})
describe('store instance axis', () => {
/** Boot with 'root' occupied and the three test children declared. */
async function storeBench() {
const bench = await boot()
const host = captureHost(bench, {
't.host': { kind: 'single', scope: 'root' },
't.rows': { kind: 'list', scope: 'root' },
't.panel': { kind: 'single', scope: 'session' },
})
return { bench, host }
}
it('resolves one instance per (handle x root scope) shared across entries', async () => {
const { bench, host } = await storeBench()
const { handle } = fakeHandle()
bench.erased.register({ name: 't.host', store: handle }, C)
bench.erased.register({ name: 't.rows', id: 'a', store: handle }, C)
const [hostEntry] = host.entriesOf('t.host')
const [rowEntry] = host.entriesOf('t.rows')
const a = host.storeOf(hostEntry as never, undefined)
const b = host.storeOf(rowEntry as never, undefined)
expect(a).toBeDefined()
expect(a).toBe(b) // shared handle, same scope key = same instance
expect(handle.create).toHaveBeenCalledTimes(1)
expect(handle.create).toHaveBeenCalledWith() // root scope: keyless create
})
it('resolves per-session instances keyed by session id, created with the scope key', async () => {
const { bench, host } = await storeBench()
const { handle } = fakeHandle()
bench.erased.register({ name: 't.panel', store: handle }, C)
const [entry] = host.entriesOf('t.panel')
const s1 = host.storeOf(entry as never, 's1')
const s2 = host.storeOf(entry as never, 's2')
expect(s1).not.toBe(s2)
expect(host.storeOf(entry as never, 's1')).toBe(s1) // cached per key
expect(handle.create).toHaveBeenCalledWith('s1')
expect(handle.create).toHaveBeenCalledWith('s2')
expect(() => host.storeOf(entry as never, undefined)).toThrow(/requires a session id/)
})
it('mints a fresh handle per register for the factory (exclusive) form', async () => {
const { bench, host } = await storeBench()
const factory = vi.fn(() => fakeHandle().handle)
bench.erased.register({ name: 't.host', store: factory }, C)
bench.erased.register({ name: 't.rows', id: 'a', store: factory }, C)
expect(factory).toHaveBeenCalledTimes(2)
const a = host.storeOf(host.entriesOf('t.host')[0] as never, undefined)
const b = host.storeOf(host.entriesOf('t.rows')[0] as never, undefined)
expect(a).not.toBe(b) // two mints, two instances
})
it('drops instances with the last holding entry and refuses stale resolution', async () => {
const { bench, host } = await storeBench()
const { handle } = fakeHandle()
const d1 = bench.erased.register({ name: 't.host', store: handle }, C)
bench.erased.register({ name: 't.rows', id: 'a', store: handle }, C)
const rowEntry = host.entriesOf('t.rows')[0]
const hostEntry = host.entriesOf('t.host')[0]
const shared = host.storeOf(rowEntry as never, undefined)
d1() // one holder left: record (and instance) survive
expect(host.storeOf(rowEntry as never, undefined)).toBe(shared)
expect(() => host.storeOf(hostEntry as never, undefined)).not.toThrow() // handle still live via the row entry
// Note: dropping the row entry would sever the last reference; stale
// resolution is covered through the cascade spec below.
})
it('pruneStoreScope clears persisted state per dead session, including never-materialized ones', async () => {
const { bench, host } = await storeBench()
const { handle, created } = fakeHandle()
bench.erased.register({ name: 't.panel', store: handle }, C)
const [entry] = host.entriesOf('t.panel')
const s1 = host.storeOf(entry as never, 's1')
expect(s1).toBe(created[0]) // the resolved instance is the fake the handle minted
bench.svc.pruneStoreScope('s1')
expect(created[0]?.clearPersisted).toHaveBeenCalledTimes(1)
expect(host.storeOf(entry as never, 's1')).not.toBe(s1) // instance dropped, next resolve mints anew
// Never-rendered dead session: a transient instance is created just to clear storage.
const before = created.length
bench.svc.pruneStoreScope('s-never')
expect(created.length).toBe(before + 1)
expect(created[created.length - 1]?.clearPersisted).toHaveBeenCalledTimes(1)
})
})
describe('entry-unload cascade', () => {
it('kills declared children, their contributions, and the ledger rows with the entry', async () => {
const bench = await boot()
let host: SlotRendererHost | undefined
bench.erased.install({
renderRoot: (h: SlotRendererHost) => { host = h; return 'rendered' },
})
bench.ctx.reflect.provide('sessions', fakeSessions())
bench.ctx.reflect.provide('workspaces', fakeWorkspaces())
// The declarer here is NOT the root occupant: root stays occupied by a
// separate entry so disposing the declarer only kills its children.
const disposeRoot = bench.erased.register({ name: 'root' }, C)
bench.erased.renderSlot('root', {})
if (host === undefined) throw new Error('renderer never received the host')
disposeRoot()
const disposeDeclarer = bench.erased.register({
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
}, C)
bench.erased.register({ name: 't.host' }, C)
const [childEntry] = host.entriesOf('t.host')
expect(childEntry).toBeDefined()
disposeDeclarer()
expect(bench.svc.spec('t.host')).toBeUndefined() // ledger row gone
expect(host.specOf('t.host')).toBeUndefined() // outlets now render empty
expect(bench.svc.entries('t.host')).toHaveLength(0) // contribution cleared
expect(host.isLive(childEntry as never)).toBe(false) // stale bindings will throw upstream
// The freed key is re-declarable by a new entry (no residue).
expect(() => bench.erased.register({
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
}, C)).not.toThrow()
})
it('cascades through cordis fiber disposal (plugin unload = full cleanup)', async () => {
const bench = await boot()
bench.erased.register({
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
}, C)
const fiber = bench.ctx.plugin({
name: 'occupant',
inject: ['slots'],
apply: (pluginCtx: Context) => {
pluginCtx.slots.register('t-single', C)
;(pluginCtx.slots as unknown as ErasedService).register({ name: 't.host' }, C)
},
})
await fiber.await()
expect(ctx.slots.entries('t-single')).toHaveLength(1)
expect(bench.svc.entries('t.host')).toHaveLength(1)
await fiber.dispose()
expect(ctx.slots.entries('t-single')).toHaveLength(0)
// The slot definition (registered from root) survives; a new occupant may register.
expect(() => ctx.slots.register('t-single', C)).not.toThrow()
expect(bench.svc.entries('t.host')).toHaveLength(0)
expect(bench.svc.spec('t.host')).toBeDefined() // declarer still live; slot stays declared
})
it('proxies specDynamic/subscribe/getVersion through the core', async () => {
const ctx = await boot()
ctx.slots.define('t-list', { kind: 'list', scope: 'root' })
expect(ctx.slots.specDynamic('t-list')).toEqual({ kind: 'list', scope: 'root' })
expect(ctx.slots.specDynamic('never-defined')).toBeUndefined()
let notified = 0
const unsubscribe = ctx.slots.subscribe('t-list', () => { notified += 1 })
ctx.slots.register('t-list', C, { id: 'row' })
await new Promise(resolve => setTimeout(resolve, 0)) // microtask-batched flush
expect(notified).toBeGreaterThan(0)
expect(ctx.slots.getVersion('t-list')).toBeGreaterThan(0)
unsubscribe()
it('disposer is idempotent (stale second call is a no-op)', async () => {
const bench = await boot()
const dispose = bench.erased.register({
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
}, C)
dispose()
expect(() => { dispose() }).not.toThrow()
expect(() => bench.erased.register({
name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
}, C)).not.toThrow()
})
})
describe('event bridge', () => {
it("re-emits entry writes and child declarations as 'slots/changed'", async () => {
const bench = await boot()
const seen: string[] = []
bench.ctx.on('slots/changed', (key) => { seen.push(key) })
bench.erased.register({
name: 'root', children: { 't.rows': { kind: 'list', scope: 'root' } },
}, C)
bench.erased.register({ name: 't.rows', id: 'a' }, C)
expect(seen).toEqual(['root', 't.rows', 't.rows'])
})
})

View File

@@ -0,0 +1,227 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createSnapshotStore, defineStore, shallowEqual } from '../src/client/contract/store.ts'
interface State {
a: { n: number }
b: { list: string[] }
}
const init = (): State => ({ a: { n: 1 }, b: { list: ['x'] } })
afterEach(() => {
vi.unstubAllGlobals()
})
describe('createSnapshotStore', () => {
it('applies update through a draft and preserves untouched branch references', () => {
const store = createSnapshotStore(init())
const before = store.getSnapshot()
store.update((d) => { d.a.n = 2 })
const after = store.getSnapshot()
expect(after).not.toBe(before)
expect(after.a.n).toBe(2)
expect(after.b).toBe(before.b)
})
it('notifies synchronously per update by default', () => {
const store = createSnapshotStore(init())
const seen: number[] = []
store.subscribe(() => { seen.push(store.getSnapshot().a.n) })
store.update((d) => { d.a.n = 2 })
store.update((d) => { d.a.n = 3 })
expect(seen).toEqual([2, 3])
})
it('coalesces a frame of updates into one notification in raf mode', () => {
const frame: FrameRequestCallback[] = []
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
frame.push(cb)
return frame.length
})
const store = createSnapshotStore(init(), { flush: 'raf' })
const spy = vi.fn()
store.subscribe(spy)
store.update((d) => { d.a.n = 2 })
store.update((d) => { d.a.n = 3 })
store.update((d) => { d.b.list.push('y') })
expect(spy).not.toHaveBeenCalled()
expect(frame).toHaveLength(1)
frame.shift()!(0)
expect(spy).toHaveBeenCalledTimes(1)
expect(store.getSnapshot().a.n).toBe(3)
// Next frame batches independently.
store.update((d) => { d.a.n = 4 })
expect(frame).toHaveLength(1)
frame.shift()!(0)
expect(spy).toHaveBeenCalledTimes(2)
})
it('falls back to microtask batching in raf mode without requestAnimationFrame', async () => {
const store = createSnapshotStore(init(), { flush: 'raf' })
const spy = vi.fn()
store.subscribe(spy)
store.update((d) => { d.a.n = 2 })
store.update((d) => { d.a.n = 3 })
expect(spy).not.toHaveBeenCalled()
await Promise.resolve()
expect(spy).toHaveBeenCalledTimes(1)
})
it('unsubscribes raf-mode listeners', () => {
const frame: FrameRequestCallback[] = []
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
frame.push(cb)
return frame.length
})
const store = createSnapshotStore(init(), { flush: 'raf' })
const spy = vi.fn()
const off = store.subscribe(spy)
store.update((d) => { d.a.n = 2 })
off()
frame.shift()!(0)
expect(spy).not.toHaveBeenCalled()
})
it('replaces state wholesale via set and freezes it outside production', () => {
const store = createSnapshotStore(init())
const next = init()
store.set(next)
expect(store.getSnapshot()).toBe(next)
expect(() => { (store.getSnapshot().a).n = 9 }).toThrow()
})
it('freezes update produce output outside production (immer dev freeze)', () => {
const store = createSnapshotStore(init())
store.update((d) => { d.a.n = 2 })
expect(() => { (store.getSnapshot().a).n = 9 }).toThrow()
})
it('rehydrates primitive state whole, not spread into index keys', () => {
const backing = new Map<string, string>()
vi.stubGlobal('localStorage', {
getItem: (k: string) => backing.get(k) ?? null,
setItem: (k: string, v: string) => { backing.set(k, v) },
removeItem: (k: string) => { backing.delete(k) },
})
const store = createSnapshotStore<string>('', { persist: { name: 'spec-draft' } })
store.set('hello')
const revived = createSnapshotStore<string>('', { persist: { name: 'spec-draft' } })
expect(revived.getSnapshot()).toBe('hello')
})
it('persists to localStorage under the given name and rehydrates', () => {
const backing = new Map<string, string>()
vi.stubGlobal('localStorage', {
getItem: (k: string) => backing.get(k) ?? null,
setItem: (k: string, v: string) => { backing.set(k, v) },
removeItem: (k: string) => { backing.delete(k) },
})
const store = createSnapshotStore(init(), { persist: { name: 'spec-store' } })
store.update((d) => { d.a.n = 42 })
expect(backing.has('spec-store')).toBe(true)
const revived = createSnapshotStore(init(), { persist: { name: 'spec-store' } })
expect(revived.getSnapshot().a.n).toBe(42)
})
})
describe('defineStore', () => {
const declare = () => defineStore({
init: () => ({ selection: null as string | null, draft: '' }),
actions: {
select: (d, target: string) => { d.selection = target },
setDraft: (d, text: string) => { d.draft = text },
clearDraft: (d) => { d.draft = '' },
},
})
it('create() yields a live instance: fresh init state, selector-visible action writes', () => {
const inst = declare().create()
expect(inst.store.getSnapshot()).toEqual({ selection: null, draft: '' })
inst.actions.setDraft('hello')
inst.actions.select('m1')
expect(inst.store.getSnapshot()).toEqual({ selection: 'm1', draft: 'hello' })
inst.actions.clearDraft()
expect(inst.store.getSnapshot().draft).toBe('')
})
it('bakes draft-stripped actions that write through update (draft mutation, not replacement)', () => {
const inst = declare().create()
const before = inst.store.getSnapshot()
inst.actions.setDraft('x')
const after = inst.store.getSnapshot()
expect(after).not.toBe(before)
expect(after.selection).toBe(before.selection) // untouched branch preserved (immer path)
})
it('creates independent instances per create() call (the handle is a spec, not a singleton)', () => {
const handle = declare()
const a = handle.create()
const b = handle.create()
a.actions.setDraft('only-a')
expect(b.store.getSnapshot().draft).toBe('')
})
it('suffixes the persist key with the scope key: per-session persistence plus clearPersisted cleanup', () => {
const backing = new Map<string, string>()
vi.stubGlobal('localStorage', {
getItem: (k: string) => backing.get(k) ?? null,
setItem: (k: string, v: string) => { backing.set(k, v) },
removeItem: (k: string) => { backing.delete(k) },
})
const handle = defineStore({
init: () => ({ draft: '' }),
persist: 'spec.chat',
actions: { setDraft: (d, text: string) => { d.draft = text } },
})
handle.create('s1').actions.setDraft('one')
handle.create('s2').actions.setDraft('two')
handle.create().actions.setDraft('root')
expect(JSON.parse(backing.get('spec.chat.s1')!)).toEqual({ draft: 'one' })
expect(JSON.parse(backing.get('spec.chat.s2')!)).toEqual({ draft: 'two' })
expect(JSON.parse(backing.get('spec.chat')!)).toEqual({ draft: 'root' })
// Rehydration honors the same suffixed key.
expect(handle.create('s1').store.getSnapshot().draft).toBe('one')
// Scope-death cleanup removes exactly the suffixed key.
handle.create('s1').clearPersisted()
expect(backing.has('spec.chat.s1')).toBe(false)
expect(backing.has('spec.chat.s2')).toBe(true)
expect(backing.has('spec.chat')).toBe(true)
})
it('clearPersisted is a no-op without a persist declaration or without storage', () => {
const inst = declare().create('s1') // no persist key declared
expect(() => { inst.clearPersisted() }).not.toThrow()
const persisting = defineStore({
init: () => ({ n: 0 }),
persist: 'spec.nostorage',
actions: { inc: (d) => { d.n += 1 } },
}).create()
// jsdom-less lane: localStorage may exist here, so simulate its absence.
vi.stubGlobal('localStorage', undefined)
expect(() => { persisting.clearPersisted() }).not.toThrow()
})
it('swallows storage failures in clearPersisted (same non-fatal contract as persistence)', () => {
vi.stubGlobal('localStorage', {
getItem: () => null,
setItem: () => {},
removeItem: () => { throw new Error('quota / private mode') },
})
const inst = defineStore({
init: () => ({ n: 0 }),
persist: 'spec.throwing',
actions: { inc: (d) => { d.n += 1 } },
}).create()
expect(() => { inst.clearPersisted() }).not.toThrow()
})
})
describe('shallowEqual', () => {
it('matches one-level-equal objects and rejects deeper drift', () => {
const leaf = { deep: 1 }
expect(shallowEqual({ x: 1, y: leaf }, { x: 1, y: leaf })).toBe(true)
expect(shallowEqual({ x: 1, y: { deep: 1 } }, { x: 1, y: { deep: 1 } })).toBe(false)
expect(shallowEqual([1, 2], [1, 2])).toBe(true)
expect(shallowEqual([1, 2], [2, 1])).toBe(false)
})
})

View File

@@ -0,0 +1,55 @@
/**
* Wire-to-typed-event bridge (web input-triggers cut 1): host/commands-changed
* → ctx 'commands/changed'; each established connection generation →
* ctx 'connection/reset' (the forced cache-invalidation broadcast).
*/
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import type { ConnectionHandle, ConnectionSinks } from '@deepseek-ai/dsh-client-connection/client'
import * as RuntimeClient from '../src/client/index.ts'
import { FakeApiClient } from './fake-api.ts'
interface Bench {
ctx: Context
sinks: ConnectionSinks | undefined
}
async function mount(): Promise<Bench> {
const ctx = new Context()
const api = new FakeApiClient()
const bench: Bench = { ctx, sinks: undefined }
const handle: ConnectionHandle = {
api,
start: (sinks) => {
bench.sinks = sinks
return { stop: () => {} }
},
}
ctx.reflect.provide('connection', handle)
await ctx.plugin(RuntimeClient).await()
return bench
}
describe('wire event bridge', () => {
it('broadcasts commands/changed on a host/commands-changed frame, not on other host frames', async () => {
const bench = await mount()
let changed = 0
bench.ctx.on('commands/changed', () => { changed++ })
bench.sinks?.onHostEnvelope?.({ rpcId: 'r1' as never, payload: { type: 'host/commands-changed' } })
expect(changed).toBe(1)
bench.sinks?.onHostEnvelope?.({
rpcId: 'r2' as never,
payload: { type: 'host/session-status', sessionId: 's1' as never, running: true },
})
expect(changed).toBe(1)
})
it('broadcasts connection/reset on every established generation (reconnect invalidation)', async () => {
const bench = await mount()
let resets = 0
bench.ctx.on('connection/reset', () => { resets++ })
bench.sinks?.onConnected?.()
bench.sinks?.onConnected?.() // second generation after a reconnect
expect(resets).toBe(2)
})
})

View File

@@ -0,0 +1,265 @@
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client'
import { SessionsService } from '../src/client/sessions/service.ts'
import { WorkspaceManager } from '../src/client/workspaces/manager.ts'
import { WorkspaceCreateError, WorkspacesService } from '../src/client/workspaces/service.ts'
import { FakeApiClient, deferred, err, ok } from './fake-api.ts'
const sid = (id: string): SessionId => id as SessionId
const wid = (id: string): WorkspaceId => id as WorkspaceId
function workspace(id: string, sessionIds: SessionId[] = [], createdAt = '2026-01-01T00:00:00.000Z'): WorkspaceView {
return {
workspaceId: wid(id), path: `/w/${id}`, title: id, sessionIds,
createdAt, updatedAt: createdAt,
}
}
describe('WorkspaceManager', () => {
it('replays changed frames over hydration and keeps established order on refresh', async () => {
const api = new FakeApiClient()
const gate = deferred<Awaited<ReturnType<FakeApiClient['onWorkspaceList']>>>()
api.onWorkspaceList = () => gate.promise
const manager = new WorkspaceManager(api)
const hydration = manager.refresh()
manager.handleHostEnvelope({
rpcId: 'changed' as never,
payload: { type: 'host/workspace-changed', workspace: workspace('new') },
})
gate.resolve(ok({ items: [workspace('old')] as never[] }))
await hydration
expect(manager.getSnapshot()).toMatchObject({ phase: 'ready', state: 'idle' })
expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['new', 'old'])
api.onWorkspaceList = () => Promise.resolve(ok({
items: [workspace('old'), workspace('new')] as never[],
}))
await manager.refresh()
expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['new', 'old'])
})
it('single-flights refreshes and exposes result and transport failures independently of readiness', async () => {
const api = new FakeApiClient()
const gate = deferred<Awaited<ReturnType<FakeApiClient['onWorkspaceList']>>>()
api.onWorkspaceList = () => gate.promise
const manager = new WorkspaceManager(api)
const first = manager.refresh()
const second = manager.refresh()
expect(manager.getSnapshot().state).toBe('loading')
gate.resolve(ok({ items: [] }))
await Promise.all([first, second])
expect(api.callsOf('workspace.list')).toHaveLength(1)
api.onWorkspaceList = () => Promise.resolve(err({ code: 'internal', message: 'down', details: {} }))
await manager.refresh()
expect(manager.getSnapshot()).toMatchObject({ phase: 'ready', state: 'error', error: { message: 'down' } })
api.onWorkspaceList = () => Promise.reject(new Error('wire down'))
await manager.refresh()
expect(manager.getSnapshot()).toMatchObject({ phase: 'ready', state: 'error', error: { message: 'wire down' } })
})
it('creates by name/path, prepends a new row, and folds failures', async () => {
const api = new FakeApiClient()
const manager = new WorkspaceManager(api)
api.onWorkspaceCreate = payload => Promise.resolve(ok({
workspace: workspace('created', [], '2026-02-01T00:00:00.000Z'),
created: true,
payload,
} as never))
await expect(manager.create({ name: 'created' })).resolves.toMatchObject({ ok: true })
expect(api.callsOf('workspace.create')).toEqual([{ name: 'created' }])
expect(manager.getSnapshot().items[0]?.workspaceId).toBe('created')
api.onWorkspaceCreate = () => Promise.reject(new Error('create transport'))
await expect(manager.create({ path: '/w/existing' })).resolves.toMatchObject({
ok: false, error: { code: 'internal', message: 'create transport' },
})
})
it('replays removal over an in-flight baseline and ignores duplicate or late updates', async () => {
const api = new FakeApiClient()
const gate = deferred<Awaited<ReturnType<FakeApiClient['onWorkspaceList']>>>()
api.onWorkspaceList = () => gate.promise
const manager = new WorkspaceManager(api)
const hydration = manager.refresh()
manager.handleHostEnvelope({
rpcId: 'removed' as never,
payload: { type: 'host/workspace-removed', workspaceId: wid('gone') },
})
gate.resolve(ok({ items: [workspace('gone'), workspace('kept')] as never[] }))
await hydration
expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['kept'])
manager.handleHostEnvelope({
rpcId: 'late-change' as never,
payload: { type: 'host/workspace-changed', workspace: workspace('gone') },
})
manager.handleHostEnvelope({
rpcId: 'duplicate-remove' as never,
payload: { type: 'host/workspace-removed', workspaceId: wid('gone') },
})
expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['kept'])
})
it('removes from the unary delete echo while a refresh is in flight', async () => {
const api = new FakeApiClient()
api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('gone')] as never[] }))
const manager = new WorkspaceManager(api)
await manager.refresh()
const gate = deferred<Awaited<ReturnType<FakeApiClient['onWorkspaceList']>>>()
api.onWorkspaceList = () => gate.promise
const refresh = manager.refresh()
await expect(manager.delete(wid('gone'))).resolves.toMatchObject({ ok: true })
expect(api.callsOf('workspace.delete')).toEqual([{ workspaceId: 'gone' }])
expect(manager.getSnapshot().items).toEqual([])
gate.resolve(ok({ items: [workspace('gone')] as never[] }))
await refresh
expect(manager.getSnapshot().items).toEqual([])
})
})
describe('WorkspacesService', () => {
it('feeds readiness and recent-Workspace targeting without changing Host order', async () => {
const ctx = new Context()
const api = new FakeApiClient()
const sessions = new SessionsService(ctx, api)
const workspaces = new WorkspacesService(ctx, api, sessions)
api.onWorkspaceList = () => Promise.resolve(ok({
items: [
workspace('stable-first', [], '2026-01-03T00:00:00.000Z'),
workspace('active', [sid('s-active')], '2026-01-01T00:00:00.000Z'),
] as never[],
}))
await workspaces.refresh()
await Promise.resolve()
expect(workspaces.list.getSnapshot()).toMatchObject({ baselinesReady: false, recentWorkspaceId: undefined })
api.onList = () => Promise.resolve(ok({
items: [{ sessionId: sid('s-active'), updatedAt: Date.parse('2026-02-01'), running: false, blank: false }] as never[],
}))
await sessions.refresh()
await Promise.resolve()
await Promise.resolve()
expect(workspaces.list.getSnapshot()).toMatchObject({
baselinesReady: true,
recentWorkspaceId: 'active',
})
expect(workspaces.list.getSnapshot().items.map(item => item.workspaceId)).toEqual(['stable-first', 'active'])
})
it('connectWorkspace reuses the workspace-matched blank session and creates otherwise', async () => {
const ctx = new Context()
const api = new FakeApiClient()
const sessions = new SessionsService(ctx, api)
const workspaces = new WorkspacesService(ctx, api, sessions)
api.onWorkspaceList = () => Promise.resolve(ok({
items: [workspace('alpha'), workspace('beta')] as never[],
}))
api.onList = () => Promise.resolve(ok({
items: [
// Blank session already parked in alpha (cwd == workspace path canon).
{ sessionId: sid('s-blank'), updatedAt: 2, running: false, blank: true, cwd: '/w/alpha' },
// Non-blank sibling in beta must never be reused.
{ sessionId: sid('s-active'), updatedAt: 3, running: false, blank: false, cwd: '/w/beta' },
] as never[],
}))
await Promise.all([workspaces.refresh(), sessions.refresh()])
await Promise.resolve()
// Hit: same workspace → the parked blank session comes back, no create RPC.
await expect(workspaces.connectWorkspace(wid('alpha'))).resolves.toBe('s-blank')
expect(api.callsOf('session.create')).toEqual([])
// Resolution guarantee: the id is binding-resolvable synchronously.
expect(sessions.binding(sid('s-blank'))).toBeDefined()
// Miss: beta has only a non-blank session → host create with workspaceId.
api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s-fresh') }))
await expect(workspaces.connectWorkspace(wid('beta'))).resolves.toBe('s-fresh')
expect(api.callsOf('session.create')).toEqual([{ workspaceId: 'beta' }])
// Same guarantee on the create arm (draft hand-off writes the machine pre-open).
expect(sessions.binding(sid('s-fresh'))).toBeDefined()
// Unknown workspace fails loud instead of silently creating in nowhere.
await expect(workspaces.connectWorkspace(wid('ghost'))).rejects.toThrow(/unknown workspace ghost/)
})
it('a rejected first prompt keeps the blank session eligible for connectWorkspace reuse', async () => {
const ctx = new Context()
const api = new FakeApiClient()
const sessions = new SessionsService(ctx, api)
const workspaces = new WorkspacesService(ctx, api, sessions)
api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('alpha')] as never[] }))
api.onList = () => Promise.resolve(ok({
items: [{ sessionId: sid('s-blank'), updatedAt: 2, running: false, blank: true, cwd: '/w/alpha' }] as never[],
}))
await Promise.all([workspaces.refresh(), sessions.refresh()])
await Promise.resolve()
const session = sessions.binding(sid('s-blank'))!.session
api.onPrompt = () => Promise.resolve(err({ code: 'internal', message: 'agent busy', details: {} }) as never)
await session.prompt([{ type: 'text', text: 'hi' }], 'queue')
await Promise.resolve()
// Failure leaves blank intact, so the same session is still the reuse hit.
await expect(workspaces.connectWorkspace(wid('alpha'))).resolves.toBe('s-blank')
expect(api.callsOf('session.create')).toEqual([])
})
it('returns created Workspaces and preserves Host business errors', async () => {
const ctx = new Context()
const api = new FakeApiClient()
const sessions = new SessionsService(ctx, api)
const workspaces = new WorkspacesService(ctx, api, sessions)
api.onWorkspaceCreate = () => Promise.resolve(ok({
workspace: { ...workspace('picked'), path: '/w/alpha', title: 'alpha' }, created: true,
}))
await expect(workspaces.create({ path: '/w/alpha' })).resolves.toMatchObject({ workspaceId: 'picked' })
expect(workspaces.list.getSnapshot().items[0]).toMatchObject({ path: '/w/alpha', title: 'alpha' })
expect(api.callsOf('workspace.create')).toEqual([{ path: '/w/alpha' }])
api.onWorkspaceCreate = () => Promise.resolve(err({
code: 'workspace-invalid-path', message: 'missing', details: { path: '/missing' },
}))
const rejected = workspaces.create({ path: '/missing' })
await expect(rejected).rejects.toThrow(/workspace-invalid-path: missing/)
await expect(rejected).rejects.toBeInstanceOf(WorkspaceCreateError)
})
it('passes native directory selection and cancellation through without local state', async () => {
const ctx = new Context()
const api = new FakeApiClient()
const sessions = new SessionsService(ctx, api)
const workspaces = new WorkspacesService(ctx, api, sessions)
api.onPickDirectory = () => Promise.resolve(ok({ path: '/w/alpha' }))
await expect(workspaces.pickDirectory()).resolves.toBe('/w/alpha')
api.onPickDirectory = () => Promise.resolve(ok({ path: null }))
await expect(workspaces.pickDirectory()).resolves.toBeNull()
expect(api.callsOf('host.pickDirectory')).toEqual([{}, {}])
})
it('opens a filesystem path through the host without local state', async () => {
const ctx = new Context()
const api = new FakeApiClient()
const sessions = new SessionsService(ctx, api)
const workspaces = new WorkspacesService(ctx, api, sessions)
await expect(workspaces.openPath('/w/alpha/a.ts')).resolves.toBeUndefined()
expect(api.callsOf('host.openPath')).toEqual([{ path: '/w/alpha/a.ts' }])
api.onOpenPath = () => Promise.resolve(err({ code: 'internal', message: 'boom', details: {} }))
await expect(workspaces.openPath('/missing')).rejects.toThrow(/path open failed/)
})
it('deletes a Workspace or preserves it when the Host rejects deletion', async () => {
const ctx = new Context()
const api = new FakeApiClient()
const sessions = new SessionsService(ctx, api)
const workspaces = new WorkspacesService(ctx, api, sessions)
api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('alpha')] as never[] }))
await workspaces.refresh()
await expect(workspaces.delete(wid('alpha'))).resolves.toBeUndefined()
expect(workspaces.list.getSnapshot().items).toEqual([])
api.onWorkspaceDelete = () => Promise.resolve(err({
code: 'workspace-not-found', message: 'gone', details: { workspaceId: 'ghost' },
}))
await expect(workspaces.delete(wid('ghost'))).rejects.toThrow(/workspace-not-found: gone/)
})
})