Merge remote-tracking branch 'origin/master' into dshw/pr-deepseek-harness-deepseek-harness-2672

This commit is contained in:
_Kerman
2026-08-31 13:35:17 +08:00
2998 changed files with 105016 additions and 41585 deletions
@@ -0,0 +1,319 @@
import { mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
import { TypertLookupFailure } from '@deepseek-ai/dsh-typert-protocol'
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
ApiSessionAgentController,
ApiSessionCwdConflict,
ApiSessionNotFound,
ApiSessionSubagentOwnership,
inspectApiSession,
} from '../src/agent.ts'
const roots: Context[] = []
afterEach(async () => {
await Promise.all(roots.splice(0).map(ctx => ctx.fiber.dispose()))
})
async function harness(): Promise<{ ctx: Context; agents: ApiSessionAgentController }> {
const ctx = new Context()
roots.push(ctx)
await ctx.plugin(TypertRegistry)
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
ctx.provide('agentDefaultModel', {
currentSelection: () => ({ provider: 'fixture', model: 'fixture-model' }),
saveSelection: () => Promise.resolve(),
} as never)
return { ctx, agents: new ApiSessionAgentController(ctx) }
}
function header(id: string, cwd: string | null = '/workspace'): SessionHeader {
return {
version: 0,
id: SessionId(id),
createdAt: 1,
...(cwd === null ? {} : { cwd }),
}
}
function agent(ctx: Context, meta: SessionHeader): Agent {
const session = ctx.sessions.create(meta.id, { meta })
return { id: meta.id, session, status: 'idle', ctx } as Agent
}
function unpublishedAgent(ctx: Context, meta: SessionHeader): Agent {
return {
id: meta.id,
session: { id: meta.id, header: meta, events: [] },
status: 'idle',
ctx,
} as unknown as Agent
}
describe('ApiSession identity failures', () => {
it('describes cwd conflicts with and without a recorded cwd', () => {
expect(new ApiSessionCwdConflict(SessionId('missing-cwd'), '/wanted', undefined).message)
.toContain('records no cwd')
expect(new ApiSessionCwdConflict(SessionId('wrong-cwd'), '/wanted', '/existing').message)
.toContain('belongs to "/existing"')
})
it('rejects absent persistence, catalog misses, and cwd-less inspected artifacts', async () => {
const ctx = new Context()
roots.push(ctx)
await expect(inspectApiSession(ctx, SessionId('missing')))
.rejects.toThrow('session persistence is not configured')
const inspect = vi.fn(() => Promise.resolve({ meta: header('missing'), events: [] as SessionEvent[] }))
const disposeMissing = ctx.provide('sessionPersistence', {
list: () => Promise.resolve([]),
inspect,
} as never)
await expect(inspectApiSession(ctx, SessionId('missing'))).rejects.toBeInstanceOf(ApiSessionNotFound)
expect(inspect).not.toHaveBeenCalled()
disposeMissing()
const listed = header('cwd-less-catalog', null)
const disposeListed = ctx.provide('sessionPersistence', {
list: () => Promise.resolve([listed]),
inspect,
} as never)
await expect(inspectApiSession(ctx, listed.id)).rejects.toBeInstanceOf(ApiSessionNotFound)
disposeListed()
const catalog = header('cwd-less-inspect')
const inspected = header('cwd-less-inspect', null)
ctx.provide('sessionPersistence', {
list: () => Promise.resolve([catalog]),
inspect: () => Promise.resolve({ meta: inspected, events: [] }),
} as never)
await expect(inspectApiSession(ctx, catalog.id)).rejects.toBeInstanceOf(ApiSessionNotFound)
})
})
describe('ApiSession Agent lookup and recovery', () => {
it('projects live Agent contexts and maps missing cold identities through Typert lookup failures', async () => {
const { ctx } = await harness()
const live = agent(ctx, header('live'))
ctx.agents.register(live)
ctx.provide('sessionPersistence', {
list: () => Promise.resolve([]),
inspect: vi.fn(),
} as never)
const host = ctx.typert.contexts.getHost('agent')
if (host === undefined) throw new Error('Agent Context resolver was not registered')
await expect(host.resolve(live.id)).resolves.toBe(live.ctx)
await expect(host.resolve(SessionId('missing'))).rejects.toBeInstanceOf(TypertLookupFailure)
})
it('returns raced ordinary Agents and ownership failures after resume throws', async () => {
const ordinary = await harness()
const ordinaryMeta = header('ordinary-race')
ordinary.ctx.provide('sessionPersistence', {
list: () => Promise.resolve([ordinaryMeta]),
inspect: () => Promise.resolve({ meta: ordinaryMeta, events: [] }),
} as never)
const winner = agent(ordinary.ctx, ordinaryMeta)
vi.spyOn(ordinary.ctx.agents, 'resume').mockImplementation(async () => {
ordinary.ctx.agents.register(winner)
throw new Error('raced publication')
})
await expect(ordinary.agents.resolveAgent(ordinaryMeta.id)).resolves.toEqual({ agent: winner })
const child = await harness()
const childMeta = header('child-race')
child.ctx.provide('sessionPersistence', {
list: () => Promise.resolve([childMeta]),
inspect: () => Promise.resolve({ meta: childMeta, events: [] }),
} as never)
vi.spyOn(child.ctx.agents, 'resume').mockImplementation(async () => {
child.ctx.sessions.create(childMeta.id, {
meta: { ...childMeta, parentSession: SessionId('parent'), origin: 'subagent' },
})
throw new Error('raced child publication')
})
await expect(child.agents.resolveAgent(childMeta.id)).resolves.toMatchObject({
error: { code: 'agent-busy' },
})
})
it('reports not-found and ordinary resume failures without fabricating an Agent', async () => {
const missing = await harness()
missing.ctx.provide('sessionPersistence', {
list: () => Promise.resolve([]),
inspect: vi.fn(),
} as never)
await expect(missing.agents.resolveAgent(SessionId('missing'))).resolves.toMatchObject({
error: { code: 'session-not-found' },
})
const failed = await harness()
const meta = header('failed')
failed.ctx.provide('sessionPersistence', {
list: () => Promise.resolve([meta]),
inspect: () => Promise.resolve({ meta, events: [] }),
} as never)
vi.spyOn(failed.ctx.agents, 'resume').mockRejectedValue(new Error('factory unavailable'))
await expect(failed.agents.resolveAgent(meta.id)).resolves.toMatchObject({
error: { code: 'internal', message: expect.stringContaining('factory unavailable') as string },
})
})
})
describe('ApiSession create or adoption', () => {
it('shares one in-flight creation between concurrent callers', async () => {
const { ctx, agents } = await harness()
const cwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-concurrent-'))
const meta = header('concurrent-create', cwd)
const created = unpublishedAgent(ctx, meta)
let release!: () => void
const gate = new Promise<void>((resolve) => { release = resolve })
const create = vi.spyOn(ctx.agents, 'create').mockImplementation(async () => {
await gate
return { agent: created, dispose: () => Promise.resolve() }
})
const first = agents.ensureSession(meta.id, cwd, false)
const second = agents.ensureSession(meta.id, cwd, false)
release()
await expect(Promise.all([first, second])).resolves.toEqual([created, created])
expect(create).toHaveBeenCalledOnce()
})
it('accepts a raced ordinary creation and rejects a raced attached child', async () => {
const ordinary = await harness()
const cwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-create-'))
const ordinaryMeta = header('create-race', cwd)
const winner = agent(ordinary.ctx, ordinaryMeta)
vi.spyOn(ordinary.ctx.agents, 'create').mockImplementation(async () => {
ordinary.ctx.agents.register(winner)
throw new Error('raced creation')
})
await expect(ordinary.agents.ensureSession(ordinaryMeta.id, cwd, false))
.resolves.toBe(winner)
const child = await harness()
const childCwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-child-'))
const childId = SessionId('create-child-race')
vi.spyOn(child.ctx.agents, 'create').mockImplementation(async () => {
child.ctx.sessions.create(childId, {
meta: { cwd: childCwd, parentSession: SessionId('parent'), origin: 'subagent' },
})
throw new Error('raced child creation')
})
await expect(child.agents.ensureSession(childId, childCwd, false))
.rejects.toBeInstanceOf(ApiSessionSubagentOwnership)
})
it('validates ownership and cwd on the Agent returned by creation', async () => {
const child = await harness()
const childCwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-returned-child-'))
const childMeta = {
...header('returned-child', childCwd),
parentSession: SessionId('parent'),
origin: 'subagent' as const,
}
const childAgent = unpublishedAgent(child.ctx, childMeta)
vi.spyOn(child.ctx.agents, 'create').mockResolvedValue({
agent: childAgent,
dispose: () => Promise.resolve(),
})
await expect(child.agents.ensureSession(childMeta.id, childCwd, false))
.rejects.toBeInstanceOf(ApiSessionSubagentOwnership)
const wrong = await harness()
const requestedCwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-wrong-cwd-'))
const wrongAgent = unpublishedAgent(wrong.ctx, header('wrong-returned-cwd', '/other'))
vi.spyOn(wrong.ctx.agents, 'create').mockResolvedValue({
agent: wrongAgent,
dispose: () => Promise.resolve(),
})
await expect(wrong.agents.ensureSession(wrongAgent.id, requestedCwd, false))
.rejects.toBeInstanceOf(ApiSessionCwdConflict)
})
it('resumes a matching persisted identity and preserves its selected preset', async () => {
const { ctx, agents } = await harness()
const meta = { ...header('stored'), agentPreset: 'minimal' }
const events = [{
type: 'agent-preset/selected',
seq: 0,
time: 1,
data: { agentPreset: 'minimal' },
}] as SessionEvent[]
ctx.provide('sessionPersistence', {
list: () => Promise.resolve([meta]),
inspect: () => Promise.resolve({ meta, events }),
} as never)
ctx.provide('agentPresets', {
resolve: (id?: string) => Promise.resolve({ id: id ?? 'minimal' }),
mount: () => Promise.resolve(),
} as never)
const resumed = {
id: meta.id,
session: { id: meta.id, header: meta, events },
status: 'idle',
ctx,
} as unknown as Agent
const resume = vi.spyOn(ctx.agents, 'resume').mockResolvedValue({
agent: resumed,
dispose: () => Promise.resolve(),
})
await expect(agents.ensureSession(meta.id, '/workspace', true, 'minimal')).resolves.toBe(resumed)
expect(resume).toHaveBeenCalledWith(expect.objectContaining({ resumeSessionId: meta.id }))
})
it('rejects an ownership race before resume and a persisted cwd conflict', async () => {
const child = await harness()
const childMeta = header('resume-child-race')
child.ctx.provide('sessionPersistence', {
list: () => Promise.resolve([childMeta]),
inspect: () => Promise.resolve({ meta: childMeta, events: [] }),
} as never)
child.ctx.provide('agentPresets', {
resolve: () => {
child.ctx.sessions.create(childMeta.id, {
meta: { ...childMeta, parentSession: SessionId('parent'), origin: 'subagent' },
})
return Promise.resolve({ id: 'standard' })
},
mount: () => Promise.resolve(),
} as never)
await expect(child.agents.resolveAgent(childMeta.id)).resolves.toMatchObject({
error: { code: 'agent-busy' },
})
const conflict = await harness()
const stored = header('stored-cwd-conflict', '/stored')
conflict.ctx.provide('sessionPersistence', {
list: () => Promise.resolve([stored]),
inspect: () => Promise.resolve({ meta: stored, events: [] }),
} as never)
await expect(conflict.agents.ensureSession(stored.id, '/requested', true))
.rejects.toBeInstanceOf(ApiSessionCwdConflict)
})
it('surfaces directory creation failure and rejects setup without a scoped Agent', async () => {
const { agents } = await harness()
const parent = mkdtempSync(join(tmpdir(), 'dsh-session-controller-file-'))
const file = join(parent, 'file')
writeFileSync(file, 'not a directory')
await expect(agents.ensureSession(SessionId('mkdir-failure'), join(file, 'child'), false))
.rejects.toThrow('failed to ensure project directory')
const composition = await agents.composeAgent(undefined)
expect(() => composition.setup(new Context())).toThrow('Agent setup has no scoped Agent')
})
})
@@ -0,0 +1,218 @@
import { Context } from '@deepseek-ai/cordis'
import type { Fiber } from '@deepseek-ai/cordis'
import type {
ConnectionHandle,
HostDescription,
} from '@deepseek-ai/dsh-client-connection/client'
import {
RemoteStreamCarrierError,
RemoteStream,
type RemoteStreamOptions,
} from '@deepseek-ai/dsh-api-gateway/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
import { afterEach, describe, expect, it, vi } from 'vitest'
import * as SessionClient from '../src/client/index.ts'
import { ClientSessions } from '../src/client/sessions/service.ts'
import { FakeApiClient, fakeRemote } from './fake-api.client.ts'
const DESCRIPTION: HostDescription = {
version: 'fixture',
cwd: '/fixture',
attachedSessions: 0,
home: '/home/fixture',
canOpenPath: true,
}
const sid = (value: string): SessionId => value as SessionId
type RemoteListener = (...args: never[]) => void
interface Bench {
readonly ctx: Context
readonly api: FakeApiClient
readonly fiber: Fiber
readonly sessions: ClientSessions
dispatch(event: string, ...args: unknown[]): void
publishHost(description: HostDescription | undefined): void
}
const contexts = new Set<Context>()
afterEach(async () => {
vi.restoreAllMocks()
await Promise.all([...contexts].map(async (ctx) => { await ctx.fiber.dispose() }))
contexts.clear()
})
async function mount(initialHost?: HostDescription): Promise<Bench> {
const ctx = new Context()
contexts.add(ctx)
await ctx.plugin(TypertRegistry)
const api = new FakeApiClient()
const remote = fakeRemote(api)
const listeners = new Map<string, Set<RemoteListener>>()
const hostListeners = new Set<() => void>()
let host = initialHost
const connection: ConnectionHandle = {
api,
isLoopback: true,
hostDescription: {
getSnapshot: () => host,
subscribe: (listener) => {
hostListeners.add(listener)
return () => { hostListeners.delete(listener) }
},
},
rpc: {
call: () => Promise.reject(new Error('unexpected generic RPC call')),
},
registerGenerationSource: () => () => {},
start: () => ({ stop: () => {} }),
}
ctx.reflect.provide('connection', connection)
ctx.reflect.provide('remote', {
...remote,
$stream: <Item>(options: RemoteStreamOptions<Item>) => (
new RemoteStream(connection, options)
),
$on: (event: string, listener: RemoteListener) => {
const eventListeners = listeners.get(event) ?? new Set<RemoteListener>()
eventListeners.add(listener)
listeners.set(event, eventListeners)
return () => { eventListeners.delete(listener) }
},
})
ctx.reflect.provide('remote.commands', remote.commands)
ctx.reflect.provide('remote.session', remote.session)
const fiber = ctx.plugin(SessionClient)
await fiber
const sessions = ctx.sessions as ClientSessions
return {
ctx,
api,
fiber,
sessions,
dispatch: (event, ...args) => {
for (const listener of listeners.get(event) ?? []) listener(...args as never[])
},
publishHost: (description) => {
host = description
for (const listener of [...hostListeners]) listener()
},
}
}
async function flush(): Promise<void> {
for (let index = 0; index < 12; index++) await Promise.resolve()
}
describe('Session Controller Client apply', () => {
it('routes Session Remote Events and connection generations into the object layer', async () => {
const connected = vi.spyOn(ClientSessions.prototype, 'handleConnected')
const error = vi.spyOn(ClientSessions.prototype, 'handleSessionError')
const bench = await mount()
expect(connected).not.toHaveBeenCalled()
bench.dispatch('api-session/added', {
sessionId: sid('session-1'),
updatedAt: 1,
running: false,
blank: true,
})
await flush()
expect(bench.sessions.list.getSnapshot().byId[sid('session-1')]).toMatchObject({
running: false,
updatedAt: 1,
})
bench.dispatch('api-session/status', sid('session-1'), true)
bench.dispatch('api-session/activity', sid('session-1'), 9)
bench.dispatch('api-session/error', sid('session-1'), 'agent failed')
await flush()
expect(bench.sessions.list.getSnapshot().byId[sid('session-1')]).toMatchObject({
running: true,
updatedAt: 9,
})
expect(error).toHaveBeenCalledWith(sid('session-1'), 'agent failed')
bench.dispatch('api-session/removed', sid('session-1'))
await flush()
expect(bench.sessions.list.getSnapshot().byId[sid('session-1')]).toBeUndefined()
bench.ctx.emit('connection/reset')
expect(connected).toHaveBeenCalledOnce()
})
it('accepts the control baseline, retries a carrier generation, and reports terminal protocol failure', async () => {
const accept = vi.spyOn(ClientSessions.prototype, 'handleControlFrame')
const logged = vi.spyOn(console, 'error').mockImplementation(() => {})
const bench = await mount(DESCRIPTION)
await flush()
expect(accept).toHaveBeenCalledWith({
type: 'baseline',
value: { queues: {}, jobs: {}, projections: {} },
})
bench.api.failStreams(new RemoteStreamCarrierError('generation lost'))
await flush()
expect(accept.mock.calls.filter(([frame]) => frame.type === 'baseline')).toHaveLength(2)
bench.api.pushControl({ type: 'baseline', value: bench.api.controlBaseline } as never)
await vi.waitFor(() => {
expect(logged).toHaveBeenCalledWith(
'[session-controller] control stream failed:',
expect.objectContaining({ message: 'session control stream emitted more than one opening snapshot' }),
)
})
})
it('materializes Host-addressed Agent scopes before the Session list arrives', async () => {
const bench = await mount()
const adapter = bench.ctx.typert.contexts.getClient('agent')
const first = adapter?.resolve(sid('agent-early'))
expect(first).toBeDefined()
expect(bench.sessions.scopeOf(first as Context)).toBe(sid('agent-early'))
expect(adapter?.resolve(sid('agent-early'))).toBe(first)
})
it('projects Agent Context identity in both directions and withdraws the adapter on disposal', async () => {
const bench = await mount(DESCRIPTION)
await flush()
expect(bench.sessions.list.getSnapshot().phase).toBe('ready')
bench.dispatch('api-session/added', {
sessionId: sid('agent-1'),
updatedAt: 1,
running: false,
blank: true,
})
await flush()
const scoped = bench.sessions.scope(sid('agent-1'))
const adapter = bench.ctx.typert.contexts.getClient('agent')
expect(scoped).toBeDefined()
expect(adapter?.identity(bench.ctx)).toBeUndefined()
expect(adapter?.identity(scoped!)).toBe(sid('agent-1'))
expect(adapter?.resolve(sid('agent-1'))).toBe(scoped)
await bench.fiber.dispose()
expect(bench.ctx.typert.contexts.getClient('agent')).toBeUndefined()
})
it('waits for a Host generation before retrying the control stream', async () => {
const accept = vi.spyOn(ClientSessions.prototype, 'handleControlFrame')
const bench = await mount()
await flush()
expect(accept.mock.calls.filter(([frame]) => frame.type === 'baseline')).toHaveLength(1)
bench.api.failStreams(new RemoteStreamCarrierError('offline'))
await flush()
expect(accept.mock.calls.filter(([frame]) => frame.type === 'baseline')).toHaveLength(1)
bench.publishHost(DESCRIPTION)
await flush()
expect(accept.mock.calls.filter(([frame]) => frame.type === 'baseline')).toHaveLength(2)
})
})
@@ -0,0 +1,88 @@
import type { SessionEventEntry } from '@deepseek-ai/dsh-api-session-controller/types'
import { describe, expect, it, vi } from 'vitest'
import { MutableSessionEventSource } from '../src/client/contract/events.ts'
import { transportResult } from '../src/client/contract/result.ts'
function entry(seq: number): SessionEventEntry {
return {
event: {
type: 'fixture/event',
seq,
time: seq,
data: { seq },
ignorable: true,
},
}
}
describe('Client Session contracts', () => {
it('publishes exact replace, prepend, and append event-window changes', () => {
const feed = new MutableSessionEventSource()
const listener = vi.fn()
const dispose = feed.subscribe(listener)
const first = entry(1)
const older = entry(0)
const live = entry(2)
feed.replace([first], true)
expect(feed.getSnapshot()).toEqual({
entries: [first],
hasMore: true,
revision: 1,
change: { kind: 'replace', entries: [first] },
})
feed.prepend([older], false)
expect(feed.getSnapshot()).toEqual({
entries: [older, first],
hasMore: false,
revision: 2,
change: { kind: 'prepend', entries: [older] },
})
feed.append(live)
expect(feed.getSnapshot()).toEqual({
entries: [older, first, live],
hasMore: false,
revision: 3,
change: { kind: 'append', entries: [live] },
})
expect(listener).toHaveBeenCalledTimes(3)
dispose()
feed.append(entry(3))
expect(listener).toHaveBeenCalledTimes(3)
})
it('does not traverse the complete event window while appending', () => {
const feed = new MutableSessionEventSource()
const first = entry(1)
const base = [first]
const iterate = vi.fn(Array.prototype[Symbol.iterator].bind(base))
Object.defineProperty(base, Symbol.iterator, { value: iterate })
feed.replace(base, false)
iterate.mockClear()
const before = feed.getSnapshot()
const live = entry(2)
feed.append(live)
const after = feed.getSnapshot()
expect(iterate).not.toHaveBeenCalled()
expect(before.entries).toEqual([first])
expect(after.entries).toEqual([first, live])
expect(after.entries).toBe(after.entries)
expect(iterate).toHaveBeenCalledOnce()
})
it('folds Error and non-Error carrier rejections into Client failures', () => {
expect(transportResult(new Error('transport unavailable'))).toEqual({
ok: false,
error: { code: 'internal', message: 'transport unavailable', details: {} },
})
expect(transportResult(404)).toEqual({
ok: false,
error: { code: 'internal', message: '404', details: {} },
})
})
})
@@ -0,0 +1,267 @@
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent'
import { PresetMountError } from '@deepseek-ai/dsh-agent-presets'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { Workspace, WorkspaceId } from '@deepseek-ai/dsh-workspace'
import { describe, expect, it, vi } from 'vitest'
import {
ApiSessionAgentController,
ApiSessionCwdConflict,
} from '../src/agent.ts'
import { SessionCommandController } from '../src/commands.ts'
async function expectFailure(operation: Promise<unknown>, code: string): Promise<void> {
await expect(operation).rejects.toMatchObject({ failure: { code } })
}
function controllerAgents(overrides: object = {}): ApiSessionAgentController {
return {
ensureSession: () => Promise.resolve(),
composeAgent: () => Promise.resolve({ setup: () => {} }),
...overrides,
} as unknown as ApiSessionAgentController
}
async function baseContext(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
ctx.provide('agentDefaultModel', {
currentSelection: () => ({ provider: 'fixture', model: 'fixture-model' }),
saveSelection: () => Promise.resolve(),
} as never)
return ctx
}
describe('Session creation failures', () => {
it('mints an identity with the default cwd when no explicit target is supplied', async () => {
const ctx = await baseContext()
ctx.provide('workspaceRegistry', { get: () => undefined, list: () => [] } as never)
const ensureSession = vi.fn((sessionId: SessionId, cwd: string) => {
const session = ctx.sessions.create(sessionId, { meta: { cwd } })
return Promise.resolve({ id: sessionId, session } as Agent)
})
const controller = new SessionCommandController(
ctx,
controllerAgents({ ensureSession }),
'/default-workspace',
)
const created = await controller.create({})
expect(created.sessionId).toMatch(/^session-/)
expect(created).not.toHaveProperty('agentPreset')
expect(ensureSession).toHaveBeenCalledWith(
created.sessionId,
'/default-workspace',
false,
undefined,
)
await ctx.fiber.dispose()
})
it('maps missing Workspaces and attachment failures', async () => {
const missing = await baseContext()
missing.provide('workspaceRegistry', { get: () => undefined, list: () => [] } as never)
const missingController = new SessionCommandController(
missing,
controllerAgents(),
'/default',
)
await expectFailure(missingController.create({
workspaceId: 'missing' as WorkspaceId,
}), 'workspace-not-found')
await missing.fiber.dispose()
const failed = await baseContext()
const workspace = {
id: 'workspace-1' as WorkspaceId,
path: '/workspace',
attachSession: () => Promise.reject(new Error('read-only workspace')),
} as unknown as Workspace
failed.provide('workspaceRegistry', {
get: () => workspace,
list: () => [workspace],
} as never)
const failedController = new SessionCommandController(
failed,
controllerAgents(),
'/default',
)
await expectFailure(failedController.create({
sessionId: SessionId('workspace-session'),
workspaceId: workspace.id,
}), 'workspace-attach-failed')
await failed.fiber.dispose()
})
it.each([
{
error: new PresetMountError('broken', 'invalid composition'),
code: 'agent-preset-invalid',
},
{
error: new ApiSessionCwdConflict(SessionId('cwd-less'), '/requested', undefined),
code: 'session-conflict',
},
{
error: new ApiSessionCwdConflict(SessionId('wrong-cwd'), '/requested', '/stored'),
code: 'session-conflict',
},
{
error: new Error('factory unavailable'),
code: 'internal',
},
])('maps $code creation failures', async ({ error, code }) => {
const ctx = await baseContext()
ctx.provide('workspaceRegistry', { get: () => undefined, list: () => [] } as never)
const controller = new SessionCommandController(
ctx,
controllerAgents({ ensureSession: () => Promise.reject(error) }),
'/default',
)
await expectFailure(controller.create({
sessionId: SessionId('failed-create'), cwd: '/requested',
}), code)
await ctx.fiber.dispose()
})
it('rejects contradictory create targets', async () => {
const ctx = await baseContext()
const controller = new SessionCommandController(ctx, controllerAgents(), '/default')
await expectFailure(controller.create({
workspaceId: 'workspace-1' as WorkspaceId,
cwd: '/workspace',
}), 'bad-request')
await ctx.fiber.dispose()
})
})
function completedSession(
ctx: Context,
id: string,
cwd?: string,
lineage: { parentSession?: SessionId; origin?: 'subagent' } = {},
) {
const session = ctx.sessions.create(SessionId(id), {
meta: { ...(cwd === undefined ? {} : { cwd }), ...lineage },
})
session.append('turn/start', { turn: 1 })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'work' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
return session
}
function resolvedHandle(ctx: Context, sessionId: SessionId): AgentHandle {
return {
agent: { id: sessionId, status: 'idle', ctx } as Agent,
dispose: () => Promise.resolve(),
}
}
describe('Session fork failures', () => {
it('distinguishes missing cold sources from unavailable persistence', async () => {
const unavailable = await baseContext()
unavailable.provide('workspaceRegistry', { list: () => [] } as never)
const unavailableController = new SessionCommandController(
unavailable, controllerAgents(), '/default',
)
await expectFailure(unavailableController.fork({
sessionId: SessionId('missing'),
}), 'internal')
await unavailable.fiber.dispose()
const missing = await baseContext()
missing.provide('workspaceRegistry', { list: () => [] } as never)
missing.provide('sessionPersistence', {
list: () => Promise.resolve([]),
inspect: vi.fn(),
} as never)
const missingController = new SessionCommandController(missing, controllerAgents(), '/default')
await expectFailure(missingController.fork({
sessionId: SessionId('missing'),
}), 'session-not-found')
await missing.fiber.dispose()
})
it('rejects a Session with no completed turn', async () => {
const ctx = await baseContext()
ctx.provide('workspaceRegistry', { list: () => [] } as never)
const source = ctx.sessions.create(SessionId('empty-source'))
const controller = new SessionCommandController(ctx, controllerAgents(), '/default')
await expectFailure(controller.fork({ sessionId: source.id }), 'fork-unavailable')
await ctx.fiber.dispose()
})
it('maps lineage lookup and Agent creation failures', async () => {
const lineage = await baseContext()
lineage.provide('workspaceRegistry', { list: () => [] } as never)
lineage.provide('sessionQuery', {
traceSession: () => Promise.reject(new Error('lineage unavailable')),
} as never)
const child = completedSession(lineage, 'subagent-source', '/workspace', {
parentSession: SessionId('parent'),
origin: 'subagent',
})
const lineageController = new SessionCommandController(lineage, controllerAgents(), '/default')
await expectFailure(lineageController.fork({ sessionId: child.id }), 'internal')
await lineage.fiber.dispose()
const creation = await baseContext()
creation.provide('workspaceRegistry', { list: () => [] } as never)
const source = completedSession(creation, 'creation-source', '/workspace')
vi.spyOn(creation.agents, 'create').mockRejectedValue(new Error('factory failed'))
const creationController = new SessionCommandController(creation, controllerAgents(), '/default')
await expectFailure(creationController.fork({ sessionId: source.id }), 'internal')
await creation.fiber.dispose()
})
it('omits absent cwd and preset metadata before reporting Workspace attachment failure', async () => {
const ctx = await baseContext()
const source = completedSession(ctx, 'workspace-source')
const workspace = {
id: 'workspace-1' as WorkspaceId,
sessionIds: [source.id],
attachSession: () => Promise.reject(new Error('workspace write failed')),
} as unknown as Workspace
ctx.provide('workspaceRegistry', { list: () => [workspace] } as never)
const create = vi.spyOn(ctx.agents, 'create').mockImplementation(
(options: CreateAgentOptions) => Promise.resolve(resolvedHandle(ctx, options.sessionId)),
)
const controller = new SessionCommandController(ctx, controllerAgents(), '/default')
await expectFailure(controller.fork({ sessionId: source.id }), 'workspace-attach-failed')
const options = create.mock.calls[0]?.[0]
if (options === undefined) throw new Error('Agent creation was not attempted')
expect(options.meta).not.toHaveProperty('cwd')
expect(options.meta).not.toHaveProperty('agentPreset')
await ctx.fiber.dispose()
})
it('carries the composed Agent preset into the child metadata', async () => {
const ctx = await baseContext()
ctx.provide('workspaceRegistry', { list: () => [] } as never)
const source = completedSession(ctx, 'preset-source', '/workspace')
const create = vi.spyOn(ctx.agents, 'create').mockImplementation(
(options: CreateAgentOptions) => Promise.resolve(resolvedHandle(ctx, options.sessionId)),
)
const controller = new SessionCommandController(ctx, controllerAgents({
composeAgent: () => Promise.resolve({ agentPreset: 'minimal', setup: () => {} }),
}), '/default')
const forked = await controller.fork({ sessionId: source.id })
expect(forked.sessionId).toMatch(/^session-/)
const options = create.mock.calls[0]?.[0]
if (options === undefined) throw new Error('Agent creation was not attempted')
expect(options.meta?.agentPreset).toBe('minimal')
await ctx.fiber.dispose()
})
})
@@ -0,0 +1,226 @@
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
import type { Agent, ModelSelectionRef } from '@deepseek-ai/dsh-agent'
import { AttachmentError, AttachmentId } from '@deepseek-ai/dsh-attachment'
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import { createUserMessage, MessageId } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
import { describe, expect, it, vi } from 'vitest'
import { ApiSessionAgentController } from '../src/agent.ts'
import { SessionCommandController } from '../src/commands.ts'
async function commandHarness(): Promise<{
ctx: Context
controller: SessionCommandController
agent: Agent
inbox: Inbox
steer: ReturnType<typeof vi.fn>
cancel: ReturnType<typeof vi.fn>
}> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
const session = ctx.sessions.create(SessionId('commands-session'), { meta: { cwd: '/workspace' } })
const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
const steer = vi.fn()
const cancel = vi.fn()
const agent = {
id: session.id,
session,
inbox,
status: 'running',
ctx,
steer,
followup: vi.fn(),
cancel,
} as unknown as Agent
ctx.agents.register(agent)
ctx.provide('workspaceRegistry', { get: () => undefined, list: () => [] } as never)
ctx.provide('agentDefaultModel', {
currentSelection: () => ({ provider: 'fixture', model: 'fixture-model' }),
saveSelection: () => Promise.resolve(),
} as never)
const selection: ModelSelectionRef = {
current: { provider: 'fixture', model: 'fixture-model' },
assembled: undefined,
}
const agents = {
resolveAgent: () => Promise.resolve({ agent }),
selectionFor: () => selection,
serializeImageAdmission: <Value>(_agent: Agent, operation: () => Promise<Value>) => operation(),
composeAgent: () => Promise.resolve({ setup: () => {} }),
} as unknown as ApiSessionAgentController
return { ctx, controller: new SessionCommandController(ctx, agents, '/workspace'), agent, inbox, steer, cancel }
}
async function expectFailure(operation: Promise<unknown>, code: string): Promise<void> {
await expect(operation).rejects.toMatchObject({ failure: { code } })
}
describe('Session queue commands', () => {
it('edits, removes, steers, and rejects stale queue occurrences', async () => {
const { ctx, controller, agent, inbox, steer, cancel } = await commandHarness()
const queued = createUserMessage({ content: [{ type: 'text', text: 'queued' }], source: { kind: 'user' } })
const nextStep = createUserMessage({ content: [{ type: 'text', text: 'step' }], source: { kind: 'user' } })
inbox.append('next-turn', queued)
inbox.append('next-step', nextStep)
await expectFailure(Promise.resolve().then(() => controller.updateQueue({
sessionId: agent.id,
itemId: queued.id,
action: {
kind: 'edit',
content: [{
type: 'image',
attachment: {
attachmentId: AttachmentId('att-edit'), mediaType: 'image/png', bytes: 1, width: 1, height: 1,
},
}],
},
})), 'attachment-error')
await expectFailure(Promise.resolve().then(() => controller.updateQueue({
sessionId: SessionId('missing'), itemId: queued.id, action: { kind: 'remove' },
})), 'queue-item-not-found')
await expectFailure(Promise.resolve().then(() => controller.updateQueue({
sessionId: agent.id, itemId: MessageId('missing'), action: { kind: 'remove' },
})), 'queue-item-not-found')
await expectFailure(Promise.resolve().then(() => controller.updateQueue({
sessionId: agent.id, itemId: nextStep.id, action: { kind: 'steer' },
})), 'steer-unavailable')
Object.assign(agent, { status: 'idle' })
await expectFailure(Promise.resolve().then(() => controller.updateQueue({
sessionId: agent.id, itemId: queued.id, action: { kind: 'steer' },
})), 'steer-unavailable')
expect(controller.updateQueue({
sessionId: agent.id,
itemId: queued.id,
action: { kind: 'edit', content: [{ type: 'text', text: 'edited' }] },
})).toEqual({ accepted: true })
expect(inbox.nextTurn[0]?.content).toEqual([{ type: 'text', text: 'edited' }])
expect(controller.updateQueue({
sessionId: agent.id, itemId: nextStep.id, action: { kind: 'remove' },
})).toEqual({ accepted: true })
Object.assign(agent, { status: 'running' })
const steered = inbox.nextTurn[0]
if (steered === undefined) throw new Error('missing edited queue item')
expect(controller.updateQueue({
sessionId: agent.id, itemId: steered.id, action: { kind: 'steer' },
})).toEqual({ accepted: true })
expect(steer).toHaveBeenCalledWith(steered)
await expectFailure(Promise.resolve().then(() => controller.cancel({
sessionId: SessionId('missing'),
})), 'session-not-found')
expect(controller.cancel({ sessionId: agent.id })).toEqual({ accepted: true })
expect(cancel).toHaveBeenCalledWith({ kind: 'user' }, { keepInbox: true })
await ctx.fiber.dispose()
})
})
function imageRef(id: string): ImageAttachmentRef {
return {
attachmentId: AttachmentId(id),
mediaType: 'image/png',
bytes: 1,
width: 1,
height: 1,
}
}
function event(type: string, seq: number, data: unknown): SessionEvent {
return { type, seq, time: seq + 1, data } as SessionEvent
}
async function persistedController(
events: SessionEvent[],
readImage: (ref: ImageAttachmentRef) => Promise<{ ref: ImageAttachmentRef; data: Uint8Array }>,
): Promise<{ ctx: Context; controller: SessionCommandController; sessionId: SessionId }> {
const ctx = new Context()
await ctx.plugin(SessionStore)
const sessionId = SessionId('cold-attachment')
const meta: SessionHeader = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' }
ctx.provide('sessionPersistence', {
list: () => Promise.resolve([meta]),
inspect: () => Promise.resolve({ meta, events }),
} as never)
ctx.provide('attachments', { readImage } as never)
const agents = { resolveAgent: vi.fn() } as unknown as ApiSessionAgentController
return { ctx, controller: new SessionCommandController(ctx, agents, '/workspace'), sessionId }
}
describe('Session attachment authorization', () => {
it('finds references in direct, message, inserted, nested, and streamed content', async () => {
const nested = imageRef('nested')
const message = imageRef('message')
const inserted = imageRef('inserted')
const streamed = imageRef('streamed')
const events = [
event('fixture/direct', 0, {
content: [null, [], { type: 'tool-result', content: [{ type: 'text', text: 'none' }] }, {
type: 'tool-result', content: [{ type: 'image', attachment: nested }],
}],
}),
event('assistant/message', 1, { message: { content: [{ type: 'image', attachment: message }] } }),
event('agent/inbox/spliced', 2, { inserted: [{ content: [{ type: 'image', attachment: inserted }] }] }),
event('assistant/chunk', 3, {
chunk: { type: 'block-end', block: { type: 'image', attachment: streamed } },
}),
]
const readImage = vi.fn((ref: ImageAttachmentRef) => Promise.resolve({ ref, data: Uint8Array.of(1) }))
const { ctx, controller, sessionId } = await persistedController(events, readImage)
for (const ref of [nested, message, inserted, streamed]) {
await expect(controller.attachment({ sessionId, attachmentId: ref.attachmentId }))
.resolves.toEqual({ attachment: ref, data: 'AQ==' })
}
expect(readImage).toHaveBeenCalledTimes(4)
await ctx.fiber.dispose()
})
it('maps missing persistence identities and attachment backend failures', async () => {
const noPersistence = new Context()
await noPersistence.plugin(SessionStore)
const noPersistenceController = new SessionCommandController(
noPersistence,
{ resolveAgent: vi.fn() } as unknown as ApiSessionAgentController,
'/workspace',
)
await expectFailure(noPersistenceController.attachment({
sessionId: SessionId('missing'), attachmentId: AttachmentId('att'),
}), 'internal')
const missing = new Context()
await missing.plugin(SessionStore)
missing.provide('sessionPersistence', {
list: () => Promise.resolve([]),
inspect: vi.fn(),
} as never)
const missingController = new SessionCommandController(
missing,
{ resolveAgent: vi.fn() } as unknown as ApiSessionAgentController,
'/workspace',
)
await expectFailure(missingController.attachment({
sessionId: SessionId('missing'), attachmentId: 'att' as never,
}), 'session-not-found')
for (const thrown of [
new AttachmentError('stored image is unavailable', 'ATTACHMENT_NOT_FOUND'),
new Error('backend offline'),
]) {
const ref = imageRef(`failure-${thrown.name}`)
const fixture = await persistedController(
[event('fixture/content', 0, { content: [{ type: 'image', attachment: ref }] })],
() => Promise.reject(thrown),
)
await expectFailure(fixture.controller.attachment({
sessionId: fixture.sessionId,
attachmentId: ref.attachmentId,
}), thrown instanceof AttachmentError ? 'attachment-error' : 'internal')
await fixture.ctx.fiber.dispose()
}
})
})
@@ -0,0 +1,215 @@
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { JobOutcome } from '@deepseek-ai/dsh-jobs'
import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { Session } from '@deepseek-ai/dsh-session'
import { describe, expect, it } from 'vitest'
import { SessionControlController } from '../src/control.ts'
import type { SessionControlFrame } from '../src/types.ts'
type BaselineFrame = Extract<SessionControlFrame, { type: 'baseline' }>
type JobFrame = Extract<SessionControlFrame, { type: 'jobs' }>
function producer(label = 'sleep 60') {
let settle!: (outcome: JobOutcome) => void
const reads = { count: 0 }
const spec = {
kind: 'bash' as const,
label,
run: () => ({
cancel: () => {},
done: new Promise<JobOutcome>((resolve) => { settle = resolve }),
readOutput: () => { reads.count += 1; return 'stolen output' },
}),
}
return { spec, reads, settle: (outcome: JobOutcome) => { settle(outcome) } }
}
async function harness(withRegistry: boolean): Promise<{
ctx: Context
session: Session
agent: Agent
control: SessionControlController
}> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
if (withRegistry) {
await ctx.plugin(LocalJobRegistry)
ctx.jobs.attachController('session-controller-test')
}
const session = ctx.sessions.create()
const agent = {
id: session.id,
session,
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
status: 'idle',
ctx,
} as Agent
ctx.agents.register(agent)
const control = new SessionControlController(ctx)
await new Promise(resolve => setTimeout(resolve, 0))
return { ctx, session, agent, control }
}
async function baseline(control: SessionControlController): Promise<BaselineFrame> {
const abort = new AbortController()
const iterator = control.control(abort.signal)[Symbol.asyncIterator]()
const first = await iterator.next()
abort.abort()
await iterator.next()
if (first.done || first.value.type !== 'baseline') throw new Error('missing control baseline')
return first.value
}
async function collectJobs(
iterable: AsyncIterable<SessionControlFrame>,
count: number,
abort: AbortController,
): Promise<JobFrame[]> {
const jobs: JobFrame[] = []
for await (const frame of iterable) {
if (frame.type !== 'jobs') continue
jobs.push(frame)
if (jobs.length >= count) abort.abort()
}
return jobs
}
describe('Session control jobs baseline', () => {
it('represents an attached session with no jobs as an empty set', async () => {
const { session, control } = await harness(true)
const frame = await baseline(control)
expect(frame.value.jobs[session.id]).toEqual([])
})
it('carries the visible set when the stream opens', async () => {
const { ctx, session, agent, control } = await harness(true)
ctx.jobs.start({ ...producer('pnpm run build').spec, owner: agent })
const frame = await baseline(control)
const jobs = frame.value.jobs[session.id]
expect(jobs).toHaveLength(1)
const [job] = jobs ?? []
expect(job?.startedAt).toBeTypeOf('number')
expect({ ...job, startedAt: 0 }).toEqual({
id: 'bash-1',
kind: 'bash',
label: 'pnpm run build',
status: 'running',
startedAt: 0,
})
})
})
describe('Session control jobs updates', () => {
it('pushes the owner whole set on registration, stopping, and settlement', async () => {
const { ctx, session, agent, control } = await harness(true)
const abort = new AbortController()
const collected = collectJobs(control.control(abort.signal), 3, abort)
const task = producer()
const id = ctx.jobs.start({ ...task.spec, owner: agent })
ctx.jobs.kill(id, agent, 'test')
task.settle({ status: 'killed', detail: 'signal: SIGTERM' })
const frames = await collected
expect(frames.map(frame => frame.sessionId)).toEqual([session.id, session.id, session.id])
expect(frames.map(frame => frame.jobs[0]?.status)).toEqual(['running', 'stopping', 'killed'])
expect(frames[2]?.jobs[0]?.detail).toBe('signal: SIGTERM')
expect(frames[2]?.jobs[0]?.finishedAt).toBeTypeOf('number')
})
it('drops internal registry fields from the browser view', async () => {
const { ctx, agent, control } = await harness(true)
const abort = new AbortController()
const collected = collectJobs(control.control(abort.signal), 1, abort)
ctx.jobs.start({ ...producer().spec, owner: agent, outputLimitBytes: 1_024 })
const [frame] = await collected
expect(Object.keys(frame?.jobs[0] ?? {}).sort()).toEqual([
'id',
'kind',
'label',
'startedAt',
'status',
])
})
it('fans an unowned change out to every attached session', async () => {
const { ctx, control } = await harness(true)
const second = ctx.sessions.create()
const abort = new AbortController()
const collected = collectJobs(control.control(abort.signal), 2, abort)
ctx.jobs.start(producer('open to every caller').spec)
const frames = await collected
expect(new Set(frames.map(frame => frame.sessionId)).size).toBe(2)
expect(frames.some(frame => frame.sessionId === second.id)).toBe(true)
for (const frame of frames) expect(frame.jobs[0]?.label).toBe('open to every caller')
})
it('does not resume persisted sessions while projecting an unowned change', async () => {
const { ctx, control } = await harness(true)
const coldId = SessionId('session-cold-tasks')
let loaded = false
ctx.provide('sessionPersistence', {
list: async () => [{ version: 0, id: coldId, createdAt: 5, cwd: '/tmp' }],
locate: () => undefined,
load: () => { loaded = true; throw new Error('job projection must not load a cold log') },
} as never)
const abort = new AbortController()
const collected = collectJobs(control.control(abort.signal), 1, abort)
ctx.jobs.start(producer().spec)
await collected
expect(loaded).toBe(false)
expect(ctx.agents.get(coldId)).toBeUndefined()
})
it('reports empty sets when no jobs registry is composed', async () => {
const { session, control } = await harness(false)
const frame = await baseline(control)
expect(frame.value.jobs[session.id]).toEqual([])
})
it('never consumes model output while projecting a lifecycle', async () => {
const { ctx, agent, control } = await harness(true)
const abort = new AbortController()
const collected = collectJobs(control.control(abort.signal), 3, abort)
const task = producer()
const id = ctx.jobs.start({ ...task.spec, owner: agent })
ctx.jobs.kill(id, agent, 'test')
task.settle({ status: 'killed', detail: 'signal: SIGTERM' })
await collected
expect(task.reads.count).toBe(0)
})
it('never consumes model output while producing a baseline', async () => {
const { ctx, agent, control } = await harness(true)
const task = producer()
ctx.jobs.start({ ...task.spec, owner: agent })
const frame = await baseline(control)
expect(frame.value.jobs[agent.id]).toHaveLength(1)
expect(task.reads.count).toBe(0)
})
it('publishes existing unowned jobs for a session created after stream open', async () => {
const { ctx, control } = await harness(true)
const abort = new AbortController()
const collected = collectJobs(control.control(abort.signal), 2, abort)
ctx.jobs.start(producer('visible to every caller').spec)
const created = ctx.sessions.create()
const frames = await collected
const forNew = frames.filter(frame => frame.sessionId === created.id)
expect(forNew.at(-1)?.jobs[0]?.label).toBe('visible to every caller')
})
})
@@ -0,0 +1,120 @@
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import { describe, expect, it } from 'vitest'
import { SessionControlController } from '../src/control.ts'
async function harness(): Promise<{
ctx: Context
control: SessionControlController
agent: Agent
inbox: Inbox
}> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
const session = ctx.sessions.create(SessionId('queue-session'))
const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
const agent = { id: session.id, session, inbox, status: 'running', ctx } as Agent
ctx.agents.register(agent)
return { ctx, control: new SessionControlController(ctx), agent, inbox }
}
function message(text: string, source: 'user' | 'plugin' = 'user') {
return createUserMessage({
content: [{ type: 'text', text }],
source: source === 'user' ? { kind: 'user' } : { kind: 'plugin', plugin: 'fixture' },
})
}
describe('Session control queue projection', () => {
it('projects both pending lists in baselines and live replacement frames', async () => {
const { control, inbox } = await harness()
const queued = message('queued')
const steering = message('steering')
const context = message('context', 'plugin')
inbox.append('next-turn', queued)
inbox.append('next-step', steering)
inbox.append('next-step', context)
const abort = new AbortController()
const iterator = control.control(abort.signal)[Symbol.asyncIterator]()
const opened = await iterator.next()
expect(opened.value).toMatchObject({
type: 'baseline',
value: {
queues: {
'queue-session': [
{ id: queued.id, placement: 'queued' },
{ id: steering.id, placement: 'steering' },
{ id: context.id, placement: 'context' },
],
},
},
})
const replacement = message('replacement')
inbox.append('next-turn', replacement)
const replaced = await iterator.next()
if (replaced.done || replaced.value.type !== 'queue') throw new Error('missing queue replacement')
expect(replaced.value.items.map(item => item.id)).toContain(replacement.id)
inbox.remove(steering.id)
const removed = await iterator.next()
if (removed.done || removed.value.type !== 'queue') throw new Error('missing queue replacement')
expect(removed.value.items.map(item => item.id)).not.toContain(steering.id)
abort.abort()
await iterator.next()
})
it('ignores inbox events without the exact live Agent session', async () => {
const { ctx, control, agent, inbox } = await harness()
const abort = new AbortController()
const iterator = control.control(abort.signal)[Symbol.asyncIterator]()
await iterator.next()
const unrelated = ctx.sessions.create(SessionId('unrelated-queue'))
unrelated.append('agent/inbox/spliced', {
target: 'next-turn',
start: 0,
inserted: [message('unrelated')],
})
const replacement = ctx.sessions.create(SessionId('replacement-session'))
Object.defineProperty(agent, 'session', { configurable: true, value: replacement })
inbox.append('next-turn', message('wrong-session'))
abort.abort()
await iterator.next()
})
it('drops broadcasts after cancellation has ended its queue', async () => {
const { control, inbox } = await harness()
const abort = new AbortController()
const iterator = control.control(abort.signal)[Symbol.asyncIterator]()
await iterator.next()
const waiting = iterator.next()
await Promise.resolve()
abort.abort()
inbox.append('next-turn', message('late'))
await expect(waiting).resolves.toMatchObject({ done: true })
})
it('ends active streams on context disposal after flushing buffered frames', async () => {
const { ctx, control, inbox } = await harness()
const iterator = control.control(new AbortController().signal)[Symbol.asyncIterator]()
await iterator.next()
inbox.append('next-turn', message('first'))
inbox.append('next-turn', message('second'))
const first = await iterator.next()
expect(first).toMatchObject({ done: false, value: { type: 'queue' } })
await ctx.fiber.dispose()
const second = await iterator.next()
expect(second).toMatchObject({ done: false, value: { type: 'queue' } })
await expect(iterator.next()).resolves.toMatchObject({ done: true })
})
})
@@ -0,0 +1,77 @@
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
import { describe, expect, it, vi } from 'vitest'
import { createSessionTestController } from './test-remote.ts'
const defaults = {
defaultModelSelection: () => ({ provider: 'fixture', model: 'fixture-model' }),
cwd: '/tmp',
}
describe('SessionController facade', () => {
it('owns Host service methods and publishes Agent lifecycle projections', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
const sessionId = SessionId('controller-session')
const header: SessionHeader = {
version: 0,
id: sessionId,
createdAt: 1,
cwd: '/workspace',
}
const events: SessionEvent[] = []
const inspect = vi.fn(() => Promise.resolve({ meta: header, events }))
ctx.provide('sessionPersistence', {
list: () => Promise.resolve([header]),
inspect,
} as never)
const controller = createSessionTestController(ctx, defaults)
const status = vi.fn()
const failure = vi.fn()
const activity = vi.fn()
ctx.on('api-session/status', status)
ctx.on('api-session/error', failure)
ctx.on('api-session/activity', activity)
await expect(controller.inspect(sessionId)).resolves.toEqual({ meta: header, events })
expect(inspect).toHaveBeenCalledOnce()
const session = ctx.sessions.create(sessionId, { meta: header })
const agent = {
id: sessionId,
session,
status: 'idle',
ctx,
} as Agent
ctx.agents.register(agent)
await expect(controller.resolveAgent(sessionId)).resolves.toEqual({ agent })
await expect(controller.inspect(sessionId)).resolves.toEqual({ meta: header, events })
expect(inspect).toHaveBeenCalledOnce()
ctx.emit('agent/status', { agent, status: 'running' })
ctx.emit('agent/error', { agent, turn: 1, step: 0, error: new Error('fixture failure') })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'hello' }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
expect(status).toHaveBeenCalledWith(sessionId, true)
expect(failure).toHaveBeenCalledWith(sessionId, expect.stringContaining('fixture failure'))
expect(activity).toHaveBeenCalledWith(sessionId, expect.any(Number))
const abort = new AbortController()
const iterator = controller.follow({
address: { kind: 'session', sessionId },
}, abort.signal)[Symbol.asyncIterator]()
await expect(iterator.next()).resolves.toMatchObject({
done: false,
value: { type: 'opened', cursor: 0 },
})
abort.abort()
await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined })
})
})
@@ -0,0 +1,148 @@
import {
CallId, createMessage, createToolResultMessage, createUserMessage,
} 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'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
/** One text content block (local helper). */
const text = (t: string): ContentBlock[] => [{ type: 'text', text: t }]
const at = (seq: number, e: Record<string, unknown>): SessionEvent =>
({ seq, time: 1_700_000_000_000 + seq, ...e }) as unknown as SessionEvent
export const ev = {
turnStart: (seq: number, turn: number): SessionEvent =>
at(seq, { type: 'turn/start', data: { turn } }),
user: (seq: number, body: string): SessionEvent =>
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 =>
at(seq, { type: 'assistant/chunk', data: { turn, step, chunk: { type: 'block-start', index, blockType: 'text' } } }),
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,
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,
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: { rootCallId: parentCallId, 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: { rootCallId: parentCallId, 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 } }),
retry: (
seq: number,
turn: number,
step = 0,
retry = 1,
maxRetries = 2,
delayMs = 500,
message = 'temporary transport failure',
): SessionEvent =>
at(seq, {
type: 'llm/retry',
data: {
turn, step,
provider: 'fake', mode: 'normal', policyKey: 'fake-normal',
retry, maxRetries, delayMs,
failure: { code: 'TRANSPORT', message },
},
}),
turnEnd: (seq: number, turn: number, reason: 'completed' | 'aborted' | 'disposed' = 'completed'): SessionEvent =>
at(seq, { type: 'turn/end', data: {
turn,
reason: reason === 'completed'
? { kind: 'completed' }
: { kind: 'aborted', reason: { kind: reason === 'disposed' ? 'disposed' : 'user' } },
} }),
commandRun: (seq: number, commandId: string, name: string, args = ''): SessionEvent =>
at(seq, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } }),
commandRunWithoutInput: (seq: number, commandId: string, name: string): SessionEvent =>
at(seq, { type: 'command/run', data: { commandId, name, source: { kind: 'user' } } }),
commandDone: (
seq: number,
commandId: string,
kind: 'success' | 'error' = 'success',
text?: string,
sourceEventSeq?: number,
): SessionEvent =>
at(seq, { type: 'command/done', data: {
commandId,
kind,
...text === undefined ? {} : { text },
...sourceEventSeq === undefined ? {} : { sourceEventSeq },
} }),
/** A compaction's log-only `compaction/summary` record. */
compactSummary: (seq: number, summary: string, start: number, end: number): SessionEvent =>
at(seq, { type: 'compaction/summary', data: {
summary: text(summary),
shadowedRange: { start, end },
shadowedSeqs: [start, end],
shadowedTokenCount: 100,
provider: 'fake',
model: 'compact-1',
} }),
/** The replacement user message a compaction backend lands (the checkpoint). */
compactCheckpoint: (seq: number, summarySeq: number, start: number, end: number): SessionEvent =>
at(seq, {
type: 'user/message',
surfaceOp: { op: 'replace', start, end },
sourceEventSeqs: [summarySeq, start, end],
data: createUserMessage({
content: text('<context_checkpoint>model only</context_checkpoint>'),
source: { kind: 'plugin', plugin: 'compact' },
}),
}),
}
/** One complete plain turn (turn/start → user → step → assistant → turn/end), 6 events from startSeq. */
export function plainTurn(startSeq: number, turn: number, ask: string, answer: string): SessionEvent[] {
return [
ev.turnStart(startSeq, turn),
ev.user(startSeq + 1, ask),
ev.stepStart(startSeq + 2, turn),
ev.assistant(startSeq + 3, turn, answer),
ev.stepEnd(startSeq + 4, turn),
ev.turnEnd(startSeq + 5, turn),
]
}
/** Wrap raw events as view-less history entries (the wire shape history returns). */
export function entries(events: readonly SessionEvent[]): { event: SessionEvent }[] {
return events.map(event => ({ event }))
}
@@ -0,0 +1,566 @@
// 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). Session streams are hand pumps: pushFollow/pushControl.
import type {
IApiClient,
RpcError, RpcResponse, SessionId, SessionSearchItem, SkillEntry,
WorkspaceId, WorkspaceView,
} from '@deepseek-ai/dsh-api-remotes/client'
import type {
SessionAddress,
SessionControlBaseline,
SessionControlFrame,
SessionFollowFrame,
SessionFollowRequest,
SessionModels,
SessionPage,
SessionPageRequest,
SessionSelectModelRequest,
SessionSelectModelValue,
} from '@deepseek-ai/dsh-api-session-controller/types'
import type { WorkspaceRemote } from '@deepseek-ai/dsh-api-workspace-controller/client'
import type { WorkspaceFollowFrame } from '@deepseek-ai/dsh-api-workspace-controller/types'
import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
import {
RemoteStream,
type RemoteStreamOptions,
} from '@deepseek-ai/dsh-api-gateway/client'
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
import type { SessionRemotes } from '../src/client/sessions/remotes.ts'
const AVAILABLE_STREAM_CONNECTION = {
hostDescription: {
getSnapshot: () => ({
version: 'fixture', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true,
}),
subscribe: () => () => {},
},
}
/** 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,
}
}
function addressSessionId(address: SessionAddress): SessionId {
return address.kind === 'session' ? address.sessionId : address.childSessionId
}
function addressKey(address: SessionAddress): string {
return address.kind === 'session'
? `session:${address.sessionId}`
: `subagent:${address.parentSessionId}:${address.childSessionId}:${address.mode}`
}
export interface Deferred<T> {
promise: Promise<T>
resolve(value: T): void
reject(error: unknown): void
}
/** Test-held settlement: the case decides when an RPC lands (history-pending injections etc.). */
export function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void
let reject!: (error: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
let nextRpc = 0
export function ok<T>(value: T): RpcResponse<T> {
return { rpcId: RpcId(`fake-${nextRpc++}`), result: { ok: true, value } }
}
export function err<T>(error: RpcError): RpcResponse<T> {
return { rpcId: RpcId(`fake-${nextRpc++}`), result: { ok: false, error } }
}
/** Successful generated Remote result for programmable domain fakes. */
function remoteOk<T>(value: T): RemoteResult<T> {
return { ok: true, value }
}
type ValueStreamItem<F> =
| { kind: 'frame'; value: F; delivered?: () => void }
| { kind: 'end' }
| { kind: 'fail'; error: unknown }
interface ValueStreamConn<F> {
feed(item: ValueStreamItem<F>): void
}
interface OpenValueStream<F> {
readonly values: AsyncGenerator<F>
dispose(): void
}
/**
* Commands Remote double: the generated face delivers the carrier's outcome, so
* a test that programs nothing sees an empty catalog and an unmatched line.
* @returns the Remote namespaces the session cluster calls.
*/
export type RuntimeRemotes = SessionRemotes & { readonly workspace: WorkspaceRemote }
export function fakeRemote(api = new FakeApiClient()): RuntimeRemotes {
return api.sessionRemotes()
}
export class FakeApiClient implements IApiClient {
/** Chronological call record: [method, payload]. */
readonly calls: { method: string; payload: unknown }[] = []
/** Session ids in physical follow-generation opening order. */
readonly followStarts: SessionId[] = []
// Programmable slots (defaults answer OK-empty); reassign per case.
onList: (payload: unknown) => Promise<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
onSearch: (payload: unknown) => Promise<RpcResponse<{ items: SessionSearchItem[]; hasMore: boolean }>> =
() => Promise.resolve(ok({ items: [], hasMore: false }))
onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
onModels: (payload: unknown) => Promise<RpcResponse<SessionModels>> = () => Promise.resolve(ok({
current: { provider: 'fixture', model: 'fixture' },
routable: true,
groups: [],
failures: [],
}))
onSelectModel: (payload: SessionSelectModelRequest) => Promise<RpcResponse<SessionSelectModelValue>> =
payload => Promise.resolve(ok({
selected: {
provider: payload.provider,
model: payload.model,
...(payload.reasoningEffort === undefined
? {}
: { reasoningEffort: payload.reasoningEffort }),
},
}))
onRename: (payload: unknown) => Promise<RpcResponse<{ title: string; seq: number }>> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 }))
onFork: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-fork' as SessionId }))
onHistory: (payload: { sessionId: SessionId; throughSeq?: number; beforeSeq?: number; maxMessages?: number })
=> Promise<RpcResponse<SessionPage>> =
() => Promise.resolve(ok({ events: [], hasMore: false }))
onPrompt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onAttachment: (payload: unknown) => Promise<RpcResponse<{ attachment: { attachmentId: never; mediaType: 'image/png'; bytes: number; width: number; height: number }; data: string }>> =
() => Promise.resolve(ok({ attachment: { attachmentId: 'a' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 }, data: 'AA==' }))
onUpdateQueue: (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
home: string
canOpenPath: boolean
}>> =
() => Promise.resolve(ok({
version: '0-fake', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true,
}))
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 }))
onListDirectory: (payload: unknown) => Promise<RpcResponse<{
path: string
home: string
crumbs: { name: string; path: string; hidden: boolean }[]
entries: { name: string; path: string; hidden: boolean }[]
truncated: boolean
}>> =
() => Promise.resolve(ok({ path: '/home/fake', home: '/home/fake', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [], truncated: false }))
onCreateDirectory: (payload: unknown) => Promise<RpcResponse<{ path: string }>> =
() => Promise.resolve(ok({ path: '/home/fake/new' }))
private readonly followConns = new Map<SessionId, ValueStreamConn<SessionFollowFrame>[]>()
private readonly controlConns: ValueStreamConn<SessionControlFrame>[] = []
private readonly workspaceConns: ValueStreamConn<WorkspaceFollowFrame>[] = []
private readonly openingPages = new Map<string, Promise<RpcResponse<SessionPage>>>()
/** Optional Host opening cursor override for stale-page and reconnect tests. */
followCursor: number | undefined
controlBaseline: SessionControlBaseline = {
queues: {},
jobs: {},
projections: {},
}
workspaceBaseline: Extract<WorkspaceFollowFrame, { type: 'baseline' }>['value'] = {
items: [],
archivedSessionIds: [],
}
lastSearchSignal: AbortSignal | undefined
onSubagentList: (payload: unknown) => Promise<RpcResponse<{ entries: never[]; parentAvailable: boolean }>>
= () => Promise.resolve(ok({ entries: [], parentAvailable: true }))
onSubagentPrompt: (payload: unknown) => Promise<RpcResponse<{ messageId: never }>>
= () => Promise.resolve(ok({ messageId: 'fake-message' as never }))
onSubagentInterrupt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>>
= () => Promise.resolve(ok({ accepted: true as const }))
readonly subagents: IApiClient['subagents'] = {
list: (payload: unknown) => this.record('subagent.list', payload, this.onSubagentList(payload)),
prompt: (payload: unknown) => this.record('subagent.prompt', payload, this.onSubagentPrompt(payload)),
interrupt: (payload: unknown) => this.record('subagent.interrupt', payload, this.onSubagentInterrupt(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)),
listDirectory: (payload: unknown) => this.record('host.listDirectory', payload, this.onListDirectory(payload)),
createDirectory: (payload: unknown) => this.record('host.createDirectory', payload, this.onCreateDirectory(payload)),
openPath: (payload: unknown) => this.record('host.openPath', payload, this.onOpenPath(payload)),
}
onWorkspaceCreate: (payload: unknown) => Promise<RemoteResult<{ workspace: WorkspaceView; created: boolean }>> =
() => Promise.resolve(remoteOk({ workspace: fakeWorkspace('fk-ws'), created: true }))
onWorkspaceRename: (payload: unknown) => Promise<RemoteResult<{ workspace: WorkspaceView }>> =
() => Promise.resolve(remoteOk({ workspace: fakeWorkspace('fk-ws') }))
onWorkspaceDelete: (payload: unknown) => Promise<RemoteResult<{ deleted: true }>> =
() => Promise.resolve(remoteOk({ deleted: true }))
onWorkspaceInsertBefore: (payload: unknown) => Promise<RemoteResult<{ workspaceIds: WorkspaceId[] }>> =
() => Promise.resolve(remoteOk({ workspaceIds: [] }))
onWorkspaceInsertSessionBefore: (payload: unknown) => Promise<RemoteResult<{ workspace: WorkspaceView }>> =
() => Promise.resolve(remoteOk({ workspace: fakeWorkspace('fk-ws') }))
onWorkspaceArchiveSession: (payload: unknown) => Promise<RemoteResult<{ archivedSessionIds: SessionId[] }>> =
payload => Promise.resolve(remoteOk({ archivedSessionIds: [(payload as { sessionId: SessionId }).sessionId] }))
// 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.
onSkillList: (payload: unknown) => Promise<RpcResponse<{ skills: SkillEntry[] }>>
= () => Promise.resolve(ok({ skills: [] }))
readonly agentPresets: IApiClient['agentPresets'] = {
list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [], authorable: false, hasDocument: false }))),
select: (payload: { agentPreset: string }) =>
this.record('agentPreset.select', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))),
read: (payload: { agentPreset: string }) =>
this.record('agentPreset.read', payload, Promise.resolve(ok({
agentPreset: payload.agentPreset, trust: 'user' as const, content: '',
}))),
copy: (payload: { agentPreset: string }) =>
this.record('agentPreset.copy', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))),
openDocument: (payload: { agentPreset: string }) =>
this.record('agentPreset.openDocument', payload, Promise.resolve(ok({ opened: true as const }))),
remove: (payload: { agentPreset: string }) =>
this.record('agentPreset.remove', payload, Promise.resolve(ok({}))),
}
readonly skills: IApiClient['skills'] = {
list: (payload: unknown) => this.record('skill.list', payload, this.onSkillList(payload)),
}
readonly goals: IApiClient['goals'] = {
create: payload => this.record('goal.create', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
edit: payload => this.record('goal.edit', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
pause: payload => this.record('goal.pause', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
resume: payload => this.record('goal.resume', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
complete: payload => this.record('goal.complete', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
clear: payload => this.record('goal.clear', payload, Promise.resolve(ok({ cleared: true as const }))),
}
readonly settings: IApiClient['settings'] = {
describe: payload => this.record('settings.describe', payload, Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [] }))),
openDocument: payload => this.record('settings.openDocument', payload, Promise.resolve(ok({ opened: true as const }))),
update: payload => this.record('settings.update', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),
replace: payload => this.record('settings.replace', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),
mutate: payload => this.record('settings.mutate', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))),
}
readonly credentials: IApiClient['credentials'] = {
describe: payload => this.record('credentials.describe', payload, Promise.resolve(ok({ credentials: {} }))),
set: payload => this.record('credentials.set', payload, Promise.resolve(ok({}))),
unset: payload => this.record('credentials.unset', payload, Promise.resolve(ok({}))),
}
readonly llm: IApiClient['llm'] = {
providers: payload => this.record('llm.providers', payload, Promise.resolve(ok({ providers: [] }))),
models: payload => this.record('llm.models', payload, Promise.resolve(ok({ groups: [], failures: [] }))),
discoverModels: payload => this.record('llm.discoverModels', payload, Promise.resolve(ok({ models: [] }))),
}
/** Remote namespaces bound to this fake's programmable unary slots and stream pumps. */
sessionRemotes(): RuntimeRemotes {
return {
$stream: <Item>(options: RemoteStreamOptions<Item>) => (
new RemoteStream(AVAILABLE_STREAM_CONNECTION, options)
),
commands: {
execute: () => Promise.resolve({ ok: true, value: undefined }),
},
session: {
list: payload => this.remoteResult('session.list', payload, this.onList(payload)),
search: (payload, signal) => {
this.lastSearchSignal = signal
return this.remoteResult('session.search', payload, this.onSearch(payload))
},
create: payload => this.remoteResult('session.create', payload, this.onCreate(payload)),
models: payload => this.remoteResult('session.models', payload, this.onModels(payload)),
selectModel: payload => this.remoteResult(
'session.selectModel',
payload,
this.onSelectModel(payload),
),
rename: payload => this.remoteResult('session.rename', payload, this.onRename(payload)),
fork: payload => this.remoteResult('session.fork', payload, this.onFork(payload)),
prompt: payload => this.remoteResult('session.prompt', payload, this.onPrompt(payload)),
attachment: payload => this.remoteResult('session.attachment', payload, this.onAttachment(payload)),
updateQueue: payload => this.remoteResult('session.updateQueue', payload, this.onUpdateQueue(payload)),
cancel: payload => this.remoteResult('session.cancel', payload, this.onCancel(payload)),
page: request => this.page(request),
follow: (request, signal) => this.openFollow(request, signal),
control: signal => this.openControl(signal),
},
workspace: {
create: payload => this.record('workspace.create', payload, this.onWorkspaceCreate(payload)),
rename: payload => this.record('workspace.rename', payload, this.onWorkspaceRename(payload)),
delete: payload => this.record('workspace.delete', payload, this.onWorkspaceDelete(payload)),
insertBefore: payload => this.record(
'workspace.insertBefore',
payload,
this.onWorkspaceInsertBefore(payload),
),
insertSessionBefore: payload => this.record(
'workspace.insertSessionBefore',
payload,
this.onWorkspaceInsertSessionBefore(payload),
),
archiveSession: payload => this.record(
'workspace.archiveSession',
payload,
this.onWorkspaceArchiveSession(payload),
),
follow: signal => this.openWorkspace(signal),
},
}
}
/** Push one live Session event to every follower of that Session. */
async pushFollow(
sessionId: SessionId,
frame: Extract<SessionFollowFrame, { type: 'event' }>,
): Promise<void> {
await Promise.all([...(this.followConns.get(sessionId) ?? [])].map(conn => new Promise<void>((resolve) => {
conn.feed({ kind: 'frame', value: frame, delivered: resolve })
})))
}
/** Push one Host-wide control update. */
pushControl(frame: Exclude<SessionControlFrame, { type: 'baseline' }>): void {
for (const conn of [...this.controlConns]) conn.feed({ kind: 'frame', value: frame })
}
/** Push one Workspace projection increment. */
pushWorkspace(frame: Exclude<WorkspaceFollowFrame, { type: 'baseline' }>): void {
for (const conn of [...this.workspaceConns]) conn.feed({ kind: 'frame', value: frame })
}
/** End (clean close) or fail (throw) every open stream — reconnect-path material. */
endStreams(): void {
for (const conns of this.followConns.values()) {
for (const conn of [...conns]) conn.feed({ kind: 'end' })
}
for (const conn of [...this.controlConns]) conn.feed({ kind: 'end' })
for (const conn of [...this.workspaceConns]) conn.feed({ kind: 'end' })
}
failStreams(error: unknown): void {
for (const conns of this.followConns.values()) {
for (const conn of [...conns]) conn.feed({ kind: 'fail', error })
}
for (const conn of [...this.controlConns]) conn.feed({ kind: 'fail', error })
for (const conn of [...this.workspaceConns]) conn.feed({ kind: 'fail', error })
}
callsOf(method: string): unknown[] {
return this.calls.filter(c => c.method === method).map(c => c.payload)
}
/** Number of currently attached journal generations for one Session. */
activeFollows(sessionId: SessionId): number {
return this.followConns.get(sessionId)?.length ?? 0
}
private record<T>(method: string, payload: unknown, response: Promise<T>): Promise<T> {
this.calls.push({ method, payload })
return response
}
private async remoteResult<T>(
method: string,
payload: unknown,
response: Promise<RpcResponse<T>>,
): Promise<RemoteResult<T>> {
return (await this.record(method, payload, response)).result
}
private page(request: SessionPageRequest): Promise<RemoteResult<SessionPage>> {
const key = addressKey(request.address)
if (request.beforeSeq === undefined && request.maxMessages === 50) {
const opening = this.openingPages.get(key)
if (opening !== undefined) {
this.openingPages.delete(key)
return this.fetchPage(request, opening)
}
}
return this.fetchPage(request)
}
private async fetchPage(
request: SessionPageRequest,
response?: Promise<RpcResponse<SessionPage>>,
): Promise<RemoteResult<SessionPage>> {
const sessionId = addressSessionId(request.address)
const payload = request.address.kind === 'session'
? {
sessionId,
throughSeq: request.throughSeq,
...request.beforeSeq === undefined ? {} : { beforeSeq: request.beforeSeq },
...request.maxMessages === undefined ? {} : { maxMessages: request.maxMessages },
}
: {
parentSessionId: request.address.parentSessionId,
childSessionId: request.address.childSessionId,
mode: request.address.mode,
throughSeq: request.throughSeq,
...request.beforeSeq === undefined ? {} : { beforeSeq: request.beforeSeq },
...request.maxMessages === undefined ? {} : { maxMessages: request.maxMessages },
}
const method = request.address.kind === 'session' ? 'session.history' : 'subagent.history'
const result = await this.remoteResult(method, payload, response ?? this.onHistory({
sessionId,
throughSeq: request.throughSeq,
...request.beforeSeq === undefined ? {} : { beforeSeq: request.beforeSeq },
...request.maxMessages === undefined ? {} : { maxMessages: request.maxMessages },
}))
if (!result.ok) return result
return {
ok: true,
value: {
...result.value,
events: result.value.events.filter(entry => entry.event.seq <= request.throughSeq),
},
}
}
private async *openFollow(
request: SessionFollowRequest,
signal: AbortSignal = new AbortController().signal,
): AsyncGenerator<SessionFollowFrame> {
const sessionId = addressSessionId(request.address)
this.followStarts.push(sessionId)
const key = addressKey(request.address)
const initialPage = this.followCursor === undefined
? this.onHistory({ sessionId, maxMessages: 50 })
: undefined
if (initialPage !== undefined) this.openingPages.set(key, initialPage)
const conns = this.followConns.get(sessionId) ?? []
if (!this.followConns.has(sessionId)) this.followConns.set(sessionId, conns)
const stream = this.openValueStream(conns, signal)
try {
const page = initialPage === undefined ? undefined : (await initialPage).result
const cursor = this.followCursor
?? (page?.ok ? page.value.events.at(-1)?.event.seq ?? -1 : -1)
yield { type: 'opened', cursor }
yield* stream.values
} finally {
stream.dispose()
if (initialPage !== undefined && this.openingPages.get(key) === initialPage) {
this.openingPages.delete(key)
}
}
}
private async *openControl(
signal: AbortSignal = new AbortController().signal,
): AsyncGenerator<SessionControlFrame> {
const stream = this.openValueStream(this.controlConns, signal)
try {
yield { type: 'baseline', value: this.controlBaseline }
yield* stream.values
} finally {
stream.dispose()
}
}
private async *openWorkspace(
signal: AbortSignal = new AbortController().signal,
): AsyncGenerator<WorkspaceFollowFrame> {
const stream = this.openValueStream(this.workspaceConns, signal)
try {
yield { type: 'baseline', value: this.workspaceBaseline }
yield* stream.values
} finally {
stream.dispose()
}
}
private openValueStream<F>(
registry: ValueStreamConn<F>[],
signal: AbortSignal,
): OpenValueStream<F> {
const inbox: ValueStreamItem<F>[] = []
let wake: (() => void) | null = null
let inFlightDelivered: (() => void) | undefined
let disposed = false
const conn: ValueStreamConn<F> = {
feed: (item) => {
inbox.push(item)
wake?.()
},
}
registry.push(conn)
const dispose = (): void => {
if (disposed) return
disposed = true
inFlightDelivered?.()
for (const item of inbox) {
if (item.kind === 'frame') item.delivered?.()
}
const index = registry.indexOf(conn)
if (index >= 0) registry.splice(index, 1)
wake?.()
}
const values = (async function* (): AsyncGenerator<F> {
try {
while (!signal.aborted && !disposed) {
while (inbox.length > 0) {
const item = inbox.shift() as ValueStreamItem<F>
if (item.kind === 'end') return
if (item.kind === 'fail') throw item.error
inFlightDelivered = item.delivered
yield item.value
inFlightDelivered?.()
inFlightDelivered = undefined
}
await new Promise<void>((resolve) => {
wake = resolve
signal.addEventListener('abort', () => { resolve() }, { once: true })
})
wake = null
}
} finally {
dispose()
}
})()
return { values, dispose }
}
}
@@ -0,0 +1,62 @@
/**
* flattenLineage: root ordering, DFS child expansion, orphan degradation, and
* cycle fail-soft (every entry always emitted, no infinite walk).
*/
import { describe, expect, it, vi } from 'vitest'
import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-api-remotes/client'
import { flattenLineage } from '../src/client/sessions/lineage.ts'
const s = (id: string, updatedAt: number, parent?: string): SessionSummary => ({
sessionId: id as SessionId, updatedAt, running: false, blank: false,
...(parent !== undefined ? { parentSessionId: parent as SessionId } : {}),
})
describe('Session lineage flattening', () => {
it('keeps established root and sibling order while expanding children DFS with depth', () => {
const out = flattenLineage([
s('old-root', 10),
s('new-root', 30),
s('kid-old', 11, 'new-root'),
s('kid-new', 12, 'new-root'),
s('grandkid', 5, 'kid-new'),
])
expect(out.map(e => [e.sessionId, e.depth])).toEqual([
['old-root', 0], ['new-root', 0], ['kid-old', 1], ['kid-new', 1], ['grandkid', 2],
])
})
it('degrades an orphan (absent parent) to root level without dropping it', () => {
const out = flattenLineage([s('orphan', 20, 'ghost-parent'), s('root', 10)])
expect(out.map(e => [e.sessionId, e.depth])).toEqual([['orphan', 0], ['root', 0]])
})
it('fails soft on a two-node cycle: all entries emitted, warn fired, no hang', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
try {
const out = flattenLineage([s('a', 20, 'b'), s('b', 10, 'a'), s('root', 30)])
expect(out.map(e => e.sessionId).sort()).toEqual(['a', 'b', 'root'])
expect(warnSpy).toHaveBeenCalled()
} finally {
warnSpy.mockRestore()
}
})
it('handles a self-referencing entry as a cycle member', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
try {
const out = flattenLineage([s('self', 10, 'self')])
expect(out.map(e => e.sessionId)).toEqual(['self'])
expect(out[0]?.depth).toBe(0)
} finally {
warnSpy.mockRestore()
}
})
it('projects the completion-reminder set into rows (absent = false)', () => {
const out = flattenLineage([s('a', 10), s('b', 20)], new Set(['b' as SessionId]))
expect(out.find(e => e.sessionId === 'a')?.completed).toBe(false)
expect(out.find(e => e.sessionId === 'b')?.completed).toBe(true)
expect(flattenLineage([s('a', 10)])[0]?.completed).toBe(false)
})
})
@@ -0,0 +1,969 @@
/**
* SessionManager orchestration: lazy resident instances, list lifecycle, host
* frame routing, and control baselines for uninstantiated sessions.
*/
import { describe, expect, it, vi } from 'vitest'
import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
import type { SessionControlFrame } from '@deepseek-ai/dsh-api-session-controller/types'
import type {} from '@deepseek-ai/dsh-session-title/client'
import { SessionManager } from '../src/client/sessions/manager.ts'
import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.client.ts'
import { entries, plainTurn } from './event-script.client.ts'
const S1 = 'fk-m1' as SessionId
const S2 = 'fk-m2' as SessionId
type SummaryOver = Partial<{
updatedAt: number
running: boolean
blank: boolean
cwd: string
parentSessionId: SessionId
origin: 'subagent'
}>
function summary(sessionId: SessionId, over: SummaryOver = {}) {
return { sessionId, updatedAt: 100, running: false, blank: false, ...over }
}
function makeManager(): SessionManager {
const api = new FakeApiClient()
return new SessionManager(api, fakeRemote(api))
}
describe('SessionManager instances', () => {
it('lazily builds one resident instance per id and syncs the running bit from the list', async () => {
const api = new FakeApiClient()
api.onList = () => Promise.resolve(ok({ items: [summary(S1, { running: true })] as never[] }))
const manager = new SessionManager(api, fakeRemote(api))
await manager.refreshList()
const session = manager.get(S1)
expect(manager.get(S1)).toBe(session) // resident: same instance forever
expect(session.getSnapshot().running).toBe(true) // list preceded instantiation
})
})
describe('list lifecycle', () => {
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
const manager = new SessionManager(api, fakeRemote(api))
const first = manager.refreshList()
const second = manager.refreshList()
expect(manager.getListSnapshot().state).toBe('loading')
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])
})
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, fakeRemote(api))
const hydration = manager.refreshList()
manager.handleSessionAdded(summary(S2, { blank: true }))
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('advances list activity from the filtered Host notification', async () => {
const api = new FakeApiClient()
api.onList = () => Promise.resolve(ok({ items: [summary(S1)] as never[] }))
const manager = new SessionManager(api, fakeRemote(api))
await manager.refreshList()
manager.handleSessionActivity(S1, 500)
expect(manager.getListSnapshot().items[0]?.updatedAt).toBe(500)
})
it('keeps the error in the list snapshot on failure', async () => {
const api = new FakeApiClient()
api.onList = () => Promise.resolve(err({ code: 'internal', message: 'boom', details: {} }))
const manager = new SessionManager(api, fakeRemote(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, fakeRemote(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 () => {
const api = new FakeApiClient()
api.onCreate = () => Promise.resolve(ok({ sessionId: S2 }))
const manager = new SessionManager(api, fakeRemote(api))
const result = await manager.create()
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, fakeRemote(api))
const titleFrame = (title: string, seq: number) => {
manager.handleControlFrame({ type: 'projection', sessionId: S1, key: 'title', value: title, seq })
}
titleFrame('Newest', 4)
titleFrame('Stale', 3)
titleFrame('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.handleSessionRemoved(S1)
manager.handleSessionAdded(summary(S1, { blank: true }))
expect(manager.getListSnapshot().items.find(item => item.sessionId === S1)?.title).toBeUndefined()
})
it('seeds cold titles from the list rows\' projections block under higher-seq-wins', async () => {
const api = new FakeApiClient()
const manager = new SessionManager(api, fakeRemote(api))
// A push frame landed before the list (S2's title is newer than the block's cut).
manager.handleControlFrame({
type: 'projection', sessionId: S2, key: 'title', value: 'Pushed', seq: 9,
})
api.onList = () => Promise.resolve(ok({
items: [
{ ...summary(S1), projections: { asOfSeq: 4, values: { title: 'Cold cached' } } },
{ ...summary(S2, { updatedAt: 200 }), projections: { asOfSeq: 5, values: { title: 'List stale' } } },
] as never[],
}))
await manager.refreshList()
const items = manager.getListSnapshot().items
// Cold row: title surfaces straight from the list block — no open, no history.
expect(items.find(item => item.sessionId === S1)?.title).toBe('Cold cached')
// The stale list block (seq 5) cannot overwrite the newer push frame (seq 9).
expect(items.find(item => item.sessionId === S2)?.title).toBe('Pushed')
})
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, fakeRemote(api))
await manager.refreshList()
const frame = (payload: SessionControlFrame) => { manager.handleControlFrame(payload) }
frame({ type: '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({
type: 'baseline',
value: {
queues: {}, jobs: {},
projections: { [S1]: { asOfSeq: 2, values: {} } },
},
})
expect(manager.getListSnapshot().items[0]?.title).toBeUndefined()
frame({ type: '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({
type: 'baseline',
value: {
queues: {}, jobs: {},
projections: { [S1]: { asOfSeq: 2, values: { title: 'Durable' } } },
},
})
expect(manager.getListSnapshot().items[0]?.title).toBe('Durable')
})
})
describe('search', () => {
it('returns bounded Host results and forwards the caller signal', async () => {
const api = new FakeApiClient()
api.onSearch = () => Promise.resolve(ok({
items: [{ sessionId: S1, snippet: 'matching excerpt' }],
hasMore: true,
}))
const manager = new SessionManager(api, fakeRemote(api))
const signal = new AbortController().signal
await expect(manager.search('exact phrase', signal)).resolves.toEqual({
ok: true,
value: {
items: [{ sessionId: S1, snippet: 'matching excerpt' }],
hasMore: true,
},
})
expect(api.callsOf('session.search')).toEqual([{ query: 'exact phrase' }])
expect(api.lastSearchSignal).toBe(signal)
})
it('preserves business errors and folds transport failures', async () => {
const api = new FakeApiClient()
const manager = new SessionManager(api, fakeRemote(api))
api.onSearch = () => Promise.resolve(err({
code: 'internal',
message: 'index unavailable',
details: {},
}))
const signal = new AbortController().signal
await expect(manager.search('first', signal)).resolves.toMatchObject({
ok: false,
error: { code: 'internal', message: 'index unavailable' },
})
api.onSearch = () => Promise.reject(new Error('wire down'))
await expect(manager.search('second', signal)).resolves.toMatchObject({
ok: false,
error: { code: 'internal', message: 'wire down' },
})
})
})
describe('Host Remote event routing', () => {
it('adds/removes/flips sessions and keeps removed instances resident', async () => {
const api = new FakeApiClient()
const manager = new SessionManager(api, fakeRemote(api))
manager.handleSessionAdded(summary(S1, { blank: true }))
manager.handleSessionAdded(summary(S1, { blank: true })) // dup: ignored
expect(manager.getListSnapshot().items).toHaveLength(1)
const session = manager.get(S1)
manager.handleSessionStatus(S1, true)
expect(session.getSnapshot().running).toBe(true)
expect(manager.getListSnapshot().items[0]?.running).toBe(true)
manager.handleSessionError(S1, '炸了')
expect(session.getSnapshot().lastAgentError).toBe('炸了')
manager.handleSessionRemoved(S1)
expect(manager.getListSnapshot().items).toHaveLength(0)
expect(session.getSnapshot().removed).toBe(true)
expect(manager.get(S1)).toBe(session) // resident-instance rule survives removal
})
})
describe('subagent catalogs', () => {
it('keeps a catalog-discovered child address across ordinary selection and status frames', async () => {
const api = new FakeApiClient()
api.onList = () => Promise.resolve(ok({ items: [
summary(S1),
summary(S2, { parentSessionId: S1, origin: 'subagent' }),
] as never[] }))
api.onSubagentList = () => Promise.resolve(ok({
entries: [{
kind: 'child', id: S2, mode: 'continuable', label: 'worker',
activity: 'running', hasChildren: false,
}] as never[],
parentAvailable: true,
}))
const manager = new SessionManager(api, fakeRemote(api))
await manager.refreshList()
await manager.refreshSubagents(S1)
manager.selectSubagent({ parentSessionId: S1, childSessionId: S2, mode: 'continuable' })
expect(manager.getListSnapshot().currentAddress).toEqual({
parentSessionId: S1, childSessionId: S2, mode: 'continuable',
})
expect(manager.get(S2).getSnapshot().subagent).toEqual({
address: { parentSessionId: S1, childSessionId: S2, mode: 'continuable' },
parentAvailable: true,
})
// Clicking the same child through an ordinary list-selection path must not
// erase the catalog-derived address and fall back to session.* transport.
manager.select(S2)
expect(manager.getListSnapshot().currentAddress).toEqual({
parentSessionId: S1, childSessionId: S2, mode: 'continuable',
})
expect(manager.get(S2).getSnapshot().subagent).toEqual({
address: { parentSessionId: S1, childSessionId: S2, mode: 'continuable' },
parentAvailable: true,
})
await manager.get(S2).open()
await manager.get(S2).prompt([{ type: 'text', text: 'continue' }], 'queue')
expect(api.callsOf('subagent.history')).toEqual([
{ parentSessionId: S1, childSessionId: S2, mode: 'continuable', throughSeq: -1, maxMessages: 50 },
])
expect(api.callsOf('subagent.prompt')).toEqual([
{
parentSessionId: S1, childSessionId: S2, mode: 'continuable',
content: [{ type: 'text', text: 'continue' }],
clientTimeZone: new Intl.DateTimeFormat().resolvedOptions().timeZone,
},
])
expect(api.callsOf('session.history')).toEqual([])
expect(api.callsOf('session.prompt')).toEqual([])
const listCalls = api.callsOf('subagent.list').length
manager.handleSessionStatus(S2, false)
expect(manager.getListSnapshot().subagentsByParent[S1]?.entries[0]).toMatchObject({
kind: 'child', id: S2, activity: 'inactive',
})
expect(api.callsOf('subagent.list')).toHaveLength(listCalls)
manager.handleSessionRemoved(S2)
expect(manager.getListSnapshot().items.find(item => item.sessionId === S2)).toMatchObject({
origin: 'subagent', parentSessionId: S1, running: false,
})
expect(manager.get(S2).getSnapshot()).toMatchObject({
removed: false,
subagent: {
address: { parentSessionId: S1, childSessionId: S2, mode: 'continuable' },
},
})
})
it('refetches debounced membership only while the parent catalog is open', async () => {
vi.useFakeTimers()
try {
const api = new FakeApiClient()
const manager = new SessionManager(api, fakeRemote(api))
await manager.refreshSubagents(S1)
manager.setSubagentCatalogOpen(S1, true)
await Promise.resolve()
const baseline = api.callsOf('subagent.list').length
manager.handleSessionAdded(summary(S2, { parentSessionId: S1 }))
manager.handleSessionAdded(summary('fk-m3' as SessionId, { parentSessionId: S1 }))
await vi.advanceTimersByTimeAsync(50)
expect(api.callsOf('subagent.list')).toHaveLength(baseline + 1)
manager.setSubagentCatalogOpen(S1, false)
manager.handleSessionAdded(summary('fk-m4' as SessionId, { parentSessionId: S1 }))
await vi.advanceTimersByTimeAsync(50)
expect(api.callsOf('subagent.list')).toHaveLength(baseline + 1)
} finally {
vi.useRealTimers()
}
})
it('marks a loaded parent row expandable only for a direct subagent publication', async () => {
const api = new FakeApiClient()
const root = 'fk-root' as SessionId
api.onSubagentList = () => Promise.resolve(ok({
entries: [
{
kind: 'child', id: S1, mode: 'continuable', label: 'parent',
activity: 'inactive', hasChildren: false,
},
{
kind: 'child', id: S2, mode: 'continuable', label: 'ordinary parent',
activity: 'inactive', hasChildren: false,
},
] as never[],
parentAvailable: true,
}))
const manager = new SessionManager(api, fakeRemote(api))
await manager.refreshSubagents(root)
manager.handleSessionAdded(summary('fk-grandchild' as SessionId, {
parentSessionId: S1, origin: 'subagent',
}))
manager.handleSessionAdded(summary('fk-fork' as SessionId, { parentSessionId: S2 }))
expect(manager.getListSnapshot().subagentsByParent[root]?.entries).toMatchObject([
{ kind: 'child', id: S1, hasChildren: true },
{ kind: 'child', id: S2, hasChildren: false },
])
})
it('preserves a live expandability hint across only the older in-flight catalog response', async () => {
const api = new FakeApiClient()
const root = 'fk-root' as SessionId
const response = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
api.onSubagentList = () => response.promise
const manager = new SessionManager(api, fakeRemote(api))
const refresh = manager.refreshSubagents(root)
manager.handleSessionAdded(summary('fk-grandchild' as SessionId, {
parentSessionId: S1, origin: 'subagent',
}))
response.resolve(ok({
entries: [{
kind: 'child', id: S1, mode: 'continuable', label: 'parent',
activity: 'inactive', hasChildren: false,
}] as never[],
parentAvailable: true,
}))
await refresh
expect(manager.getListSnapshot().subagentsByParent[root]?.entries).toMatchObject([
{ kind: 'child', id: S1, hasChildren: true },
])
api.onSubagentList = () => Promise.resolve(ok({
entries: [{
kind: 'child', id: S1, mode: 'continuable', label: 'parent',
activity: 'inactive', hasChildren: false,
}] as never[],
parentAvailable: true,
}))
await manager.refreshSubagents(root)
expect(manager.getListSnapshot().subagentsByParent[root]?.entries).toMatchObject([
{ kind: 'child', id: S1, hasChildren: false },
])
})
it('replays status frames over an older in-flight catalog response', async () => {
const api = new FakeApiClient()
const root = 'fk-root' as SessionId
const response = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
api.onSubagentList = () => response.promise
const manager = new SessionManager(api, fakeRemote(api))
const refresh = manager.refreshSubagents(root)
manager.handleSessionStatus(S1, false)
manager.handleSessionStatus(S2, true)
response.resolve(ok({
entries: [
{
kind: 'child', id: S1, mode: 'continuable', label: 'stopped',
activity: 'running', hasChildren: false,
},
{
kind: 'child', id: S2, mode: 'continuable', label: 'started',
activity: 'inactive', hasChildren: false,
},
] as never[],
parentAvailable: true,
}))
await refresh
expect(manager.getListSnapshot().subagentsByParent[root]?.entries).toMatchObject([
{ kind: 'child', id: S1, activity: 'inactive' },
{ kind: 'child', id: S2, activity: 'running' },
])
})
it('marks a detached catalog child inactive without requiring a selected address', async () => {
const api = new FakeApiClient()
api.onSubagentList = () => Promise.resolve(ok({
entries: [{
kind: 'child', id: S2, mode: 'continuable', label: 'worker',
activity: 'running', hasChildren: false,
}] as never[],
parentAvailable: true,
}))
const manager = new SessionManager(api, fakeRemote(api))
await manager.refreshSubagents(S1)
manager.handleSessionRemoved(S2)
expect(manager.getListSnapshot().subagentsByParent[S1]?.entries).toMatchObject([
{ kind: 'child', id: S2, activity: 'inactive' },
])
})
it('coalesces overlapping catalog reads without scheduling a trailing pull', async () => {
const api = new FakeApiClient()
const root = 'fk-root' as SessionId
const first = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
api.onSubagentList = () => first.promise
const manager = new SessionManager(api, fakeRemote(api))
const refresh = manager.refreshSubagents(root)
expect(manager.refreshSubagents(root)).toBe(refresh)
api.onSubagentList = () => Promise.resolve(ok({ entries: [], parentAvailable: true }))
first.resolve(ok({ entries: [], parentAvailable: true }))
await refresh
expect(api.callsOf('subagent.list')).toHaveLength(1)
})
it('runs one trailing catalog refresh for a membership change coalesced into an in-flight pull', async () => {
vi.useFakeTimers()
try {
const api = new FakeApiClient()
const root = 'fk-root' as SessionId
const first = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
const second = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
api.onSubagentList = () => first.promise
const manager = new SessionManager(api, fakeRemote(api), root)
const refresh = manager.refreshSubagents(root)
// A membership frame arrives while the pull is in flight; the debounced
// refresh it schedules fires 50ms later and is coalesced into the pull —
// which was requested before the new child existed. The stale mark must
// queue one trailing pull carrying the change.
manager.handleSessionAdded(summary(S2, { parentSessionId: root }))
await vi.advanceTimersByTimeAsync(50)
api.onSubagentList = () => second.promise
first.resolve(ok({
entries: [{
kind: 'child', id: S1, mode: 'continuable', label: 'older',
activity: 'inactive', hasChildren: false,
}] as never[],
parentAvailable: true,
}))
await refresh
// The trailing pull is already in flight (kicked synchronously in finally).
second.resolve(ok({
entries: [
{
kind: 'child', id: S1, mode: 'continuable', label: 'older',
activity: 'inactive', hasChildren: false,
},
{
kind: 'child', id: S2, mode: 'continuable', label: 'new child',
activity: 'inactive', hasChildren: false,
},
] as never[],
parentAvailable: true,
}))
await second.promise
expect(api.callsOf('subagent.list')).toHaveLength(2)
expect(manager.getListSnapshot().subagentsByParent[root]?.entries).toMatchObject([
{ kind: 'child', id: S1, label: 'older' },
{ kind: 'child', id: S2, label: 'new child' },
])
} finally {
vi.useRealTimers()
}
})
it('keeps removal invalidation across a stale success and failed trailing pull', async () => {
const api = new FakeApiClient()
const root = 'fk-root' as SessionId
const child = () => ({
kind: 'child' as const, id: S2, mode: 'continuable' as const, label: 'worker',
activity: 'inactive' as const, hasChildren: false,
})
const first = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
api.onSubagentList = () => first.promise
const manager = new SessionManager(api, fakeRemote(api))
const refresh = manager.refreshSubagents(root)
first.resolve(ok({ entries: [child()] as never[], parentAvailable: true }))
await refresh
manager.selectSubagent({ parentSessionId: root, childSessionId: S2, mode: 'continuable' })
// The removal lands while a second pull is in flight: the invalidation
// must survive the pre-removal ok response, so one trailing pull runs.
const mid = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
api.onSubagentList = () => mid.promise
const midRefresh = manager.refreshSubagents(root)
manager.handleSessionRemoved(root)
const trailing = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
api.onSubagentList = () => trailing.promise
mid.resolve(ok({ entries: [child()] as never[], parentAvailable: true }))
await midRefresh
expect(manager.getListSnapshot().subagentsByParent[root]?.parentAvailable).toBe(false)
expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: false })
trailing.resolve(err({ code: 'internal', message: 'trailing pull failed', details: {} }))
await vi.waitFor(() => {
expect(manager.getListSnapshot().subagentsByParent[root]).toMatchObject({
state: 'error',
parentAvailable: false,
})
})
const rootCalls = api.callsOf('subagent.list')
.filter(call => (call as { parentSessionId: SessionId }).parentSessionId === root)
expect(rootCalls).toHaveLength(3)
expect(manager.getListSnapshot().subagentsByParent[root]?.parentAvailable).toBe(false)
expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: false })
})
it('invalidates catalog availability when the owning parent is removed', async () => {
const api = new FakeApiClient()
const root = 'fk-root' as SessionId
api.onSubagentList = () => Promise.resolve(ok({
entries: [{
kind: 'child', id: S2, mode: 'continuable', label: 'worker',
activity: 'inactive', hasChildren: false,
}] as never[],
parentAvailable: true,
}))
const manager = new SessionManager(api, fakeRemote(api))
await manager.refreshSubagents(root)
manager.selectSubagent({ parentSessionId: root, childSessionId: S2, mode: 'continuable' })
expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: true })
manager.handleSessionRemoved(root)
expect(manager.getListSnapshot().subagentsByParent[root]?.parentAvailable).toBe(false)
expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: false })
})
})
describe('remaining branches', () => {
it('refreshList folds a transport throw into the error state', async () => {
const api = new FakeApiClient()
api.onList = () => Promise.reject(new Error('list wire down'))
const manager = new SessionManager(api, fakeRemote(api))
await manager.refreshList()
expect(manager.getListSnapshot()).toMatchObject({ state: 'error', error: { code: 'internal', message: 'list wire down' } })
})
it('refreshList pushes running bits down to already-instantiated sessions', async () => {
const api = new FakeApiClient()
const manager = new SessionManager(api, fakeRemote(api))
const session = manager.get(S1)
api.onList = () => Promise.resolve(ok({ items: [summary(S1, { running: true })] as never[] }))
await manager.refreshList()
expect(session.getSnapshot().running).toBe(true)
})
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, fakeRemote(api))
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({ 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' } })
// Business error passes through untouched.
api.onCreate = () => Promise.resolve(err({ code: 'internal', message: 'no', details: {} }))
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, fakeRemote(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 fork child published before workspace attachment fails', async () => {
const api = new FakeApiClient()
api.onFork = () => Promise.resolve(err({
code: 'workspace-attach-failed',
message: 'forked but unattached',
details: { sessionId: S2, workspaceId: 'w1' },
} as never))
const manager = new SessionManager(api, fakeRemote(api))
const result = await manager.fork({ sessionId: S1 })
expect(result).toMatchObject({ ok: false, error: { code: 'workspace-attach-failed' } })
expect(manager.getListSnapshot().items).toEqual([expect.objectContaining({
sessionId: S2,
parentSessionId: S1,
blank: false,
})])
})
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, fakeRemote(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.handleSessionAdded(summary(S1, { blank: true, cwd: '/w/one' }))
expect(manager.getListSnapshot().items).toEqual([
expect.objectContaining({ sessionId: S1, cwd: '/w/one' }),
])
manager.handleSessionAdded(summary(S1, { blank: true, 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, fakeRemote(api))
let notified = 0
const unsubscribe = manager.subscribe(() => { notified++ })
await manager.refreshList()
await new Promise(resolve => setTimeout(resolve, 0))
expect(notified).toBeGreaterThan(0)
const seen = notified
unsubscribe()
manager.handleSessionAdded(summary(S1, { blank: true }))
await new Promise(resolve => setTimeout(resolve, 0))
expect(notified).toBe(seen)
})
it('ignores Host status and error events for sessions without an instance', () => {
const api = new FakeApiClient()
const manager = new SessionManager(api, fakeRemote(api))
manager.handleSessionStatus(S2, true)
manager.handleSessionError(S2, '无实例')
})
it('keeps list-entry identity for unchanged rows across an unrelated list change', async () => {
const api = new FakeApiClient()
api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] }))
const manager = new SessionManager(api, fakeRemote(api))
await manager.refreshList()
const before = manager.getListSnapshot()
manager.handleSessionStatus(S2, true)
const after = manager.getListSnapshot()
expect(after.items).not.toBe(before.items)
const beforeS1 = before.items.find(e => e.sessionId === S1)
const afterS1 = after.items.find(e => e.sessionId === S1)
expect(afterS1).toBe(beforeS1) // untouched entry keeps identity (entryCache)
// Same-order same-entries snapshot reuses the items array.
manager.handleSessionError(S1, 'x')
expect(manager.getListSnapshot().items).toBe(after.items)
})
it('carries parentSessionId from the added event into the lineage row', () => {
const api = new FakeApiClient()
const manager = new SessionManager(api, fakeRemote(api))
manager.handleSessionAdded(summary(S1, { blank: true }))
manager.handleSessionAdded(summary(S2, {
blank: true, parentSessionId: S1, origin: 'subagent',
}))
const items = manager.getListSnapshot().items
expect(items.find(e => e.sessionId === S2)).toMatchObject({
parentSessionId: S1, origin: 'subagent', depth: 1,
})
})
})
describe('connected generation', () => {
it('refreshes query baselines without rebuilding independently resumed Session sources', async () => {
const api = new FakeApiClient()
api.onHistory = () => Promise.resolve(ok({
events: entries(plainTurn(0, 0, 'a', 'b')) as never[],
hasMore: false,
modelSelection: { provider: 'deepseek-official', model: 'deepseek-chat' },
}))
const manager = new SessionManager(api, fakeRemote(api))
const openedSession = manager.get(S1)
await openedSession.open()
manager.get(S2) // instantiated but never opened
const historyCallsBefore = api.callsOf('session.history').length
manager.handleConnected()
await vi.waitFor(() => {
expect(api.callsOf('session.list').length).toBe(1)
})
expect(api.callsOf('session.history')).toHaveLength(historyCallsBefore)
})
it('reloads the durable parent address for a restored child selection', async () => {
const api = new FakeApiClient()
const address = {
parentSessionId: S1, childSessionId: S2, mode: 'continuable' as const,
}
const manager = new SessionManager(api, fakeRemote(api), S2, address)
manager.handleConnected()
await vi.waitFor(() => {
expect(api.callsOf('subagent.list')).toContainEqual({ parentSessionId: S1 })
})
expect(manager.getListSnapshot().currentAddress).toEqual(address)
})
})
describe('completed reminder', () => {
const status = (manager: SessionManager, sessionId: SessionId, running: boolean): void => {
manager.handleSessionStatus(sessionId, running)
}
const added = (manager: SessionManager, sessionId: SessionId): void => {
manager.handleSessionAdded(summary(sessionId))
}
const entry = (manager: SessionManager, sessionId: SessionId) =>
manager.getListSnapshot().items.find(item => item.sessionId === sessionId)
it('arms on a running→idle flip of a non-selected session and clears on select', () => {
const manager = makeManager()
added(manager, S1)
added(manager, S2)
manager.select(S1)
expect(entry(manager, S2)?.completed).toBe(false)
status(manager, S2, true)
status(manager, S2, false)
expect(entry(manager, S2)?.completed).toBe(true)
// Opening the session consumes the reminder.
manager.select(S2)
expect(entry(manager, S2)?.completed).toBe(false)
})
it('never arms for the session being watched and re-arms after a switch-away re-run', () => {
const manager = makeManager()
added(manager, S1)
added(manager, S2)
manager.select(S2)
status(manager, S2, true)
status(manager, S2, false)
expect(entry(manager, S2)?.completed).toBe(false) // watched to completion: no reminder
// Switch away; a fresh run completing again arms the reminder.
manager.select(S1)
status(manager, S2, true)
status(manager, S2, false)
expect(entry(manager, S2)?.completed).toBe(true)
})
it('a re-run disarms the reminder while running and re-arms on its completion', () => {
const manager = makeManager()
added(manager, S1)
added(manager, S2)
manager.select(S1)
status(manager, S2, true)
status(manager, S2, false)
expect(entry(manager, S2)?.completed).toBe(true)
// The user starts a new run without opening the session: running wins.
status(manager, S2, true)
expect(entry(manager, S2)?.completed).toBe(false)
status(manager, S2, false)
expect(entry(manager, S2)?.completed).toBe(true)
})
it('session-removed drops the reminder and a re-add starts clean', () => {
const manager = makeManager()
added(manager, S1)
added(manager, S2)
manager.select(S1)
status(manager, S2, true)
status(manager, S2, false)
expect(entry(manager, S2)?.completed).toBe(true)
manager.handleSessionRemoved(S2)
expect(manager.getListSnapshot().items.find(item => item.sessionId === S2)).toBeUndefined()
added(manager, S2)
expect(entry(manager, S2)?.completed).toBe(false)
})
it('a list refresh carrying the running→idle transition arms the reminder', async () => {
const api = new FakeApiClient()
api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200, running: true })] as never[] }))
const manager = new SessionManager(api, fakeRemote(api))
await manager.refreshList()
manager.select(S1)
expect(entry(manager, S2)?.completed).toBe(false)
api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200, running: false })] as never[] }))
await manager.refreshList()
expect(entry(manager, S2)?.completed).toBe(true)
})
it('never arms for sessions already idle at first observation', async () => {
const api = new FakeApiClient()
api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] }))
const manager = new SessionManager(api, fakeRemote(api))
await manager.refreshList()
manager.select(S1)
expect(entry(manager, S2)?.completed).toBe(false)
api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 201 })] as never[] }))
await manager.refreshList()
expect(entry(manager, S2)?.completed).toBe(false)
})
it('arms a completion that happened during an in-flight first pull (baseline running, replayed idle)', async () => {
const api = new FakeApiClient()
const gate = deferred<Awaited<ReturnType<FakeApiClient['onList']>>>()
api.onList = () => gate.promise
const manager = new SessionManager(api, fakeRemote(api))
const refresh = manager.refreshList()
// The session finishes while the first pull is still in flight; the pull
// response recorded it as running at pull time.
status(manager, S2, false)
gate.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200, running: true })] as never[] }))
await refresh
expect(entry(manager, S2)?.completed).toBe(true)
})
it('arms when a session ran and completed entirely between in-flight mutations (baseline idle)', async () => {
const api = new FakeApiClient()
const gate = deferred<Awaited<ReturnType<FakeApiClient['onList']>>>()
api.onList = () => gate.promise
const manager = new SessionManager(api, fakeRemote(api))
const refresh = manager.refreshList()
// The unknown session starts and finishes while the first pull is in
// flight; the pull-time baseline recorded it idle, so the running→idle
// edge lives entirely inside the replayed mutations.
status(manager, S2, true)
status(manager, S2, false)
gate.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] }))
await refresh
expect(entry(manager, S2)?.completed).toBe(true)
})
})
describe('background-job mirror', () => {
const view = (over: Partial<{ id: string; status: string; label: string }> = {}) => ({
id: 'bash-1', kind: 'bash', label: 'pnpm run build', status: 'running', startedAt: 5, ...over,
})
const tasksFrame = (
sessionId: SessionId,
jobs: unknown[],
): Extract<SessionControlFrame, { type: 'jobs' }> => ({
type: 'jobs', sessionId, jobs: jobs as never,
})
it('mirrors the whole set last-wins, keyed per session, with no Session instance needed', () => {
const manager = makeManager()
manager.handleControlFrame(tasksFrame(S1, [view()]))
manager.handleControlFrame(tasksFrame(S2, [view({ id: 'pwsh-1', label: 'other' })]))
const first = manager.getListSnapshot().jobsBySession
expect(first[S1]).toEqual([view()])
expect(first[S2]?.[0]?.label).toBe('other')
// Last-wins: the newer whole set replaces, it does not merge.
manager.handleControlFrame(tasksFrame(S1, [view({ status: 'completed' })]))
expect(manager.getListSnapshot().jobsBySession[S1]).toEqual([view({ status: 'completed' })])
})
it('stores an emptied set as an absent key so absence and [] read alike', () => {
const manager = makeManager()
manager.handleControlFrame(tasksFrame(S1, [view()]))
expect(S1 in manager.getListSnapshot().jobsBySession).toBe(true)
manager.handleControlFrame(tasksFrame(S1, []))
expect(S1 in manager.getListSnapshot().jobsBySession).toBe(false)
})
it('clears the mirror when the next control baseline has no jobs', () => {
const manager = makeManager()
manager.handleControlFrame(tasksFrame(S1, [view()]))
manager.handleControlFrame({
type: 'baseline',
value: { queues: {}, jobs: {}, projections: {} },
})
expect(S1 in manager.getListSnapshot().jobsBySession).toBe(false)
})
it('drops the rows when the session is removed, whichever stream lands first', () => {
const manager = makeManager()
manager.handleSessionAdded(summary(S1, { blank: true }))
manager.handleControlFrame(tasksFrame(S1, [view()]))
manager.handleSessionRemoved(S1)
expect(S1 in manager.getListSnapshot().jobsBySession).toBe(false)
})
it('notifies list subscribers so an open header re-renders without a poll', async () => {
const manager = makeManager()
const seen = vi.fn()
manager.subscribe(seen)
manager.handleControlFrame(tasksFrame(S1, [view()]))
// The notifier batches on a microtask; the frame itself is already applied.
await Promise.resolve()
expect(seen).toHaveBeenCalled()
})
})
@@ -0,0 +1,130 @@
/**
* Notifier: microtask/frame batching, rebuild-before-notify ordering,
* no-listener laziness, synchronous notifyNow, and unsubscribe.
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Notifier } from '../src/client/sessions/notifier.ts'
const microtask = (): Promise<void> => new Promise((resolve) => { queueMicrotask(resolve) })
afterEach(() => {
vi.unstubAllGlobals()
})
describe('Session notifier', () => {
it('collapses N markDirty calls into one flush, rebuilding before notifying', async () => {
const order: string[] = []
const notifier = new Notifier(() => order.push('rebuild'))
notifier.subscribe(() => order.push('notify'))
notifier.markDirty()
notifier.markDirty()
notifier.markDirty()
expect(order).toEqual([]) // nothing until the microtask boundary
await microtask()
expect(order).toEqual(['rebuild', 'notify'])
})
it('skips rebuild with zero listeners and ensureFresh rebuilds lazily exactly once', async () => {
let rebuilds = 0
const notifier = new Notifier(() => { rebuilds++ })
notifier.markDirty()
await microtask()
expect(rebuilds).toBe(0) // lazy: kept dirty
notifier.ensureFresh()
expect(rebuilds).toBe(1)
notifier.ensureFresh()
expect(rebuilds).toBe(1) // clean: no second rebuild
})
it('notifyNow runs listeners synchronously (controlled-input contract)', () => {
const order: string[] = []
const notifier = new Notifier(() => order.push('rebuild'))
notifier.subscribe(() => order.push('notify'))
notifier.notifyNow()
expect(order).toEqual(['rebuild', 'notify']) // before returning, no microtask needed
})
it('notifyNow with zero listeners stays lazy like markDirty', () => {
let rebuilds = 0
const notifier = new Notifier(() => { rebuilds++ })
notifier.notifyNow()
expect(rebuilds).toBe(0)
notifier.ensureFresh()
expect(rebuilds).toBe(1)
})
it('a scheduled flush after notifyNow already flushed is a no-op', async () => {
let rebuilds = 0
const notifier = new Notifier(() => { rebuilds++ })
notifier.subscribe(() => undefined)
notifier.markDirty() // schedules the microtask flush
notifier.notifyNow() // flushes synchronously, clears dirty
await microtask() // the scheduled flush finds dirty=false
expect(rebuilds).toBe(1)
})
it('collapses frame-dirty changes into one cumulative frame publication', () => {
const frames: FrameRequestCallback[] = []
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
frames.push(callback)
return frames.length
})
const order: string[] = []
const notifier = new Notifier(() => order.push('rebuild'))
notifier.subscribe(() => order.push('notify'))
notifier.markFrameDirty()
notifier.markFrameDirty()
notifier.markFrameDirty()
expect(order).toEqual([])
expect(frames).toHaveLength(1)
frames.shift()!(0)
expect(order).toEqual(['rebuild', 'notify'])
})
it('lets a structural microtask publication supersede a pending frame', async () => {
const frames: FrameRequestCallback[] = []
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
frames.push(callback)
return frames.length
})
let notifications = 0
const notifier = new Notifier(() => undefined)
notifier.subscribe(() => { notifications++ })
notifier.markFrameDirty()
notifier.markDirty()
await microtask()
expect(notifications).toBe(1)
frames.shift()!(0)
expect(notifications).toBe(1)
})
it('falls back to microtask batching when animation frames are unavailable', async () => {
let notifications = 0
const notifier = new Notifier(() => undefined)
notifier.subscribe(() => { notifications++ })
notifier.markFrameDirty()
notifier.markFrameDirty()
expect(notifications).toBe(0)
await microtask()
expect(notifications).toBe(1)
})
it('unsubscribed listeners stop receiving notifications', async () => {
let calls = 0
const notifier = new Notifier(() => undefined)
const unsubscribe = notifier.subscribe(() => { calls++ })
notifier.notifyNow()
expect(calls).toBe(1)
unsubscribe()
notifier.markDirty()
await microtask()
notifier.notifyNow()
expect(calls).toBe(1)
})
})
@@ -0,0 +1,222 @@
/**
* Projection value store (push model; session-projection subsystem page:
* docs/subsystems/session-projection.md): 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, control-stream projection routing pre- and post-instantiation, the
* list rows' title projection).
*/
import { describe, expect, it } from 'vitest'
import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
import { ProjectionValueStore } from '../src/client/sessions/projection-store.ts'
import { Session } from '../src/client/sessions/session.ts'
import { SessionManager } from '../src/client/sessions/manager.ts'
import { FakeApiClient, fakeRemote, ok } from './fake-api.client.ts'
import { entries, plainTurn } from './event-script.client.ts'
// Test-domain keys merged into the projection map (the Service Definition 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('Session projection value 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'))
})
it('publishes one reference-stable whole-value snapshot until a row changes', () => {
const store = new ProjectionValueStore()
const empty = store.values()
expect(store.values()).toBe(empty)
store.apply('test/marks', { marks: ['a'] }, 1)
const populated = store.values()
expect(populated).toEqual({ 'test/marks': { marks: ['a'] } })
expect(populated).not.toBe(empty)
expect(store.values()).toBe(populated)
})
})
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, fakeRemote(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, fakeRemote(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, fakeRemote(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 projection frames before instantiation and the Session adopts the same store', async () => {
const api = new FakeApiClient()
const manager = new SessionManager(api, fakeRemote(api))
manager.handleControlFrame({
type: 'projection', sessionId: sid('s1'), key: 'test/marks', value: { marks: ['early'] }, seq: 7,
})
const session = manager.get(sid('s1'))
expect(session.projections.get('test/marks')).toEqual({ marks: ['early'] })
// Frames after instantiation land in the same store.
manager.handleControlFrame({
type: 'projection', sessionId: sid('s1'), key: 'test/marks', value: { marks: ['later'] }, seq: 9,
})
expect(session.projections.get('test/marks')).toEqual({ marks: ['later'] })
})
it('projects the title key into list rows and truncates phantom rows on the control baseline', async () => {
const api = new FakeApiClient()
const manager = new SessionManager(api, fakeRemote(api))
api.onList = () => Promise.resolve(ok({
items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }],
}) as never)
await manager.refreshList()
manager.handleControlFrame({
type: 'projection', sessionId: sid('s1'), key: 'title', value: 'Projected title', seq: 4,
})
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.handleControlFrame({
type: 'baseline',
value: {
queues: {}, jobs: {},
projections: { [sid('s1')]: { asOfSeq: 2, values: {} } },
},
})
await Promise.resolve()
expect(manager.getListSnapshot().items[0]?.title).toBeUndefined()
})
it('projects every retained value into list rows with stable snapshot identity', async () => {
const api = new FakeApiClient()
const manager = new SessionManager(api, fakeRemote(api))
api.onList = () => Promise.resolve(ok({
items: [{
sessionId: sid('s1'), updatedAt: 1, running: false, blank: false,
projections: {
asOfSeq: 2,
values: { 'test/marks': { marks: ['baseline'] } },
},
}],
}) as never)
await manager.refreshList()
const baseline = manager.getListSnapshot().items[0]?.projectionValues
expect(baseline).toEqual({ 'test/marks': { marks: ['baseline'] } })
expect(manager.getListSnapshot().items[0]?.projectionValues).toBe(baseline)
manager.handleControlFrame({
type: 'projection', sessionId: sid('s1'), key: 'test/marks',
value: { marks: ['live'] }, seq: 3,
})
await Promise.resolve()
expect(manager.getListSnapshot().items[0]?.projectionValues)
.toEqual({ 'test/marks': { marks: ['live'] } })
expect(manager.getListSnapshot().items[0]?.projectionValues).not.toBe(baseline)
})
it('drops the projection store with the removed session', async () => {
const api = new FakeApiClient()
const manager = new SessionManager(api, fakeRemote(api))
api.onList = () => Promise.resolve(ok({
items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }],
}) as never)
await manager.refreshList()
manager.handleControlFrame({
type: 'projection', sessionId: sid('s1'), key: 'title', value: 'Doomed', seq: 4,
})
manager.handleSessionRemoved(sid('s1'))
expect(manager.get(sid('s1')).projections.get('title')).toBeUndefined()
})
})
@@ -0,0 +1,287 @@
/**
* Queue snapshot semantics: authoritative replacement after every host-side
* change, reconnect re-baselining, pre-instantiation buffering, editable-text
* projection, and snapshot reference stability.
*/
import { describe, expect, it, vi } from 'vitest'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, UserMessage } from '@deepseek-ai/dsh-llm/types'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type { MessageId, RpcId, SessionId } from '@deepseek-ai/dsh-api-remotes/client'
import type { SessionControlFrame } from '@deepseek-ai/dsh-api-session-controller/types'
import { Session } from '../src/client/sessions/session.ts'
import { SessionManager } from '../src/client/sessions/manager.ts'
import { FakeApiClient, fakeRemote } from './fake-api.client.ts'
const SID = 'fk-q1' as SessionId
const text = (value: string): ContentBlock[] => [{ type: 'text', text: value }]
const rid = (id: string): RpcId => id as RpcId
const iid = (id: string): MessageId => id as MessageId
interface QueueFixture {
id: string
body: string
content?: ContentBlock[]
placement?: 'queued' | 'steering'
message?: UserMessage
}
/** Build one authoritative queue snapshot. */
function queueFrame(items: QueueFixture[]): Extract<SessionControlFrame, { type: 'queue' }> {
return {
type: 'queue',
sessionId: SID,
items: items.map(item => ({
id: iid(item.id),
placement: item.placement ?? 'queued',
message: (item.message ?? createUserMessage({
content: item.content ?? text(item.body),
source: { kind: 'user', rpcId: rid(`rpc-${item.id}`) } as never,
})) as never,
})),
}
}
function makeSession(): Session {
return makeBench().session
}
function makeBench(): { api: FakeApiClient; session: Session } {
const api = new FakeApiClient()
return { api, session: new Session(SID, api, fakeRemote(api)) }
}
function makeManager(): SessionManager {
const api = new FakeApiClient()
return new SessionManager(api, fakeRemote(api))
}
describe('Session queue snapshot intake', () => {
it('projects stable ids, flat previews, and complete text', () => {
const session = makeSession()
session.handleControlFrame(queueFrame([
{ id: 'q-1', body: '第一条 排队\n消息' },
]))
const queue = session.getSnapshot().queue
expect(typeof queue[0]?.messageId).toBe('string')
expect(queue).toMatchObject([
{
id: 'q-1', placement: 'queued',
content: [{ type: 'text', text: '第一条 排队\n消息' }],
preview: '第一条 排队 消息', text: '第一条 排队\n消息',
},
])
})
it('marks mixed-content messages non-editable while retaining their preview', () => {
const session = makeSession()
session.handleControlFrame(queueFrame([{
id: 'q-image',
body: '',
content: [{ type: 'text', text: 'hi' }, { type: 'image', data: 'x' } as never],
}]))
const queue = session.getSnapshot().queue
expect(typeof queue[0]?.messageId).toBe('string')
expect(queue).toMatchObject([
{
id: 'q-image', placement: 'queued',
content: [{ type: 'text', text: 'hi' }, { type: 'image', data: 'x' }],
preview: 'hi [image]', text: null,
},
])
})
it('caps previews at 200 code points and preserves the full editable text', () => {
const session = makeSession()
const body = '长'.repeat(201)
session.handleControlFrame(queueFrame([{ id: 'q-cap', body }]))
const row = session.getSnapshot().queue[0]
expect(Array.from(row?.preview ?? '')).toHaveLength(201)
expect(row?.preview.endsWith('…')).toBe(true)
expect(row?.text).toBe(body)
})
it('replaces content, order, and membership from each authoritative frame', () => {
const session = makeSession()
session.handleControlFrame(queueFrame([
{ id: 'q-1', body: 'one' },
{ id: 'q-2', body: 'two' },
]))
session.handleControlFrame(queueFrame([
{ id: 'q-2', body: 'two edited' },
]))
const queue = session.getSnapshot().queue
expect(typeof queue[0]?.messageId).toBe('string')
expect(queue).toMatchObject([
{
id: 'q-2', placement: 'queued',
content: [{ type: 'text', text: 'two edited' }],
preview: 'two edited', text: 'two edited',
},
])
session.handleControlFrame(queueFrame([]))
expect(session.getSnapshot().queue).toEqual([])
})
it('keeps the queue array reference stable across unrelated snapshot swaps', () => {
const session = makeSession()
session.handleControlFrame(queueFrame([{ id: 'q-stable', body: '稳定' }]))
const before = session.getSnapshot().queue
session.handleAgentError('unrelated')
expect(session.getSnapshot().queue).toBe(before)
})
it('retains steering placement and complete content in the same authoritative snapshot', () => {
const session = makeSession()
session.handleControlFrame(queueFrame([
{ id: 'q-next', body: 'later' },
{ id: 's-now', body: 'interrupt now', placement: 'steering' },
]))
expect(session.getSnapshot().queue.map(item => ({
id: item.id, placement: item.placement, content: item.content,
}))).toEqual([
{ id: 'q-next', placement: 'queued', content: text('later') },
{ id: 's-now', placement: 'steering', content: text('interrupt now') },
])
})
it('hands off exactly one current occurrence when live steering becomes durable', async () => {
const { api, session } = makeBench()
await session.open()
const message = createUserMessage({
content: text('same message'),
source: { kind: 'user' },
})
session.handleControlFrame(queueFrame([
{ id: 's-first', body: '', placement: 'steering', message },
{ id: 's-second', body: '', placement: 'steering', message },
]))
const durable = {
seq: 0,
time: 1_700_000_000_000,
type: 'user/message',
surfaceOp: 'append',
data: message,
} as SessionEvent
await api.pushFollow(SID, { type: 'event', event: durable as never })
await vi.waitFor(() => {
expect(session.getSnapshot().queue.map(item => item.id)).toEqual(['s-second'])
})
session.handleControlFrame(queueFrame([
{ id: 's-later', body: '', placement: 'steering', message },
]))
await api.pushFollow(SID, { type: 'event', event: durable as never })
await vi.waitFor(() => {
expect(session.getSnapshot().queue.map(item => item.id)).toEqual(['s-later'])
})
})
it('hands off live steering when the agent claims it as a user message', async () => {
const { api, session } = makeBench()
await session.open()
const message = createUserMessage({
content: text('claimed steering'),
source: { kind: 'user' },
})
session.handleControlFrame(queueFrame([
{ id: 's-claimed', body: '', placement: 'steering', message },
]))
await api.pushFollow(SID, {
type: 'event',
event: {
seq: 0,
time: 1_700_000_000_000,
type: 'user/message',
surfaceOp: 'append',
data: message,
} as never,
})
await vi.waitFor(() => {
expect(session.getSnapshot().queue).toEqual([])
})
})
})
describe('queue operation transport', () => {
it('addresses the session.updateQueue RPC without optimistic local mutation', async () => {
const api = new FakeApiClient()
const session = new Session(SID, api, fakeRemote(api))
session.handleControlFrame(queueFrame([{ id: 'q-op', body: 'pending' }]))
const before = session.getSnapshot().queue
await expect(session.updateQueue(iid('q-op'), { kind: 'edit', content: text('next') }))
.resolves.toEqual({ ok: true, value: { accepted: true } })
await expect(session.updateQueue(iid('q-op'), { kind: 'steer' }))
.resolves.toEqual({ ok: true, value: { accepted: true } })
expect(api.callsOf('session.updateQueue')).toEqual([
{
sessionId: SID,
itemId: 'q-op',
action: { kind: 'edit', content: text('next') },
},
{
sessionId: SID,
itemId: 'q-op',
action: { kind: 'steer' },
},
])
expect(session.getSnapshot().queue).toBe(before)
})
})
describe('queue reconnect semantics', () => {
it('a control baseline clears stale state before a fresh update lands', () => {
const session = makeSession()
session.handleControlFrame(queueFrame([{ id: 'q-old', body: '旧连接' }]))
session.replaceControl([])
expect(session.getSnapshot().queue).toEqual([])
session.handleControlFrame(queueFrame([{ id: 'q-new', body: '新基线' }]))
expect(session.getSnapshot().queue.map(row => row.id)).toEqual(['q-new'])
})
it('resync does not clear a baseline that raced ahead of the host connection signal', async () => {
const session = makeSession()
await session.open()
session.handleControlFrame(queueFrame([{ id: 'q-fresh', body: '新基线' }]))
await session.resync()
expect(session.getSnapshot().queue.map(row => row.id)).toEqual(['q-fresh'])
})
it('running-status changes never guess at queue retirement', () => {
const session = makeSession()
session.handleControlFrame(queueFrame([{ id: 'q-live', body: '保留' }]))
session.handleRunning(true)
session.handleRunning(false)
expect(session.getSnapshot().queue.map(row => row.id)).toEqual(['q-live'])
})
})
describe('manager buffering of queue snapshots', () => {
it('replays only the latest snapshot for an uninstantiated session', () => {
const manager = makeManager()
manager.handleControlFrame(queueFrame([{ id: 'q-old', body: '旧' }]))
manager.handleControlFrame(queueFrame([{ id: 'q-new', body: '新' }]))
expect(manager.get(SID).getSnapshot().queue.map(row => row.id)).toEqual(['q-new'])
})
it('a control baseline replaces the prior queue', () => {
const manager = makeManager()
manager.handleControlFrame(queueFrame([{ id: 'q-g1', body: '第一代' }]))
const nextQueue = queueFrame([{ id: 'q-g2', body: '第二代' }]).items
manager.handleControlFrame({
type: 'baseline',
value: {
queues: { [SID]: nextQueue },
jobs: {},
projections: {},
},
})
const snapshot = manager.get(SID).getSnapshot()
expect(snapshot.queue.map(row => row.id)).toEqual(['q-g2'])
})
})
@@ -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 '@deepseek-ai/cordis'
import { describe, expect, it } from 'vitest'
import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
import { createScope, scopeOf } from '../src/client/scope.ts'
const sid = (k: string): SessionId => k as SessionId
declare module '@deepseek-ai/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([])
})
})
@@ -0,0 +1,801 @@
/**
* Cold-session and degenerate-composition paths of the Session Controller:
* metadata-only listing, Agent-free history reads, subagent ownership
* isolation, and prompt failure mapping.
*/
import { mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import SessionStore from '@deepseek-ai/dsh-session'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import { SessionHistoryController } from '@deepseek-ai/dsh-api-session-controller/src/history.ts'
import { TypertLookupFailure } from '@deepseek-ai/dsh-typert-protocol'
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
import { createUserMessage, MessageId } from '@deepseek-ai/dsh-llm'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionPromptRequest, SessionRequestId } from '../src/types.ts'
import {
PersistenceCoordinator,
SessionPersistenceRevision,
type PersistenceBackend,
type StoredPrefix,
} from '@deepseek-ai/dsh-session-persistence'
import { createSessionTestRemote } from './test-remote.ts'
const sid = (id: string): SessionId => id as SessionId
function request<P>(payload: P): P {
return payload
}
let nextRequestId = 1
function promptRequest(
payload: Omit<SessionPromptRequest, 'requestId'>,
): SessionPromptRequest {
return {
...payload,
requestId: `cold-${String(nextRequestId++)}` as SessionRequestId,
}
}
function header(id: string, createdAt: number, extra: Partial<SessionHeader> = {}): SessionHeader {
return { version: 0, id: sid(id), createdAt, cwd: '/proj', ...extra }
}
describe('sessions.list cold merge', () => {
it('verifies only small possibly-blank artifacts and treats every unavailable probe as visible', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const root = mkdtempSync(join(tmpdir(), 'dsh-cold-'))
const smallPath = join(root, 'small.log')
const largePath = join(root, 'large.log')
writeFileSync(smallPath, 'x'.repeat(1024))
writeFileSync(largePath, 'x'.repeat(1025))
const metas = [
header('small-blank', 100),
header('small-conversation', 200),
header('large-unknown', 300),
header('cached-nonblank', 400),
header('locationless', 500, { parentSession: sid('session-parent'), origin: 'subagent' }),
header('vanished', 600),
header('read-failure', 700),
]
const readFrom = vi.fn(async (id: SessionId) => {
if (id === sid('small-blank')) {
return {
meta: metas[0]!,
events: [{ type: 'session/end-seed', seq: 0, time: 700, data: {} }] as SessionEvent[],
}
}
if (id === sid('small-conversation')) {
return {
meta: metas[1]!,
events: [
{ type: 'turn/start', seq: 0, time: 800, data: { turn: 1 } },
{
type: 'user/message', seq: 1, time: 1200,
data: createUserMessage({ content: [{ type: 'text', text: 'worked' }], source: { kind: 'user' } }),
surfaceOp: 'append',
},
] as SessionEvent[],
}
}
if (id === sid('read-failure')) throw new Error('simulated read failure')
throw new Error(`unexpected cold read: ${id}`)
})
ctx.provide('sessionPersistence', {
list: () => Promise.resolve(metas),
locate: (meta: SessionHeader) => {
if (meta.id === sid('large-unknown')) return { kind: 'jsonl', path: largePath }
if (meta.id === sid('locationless')) return undefined
if (meta.id === sid('vanished')) return { kind: 'jsonl', path: join(root, 'vanished.log') }
return { kind: 'jsonl', path: smallPath }
},
readFrom,
} as never)
ctx.provide('sessionProjectionCache', {
cachedSnapshot: (meta: SessionHeader) => {
if (meta.id === sid('small-blank')) {
return { asOfSeq: 0, values: { sessionListMetadata: { blank: true, lastPromptAt: null } } }
}
if (meta.id === sid('small-conversation')) {
return { asOfSeq: 0, values: { sessionListMetadata: { blank: true, lastPromptAt: 900 } } }
}
if (meta.id === sid('cached-nonblank')) {
return { asOfSeq: 1, values: { sessionListMetadata: { blank: false, lastPromptAt: 1000 } } }
}
return undefined
},
} as never)
const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const response = await remote.list(request({}))
expect(response.ok).toBe(true)
if (!response.ok) throw new Error('unreachable')
const byId = Object.fromEntries(response.value.items.map(item => [item.sessionId, item]))
expect(byId['small-blank']).toMatchObject({ blank: true, updatedAt: 100, running: false })
// A stale true hint cannot hide the turn found in the bounded read.
expect(byId['small-conversation']).toMatchObject({ blank: false, updatedAt: 1200 })
expect(byId['large-unknown']).toMatchObject({ blank: false, updatedAt: 300 })
// false is monotonic, so this row skips stat/read and keeps cached recency.
expect(byId['cached-nonblank']).toMatchObject({ blank: false, updatedAt: 1000 })
expect(byId['locationless']).toMatchObject({
blank: false,
updatedAt: 500,
parentSessionId: 'session-parent',
origin: 'subagent',
})
expect(byId['vanished']).toMatchObject({ blank: false, updatedAt: 600 })
expect(byId['read-failure']).toMatchObject({ blank: false, updatedAt: 700 })
expect(readFrom).toHaveBeenCalledTimes(3)
expect(readFrom.mock.calls.map(([id]) => id)).toEqual(expect.arrayContaining([
sid('small-blank'),
sid('small-conversation'),
sid('read-failure'),
]))
})
it('can disable bounded blank probes without hiding cold Sessions', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const meta = header('probe-disabled', 100)
const readFrom = vi.fn()
ctx.provide('sessionPersistence', {
list: () => Promise.resolve([meta]),
locate: () => ({ kind: 'jsonl', path: '/not-read' }),
readFrom,
} as never)
const remote = createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
cwd: '/tmp',
coldBlankProbeMaxBytes: 0,
})
const response = await remote.list(request({}))
if (!response.ok) throw new Error('unreachable')
expect(response.value.items).toEqual([
expect.objectContaining({ sessionId: meta.id, blank: false, updatedAt: meta.createdAt }),
])
expect(readFrom).not.toHaveBeenCalled()
})
it('replaces a probed cold row with the live Session that attached during the read', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
const meta = header('attached-during-probe', 100)
const root = mkdtempSync(join(tmpdir(), 'dsh-cold-race-'))
const path = join(root, 'small.log')
writeFileSync(path, 'x')
const started = Promise.withResolvers<undefined>()
const release = Promise.withResolvers<undefined>()
ctx.provide('sessionPersistence', {
list: () => Promise.resolve([meta]),
locate: () => ({ kind: 'jsonl', path }),
readFrom: async () => {
started.resolve(undefined)
await release.promise
return {
meta,
events: [{ type: 'session/end-seed', seq: 0, time: 110, data: {} }] as SessionEvent[],
}
},
} as never)
const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const listing = remote.list(request({}))
await started.promise
const session = ctx.sessions.create(meta.id, {
seed: [
{ type: 'turn/start', seq: 0, time: 200, data: { turn: 1 } },
{
type: 'user/message', seq: 1, time: 300,
data: createUserMessage({ content: [{ type: 'text', text: 'live' }], source: { kind: 'user' } }),
surfaceOp: 'append',
},
],
meta: {
...meta.cwd === undefined ? {} : { cwd: meta.cwd },
createdAt: meta.createdAt,
},
})
ctx.agents.register({ id: session.id, session, status: 'running', ctx } as Agent)
release.resolve(undefined)
const response = await listing
if (!response.ok) throw new Error('list failed')
expect(response.value.items).toEqual([
expect.objectContaining({
sessionId: meta.id,
blank: false,
running: true,
updatedAt: 300,
}),
])
})
})
describe('attached updatedAt tracks human prompts', () => {
it('ignores pickup and non-prompt work after the latest human message', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
// Old work, resumed just now: the log tail would report the pickup.
const worked = 1_000_000
const resumed = ctx.sessions.create(sid('resumed-untouched'), {
seed: [
{ type: 'turn/start', seq: 0, time: worked, data: { turn: 1 } },
{
type: 'user/message', seq: 1, time: worked,
data: createUserMessage({ content: [{ type: 'text', text: 'worked' }], source: { kind: 'user' } }),
surfaceOp: 'append',
},
{ type: 'turn/end', seq: 2, time: worked + 1, data: { turn: 1, reason: { kind: 'completed' } } },
],
meta: { cwd: '/proj', createdAt: 500 },
})
ctx.agents.register({ id: resumed.id, session: resumed, status: 'idle', ctx } as Agent)
const boundary = resumed.events.at(-1)
expect(boundary?.type).toBe('session/end-seed')
expect(boundary?.time).toBeGreaterThan(worked)
const listed = await remote.list(request({}))
if (!listed.ok) throw new Error('list failed')
const summary = listed.value.items.find(item => item.sessionId === 'resumed-untouched')
expect(summary?.updatedAt).toBe(worked)
// A lifecycle boundary is not a human update.
resumed.append('turn/start', { turn: 2 })
const afterBoundary = await remote.list(request({}))
if (!afterBoundary.ok) throw new Error('list failed')
expect(afterBoundary.value.items.find(item => item.sessionId === 'resumed-untouched')?.updatedAt)
.toBe(worked)
const prompt = resumed.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'new prompt' }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
const after = await remote.list(request({}))
if (!after.ok) throw new Error('list failed')
const moved = after.value.items.find(item => item.sessionId === 'resumed-untouched')
expect(moved?.updatedAt).toBe(prompt.time)
})
})
describe('cold history recovery view', () => {
it('shows in-memory interruption repair without activating the session', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const sessionId = sid('session-interrupted')
const meta = header(sessionId, 1000)
const stored: StoredPrefix<never> = {
meta,
events: [{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }],
revision: SessionPersistenceRevision('history-recovery-test:1'),
}
const backend: PersistenceBackend<never> = {
name: 'history-recovery-test',
loadStored: id => Promise.resolve(id === sessionId ? structuredClone(stored) : undefined),
readStoredRevision: id => Promise.resolve(
id === sessionId ? SessionPersistenceRevision('history-recovery-test:1') : undefined,
),
appendBatch: () => Promise.resolve(),
commitRepair: () => Promise.resolve(),
list: () => Promise.resolve([structuredClone(meta)]),
}
const coordinator = new PersistenceCoordinator(ctx, backend)
ctx.provide('sessionPersistence', {
list: (signal?: AbortSignal) => backend.list(signal),
inspect: (id: SessionId, signal?: AbortSignal) => coordinator.inspect(id, signal),
locate: () => undefined,
} as never)
const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const history = await remote.page({
address: { kind: 'session', sessionId },
throughSeq: 1,
beforeSeq: 2,
maxMessages: 10,
})
if (!history.ok) throw new Error('history failed')
expect(history.value.events.map(entry => entry.event)).toMatchInlineSnapshot(`
[
{
"data": {
"turn": 1,
},
"seq": 0,
"time": 1,
"type": "turn/start",
},
{
"data": {
"reason": {
"kind": "interrupted",
},
"turn": 1,
},
"seq": 1,
"time": 1,
"type": "turn/end",
},
]
`)
expect(ctx.sessions.get(sessionId)).toBeUndefined()
await ctx.fiber.dispose()
})
})
describe('Remote Agent and Session lookup policy', () => {
it('deduplicates a cold resume across Agent and Session parameters', async () => {
const ctx = new Context()
await ctx.plugin(TypertRegistry)
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
const sessionId = sid('session-remote-cold')
const meta = header(sessionId, 1000)
const inspect = vi.fn(() => Promise.resolve({ meta, events: [] as SessionEvent[] }))
ctx.provide('sessionPersistence', {
list: () => Promise.resolve([meta]),
inspect,
locate: () => undefined,
} as never)
const resumedSession = { id: sessionId, header: meta, events: [] } as unknown as import('@deepseek-ai/dsh-session').Session
const resumedAgent = { id: sessionId, session: resumedSession, status: 'idle', ctx } as Agent
const release = Promise.withResolvers<undefined>()
const resume = vi.spyOn(ctx.agents, 'resume').mockImplementation(async () => {
await release.promise
return { agent: resumedAgent, dispose: () => Promise.resolve() }
})
const defaultAgentLookup = ctx.typert.lookups.get('agent')
const defaultSessionLookup = ctx.typert.lookups.get('session')
createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
await vi.waitFor(() => {
expect(ctx.typert.lookups.get('agent')).not.toBe(defaultAgentLookup)
expect(ctx.typert.lookups.get('session')).not.toBe(defaultSessionLookup)
})
const agentLookup = ctx.typert.lookups.get('agent')
const sessionLookup = ctx.typert.lookups.get('session')
if (agentLookup === undefined || sessionLookup === undefined) throw new Error('core lookup providers were not mounted')
const resolvedAgent = Promise.resolve(agentLookup.resolve(sessionId))
const resolvedSession = Promise.resolve(sessionLookup.resolve(sessionId))
await vi.waitFor(() => { expect(resume).toHaveBeenCalledOnce() })
release.resolve(undefined)
await expect(resolvedAgent).resolves.toBe(resumedAgent)
await expect(resolvedSession).resolves.toBe(resumedSession)
expect(inspect).toHaveBeenCalledOnce()
})
it('preserves the subagent ownership fence for cold and live Remote lookups', async () => {
const ctx = new Context()
await ctx.plugin(TypertRegistry)
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
const coldId = sid('session-remote-cold-child')
const coldMeta = header(coldId, 1000, {
parentSession: sid('session-parent'),
origin: 'subagent',
})
const inspect = vi.fn(() => Promise.resolve({ meta: coldMeta, events: [] as SessionEvent[] }))
ctx.provide('sessionPersistence', {
list: () => Promise.resolve([coldMeta]),
inspect,
locate: () => undefined,
} as never)
const liveSession = ctx.sessions.create(sid('session-remote-live-child'), {
meta: { cwd: '/proj', parentSession: sid('session-parent'), origin: 'subagent' },
})
const liveAgent = { id: liveSession.id, session: liveSession, status: 'idle', ctx } as Agent
ctx.agents.register(liveAgent)
const resume = vi.spyOn(ctx.agents, 'resume')
const defaultAgentLookup = ctx.typert.lookups.get('agent')
const defaultSessionLookup = ctx.typert.lookups.get('session')
createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
await vi.waitFor(() => {
expect(ctx.typert.lookups.get('agent')).not.toBe(defaultAgentLookup)
expect(ctx.typert.lookups.get('session')).not.toBe(defaultSessionLookup)
})
const agentLookup = ctx.typert.lookups.get('agent')
const sessionLookup = ctx.typert.lookups.get('session')
if (agentLookup === undefined || sessionLookup === undefined) throw new Error('core lookup providers were not mounted')
const ownershipFailure = {
failure: {
code: 'agent-busy',
details: { reason: 'use subagent delivery for this child session' },
},
}
const coldFailure = Promise.resolve(agentLookup.resolve(coldId))
const liveFailure = Promise.resolve(sessionLookup.resolve(liveSession.id))
await expect(coldFailure).rejects.toBeInstanceOf(TypertLookupFailure)
await expect(coldFailure).rejects.toMatchObject(ownershipFailure)
await expect(liveFailure).rejects.toBeInstanceOf(TypertLookupFailure)
await expect(liveFailure).rejects.toMatchObject(ownershipFailure)
expect(resume).not.toHaveBeenCalled()
expect(inspect).toHaveBeenCalledOnce()
})
})
describe('subagent ownership fence', () => {
it('reads a cold child without an Agent and rejects generic resume or adoption', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
const sessionId = sid('session-child')
const meta = header('session-child', 1000, {
parentSession: sid('session-parent'),
seedLength: 0,
origin: 'subagent',
})
const events = [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{
type: 'user/message',
seq: 1,
time: 2,
data: { content: [{ type: 'text', text: 'work' }], source: { kind: 'user' } },
surfaceOp: 'append',
},
{
type: 'subagent/descriptor',
seq: 2,
time: 3,
data: { version: 2, mode: 'continuable', provider: 'spawn', label: 'child' },
},
{ type: 'turn/end', seq: 3, time: 4, data: { turn: 1, reason: { kind: 'completed' } } },
] as SessionEvent[]
const inspect = vi.fn(() => Promise.resolve({ meta, events }))
ctx.provide('sessionPersistence', {
list: () => Promise.resolve([meta]),
inspect,
locate: () => undefined,
} as never)
const resume = vi.spyOn(ctx.agents, 'resume')
const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const history = await new SessionHistoryController(ctx).page({
address: {
kind: 'subagent',
parentSessionId: meta.parentSession as SessionId,
childSessionId: sessionId,
mode: 'continuable',
},
throughSeq: 3,
}, new AbortController().signal)
expect(history.events.map(entry => entry.event.type)).toEqual(events.map(event => event.type))
expect(ctx.agents.get(sessionId)).toBeUndefined()
const prompt = await remote.prompt(promptRequest({
sessionId,
mode: 'queue',
content: [{ type: 'text', text: 'follow up' }],
}))
expect(prompt.ok).toBe(false)
if (!prompt.ok) {
expect(prompt.error).toMatchObject({
code: 'agent-busy',
details: { reason: 'use subagent delivery for this child session' },
})
}
const create = await remote.create(request({ sessionId, cwd: '/proj' }))
expect(create.ok).toBe(false)
if (!create.ok) expect(create.error.code).toBe('agent-busy')
expect(resume).not.toHaveBeenCalled()
expect(ctx.agents.get(sessionId)).toBeUndefined()
expect(inspect).toHaveBeenCalledTimes(3)
})
it('no longer treats a descriptor-only cold child without origin as subagent-owned', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
const sessionId = sid('session-legacy-child')
const meta = header('session-legacy-child', 1000, {
parentSession: sid('session-parent'),
seedLength: 0,
})
const events = [
{
type: 'subagent/descriptor',
seq: 0,
time: 1,
data: { version: 2, mode: 'continuable', provider: 'spawn', label: 'child' },
},
] as SessionEvent[]
ctx.provide('sessionPersistence', {
list: () => Promise.resolve([meta]),
inspect: () => Promise.resolve({ meta, events }),
locate: () => undefined,
} as never)
// Stores whose headers predate `origin` classify a child only through the
// descriptor event; the pre-release decision stops recognizing them, so
// the ownership fence lets generic resume reach the registry instead of
// answering `agent-busy`.
const resume = vi.spyOn(ctx.agents, 'resume')
.mockRejectedValue(new Error('registry unavailable in this bench'))
const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const prompt = await remote.prompt(promptRequest({
sessionId,
mode: 'queue',
content: [{ type: 'text', text: 'follow up' }],
}))
expect(resume).toHaveBeenCalledTimes(1)
expect(prompt.ok).toBe(false)
if (!prompt.ok) expect(prompt.error.code).toBe('internal')
})
it('rejects origin-marked and runtime-owned live children from generic controls', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
const parentSession = ctx.sessions.create(sid('session-parent'), { meta: { cwd: '/proj' } })
const parent = { id: parentSession.id, session: parentSession, status: 'idle', ctx } as Agent
ctx.agents.register(parent)
const originSession = ctx.sessions.create(sid('session-origin-child'), {
meta: { cwd: '/proj', parentSession: parent.id, origin: 'subagent' },
})
const cancel = vi.fn()
const updateInbox = vi.fn(() => 'applied' as const)
const originChild = {
id: originSession.id,
session: originSession,
status: 'idle',
ctx,
cancel,
updateInbox,
} as unknown as Agent
ctx.agents.register(originChild)
const startingSession = ctx.sessions.create(sid('session-starting-child'), {
meta: { cwd: '/proj', parentSession: parent.id },
})
const startingChild = { id: startingSession.id, session: startingSession, status: 'idle', ctx } as Agent
ctx.agents.enter(startingChild, parent)
const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const stopped = await remote.cancel(request({ sessionId: originChild.id }))
expect(stopped.ok).toBe(false)
if (!stopped.ok) expect(stopped.error.code).toBe('agent-busy')
expect(cancel).not.toHaveBeenCalled()
const queued = await remote.updateQueue(request({
sessionId: originChild.id,
itemId: MessageId('queued-item'),
action: { kind: 'remove' },
}))
expect(queued.ok).toBe(false)
if (!queued.ok) expect(queued.error.code).toBe('agent-busy')
expect(updateInbox).not.toHaveBeenCalled()
const models = await remote.models(request({ sessionId: startingChild.id }))
expect(models.ok).toBe(false)
if (!models.ok) expect(models.error.code).toBe('agent-busy')
const create = await remote.create(request({ sessionId: originChild.id, cwd: '/proj' }))
expect(create.ok).toBe(false)
if (!create.ok) expect(create.error.code).toBe('agent-busy')
expect(ctx.agents.get(originChild.id)).toBe(originChild)
})
it('does not classify an ordinary fork from an inherited ancestor descriptor', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
const session = ctx.sessions.create(sid('session-ordinary-fork'), {
seed: [{
type: 'subagent/descriptor',
seq: 0,
time: 1,
data: { version: 2, mode: 'continuable', provider: 'spawn', label: 'ancestor' },
}],
meta: { cwd: '/proj', parentSession: sid('session-source'), seedLength: 1 },
})
const followup = vi.fn()
const agent = { id: session.id, session, status: 'idle', ctx, followup } as unknown as Agent
ctx.agents.register(agent)
const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const response = await remote.prompt(promptRequest({
sessionId: agent.id,
mode: 'queue',
content: [{ type: 'text', text: 'ordinary work' }],
}))
expect(response.ok).toBe(true)
expect(followup).toHaveBeenCalledOnce()
})
it('canonicalizes a supplied browser zone on the exact prompt and rejects invalid names', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
const session = ctx.sessions.create(sid('session-browser-zone'), { meta: { cwd: '/proj' } })
const followup = vi.fn()
const agent = { id: session.id, session, status: 'idle', ctx, followup } as unknown as Agent
ctx.agents.register(agent)
const remote = createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
cwd: '/tmp',
})
const alias = 'US/Pacific'
const canonical = new Intl.DateTimeFormat('en-US', { timeZone: alias })
.resolvedOptions().timeZone
const zonedRequest = promptRequest({
sessionId: agent.id,
mode: 'queue' as const,
content: [{ type: 'text' as const, text: 'zoned work' }],
clientTimeZone: alias,
})
await expect(remote.prompt(zonedRequest)).resolves.toMatchObject({ ok: true })
expect(followup).toHaveBeenNthCalledWith(1, expect.objectContaining({
source: { kind: 'user', rpcId: zonedRequest.requestId, clientTimeZone: canonical },
}))
const utcRequest = promptRequest({
sessionId: agent.id,
mode: 'queue' as const,
content: [{ type: 'text' as const, text: 'UTC work' }],
clientTimeZone: 'UTC',
})
await expect(remote.prompt(utcRequest)).resolves.toMatchObject({ ok: true })
expect(followup).toHaveBeenNthCalledWith(2, expect.objectContaining({
source: { kind: 'user', rpcId: utcRequest.requestId, clientTimeZone: 'UTC' },
}))
const unzonedRequest = promptRequest({
sessionId: agent.id,
mode: 'queue' as const,
content: [{ type: 'text' as const, text: 'headless work' }],
})
await expect(remote.prompt(unzonedRequest)).resolves.toMatchObject({ ok: true })
expect(followup).toHaveBeenNthCalledWith(3, expect.objectContaining({
source: { kind: 'user', rpcId: unzonedRequest.requestId },
}))
for (const clientTimeZone of ['', ' UTC', 'CST', 'Not/A_Real_Zone']) {
const invalid = await remote.prompt(promptRequest({
sessionId: agent.id,
mode: 'queue' as const,
content: [{ type: 'text' as const, text: 'invalid zone' }],
clientTimeZone,
}))
expect(invalid).toEqual({
ok: false,
error: {
code: 'invalid-time-zone',
message: 'clientTimeZone must be UTC or a valid IANA Area/Location name',
details: { value: clientTimeZone },
},
})
}
expect(followup).toHaveBeenCalledTimes(3)
})
})
describe('degenerate composition (no persistence, no factory)', () => {
it('list skips the cold merge and history reports missing persistence as internal', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const listed = await remote.list(request({}))
expect(listed.ok).toBe(true)
if (listed.ok) expect(listed.value.items).toEqual([])
// No persistence means cold history cannot inspect a transcript.
const response = await remote.page({
address: { kind: 'session', sessionId: sid('session-ghost') },
throughSeq: -1,
})
expect(response.ok).toBe(false)
if (!response.ok) {
expect(response.error.code).toBe('internal')
expect(response.error.message).toMatch(/session persistence is not configured/)
}
})
it('maps a persistence catalog miss to session-not-found without inspection', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
const inspect = vi.fn()
ctx.provide('sessionPersistence', {
list: () => Promise.resolve([]),
inspect,
} as never)
const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const response = await remote.page({
address: { kind: 'session', sessionId: sid('session-missing') },
throughSeq: -1,
})
expect(response.ok).toBe(false)
if (!response.ok) expect(response.error.code).toBe('session-not-found')
expect(inspect).not.toHaveBeenCalled()
})
})
describe('sessions.prompt synchronous rejection', () => {
it('maps a synchronous send throw (disposed/invalid input) to agent-busy with the reason attached', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
const session = ctx.sessions.create(sid('session-throwing'))
// A live structural stub whose delivery verbs throw synchronously, the
// shape a disposed loop presents at this gateway boundary.
ctx.agents.register({
id: session.id,
session,
status: 'idle',
ctx,
followup: () => { throw new Error('agent "session-throwing" lifecycle disposed') },
steer: () => { throw new Error('agent "session-throwing" lifecycle disposed') },
} as unknown as Agent)
const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
for (const mode of ['queue', 'steer'] as const) {
const response = await remote.prompt(promptRequest({
sessionId: session.id, mode, content: [{ type: 'text' as const, text: 'x' }],
}))
expect(response.ok).toBe(false)
if (!response.ok) {
expect(response.error.code).toBe('agent-busy')
expect(response.error.message).toBe('prompt rejected')
expect(response.error.details).toEqual({
reason: 'Error: agent "session-throwing" lifecycle disposed',
})
}
}
})
it('classifies a raced cold-resume ID collision as agent-busy', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
const sessionId = sid('race-resume')
const meta: SessionHeader = header('race-resume', 1000)
ctx.provide('sessionPersistence', {
list: () => Promise.resolve([meta]),
inspect: () => Promise.resolve({ meta, events: [] as SessionEvent[] }),
locate: () => undefined,
} as never)
// The raced winner: a live parent-owned subagent publishes the identity
// while the generic cold resume is in flight, so the resume collides.
const parentSession = ctx.sessions.create(sid('race-parent'), { meta: { cwd: '/proj' } })
const parent = { id: parentSession.id, session: parentSession, status: 'idle', ctx } as Agent
ctx.agents.register(parent)
const childSession = ctx.sessions.create(sessionId, {
meta: { cwd: '/proj', parentSession: parent.id, origin: 'subagent' },
})
const child = { id: sessionId, session: childSession, status: 'idle', ctx } as unknown as Agent
vi.spyOn(ctx.agents, 'resume').mockImplementationOnce(async () => {
// The parent's `enter()` wins the identity between the pre-resume
// re-check and publication; the generic resume then collides.
ctx.agents.register(child)
throw new Error('session id already published')
})
const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const models = await remote.models(request({ sessionId }))
expect(models.ok).toBe(false)
if (!models.ok) {
expect(models.error).toMatchObject({
code: 'agent-busy',
details: { reason: 'use subagent delivery for this child session' },
})
}
})
})
@@ -0,0 +1,296 @@
/** Session Controller fork boundaries, lineage, and inherited model routing. */
import { describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent'
import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import type { LlmCallConfig } from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import type { Workspace } from '@deepseek-ai/dsh-workspace'
import { createSessionTestRemote } from './test-remote.ts'
const sid = (id: string): SessionId => id as SessionId
function request<P>(payload: P): P {
return payload
}
async function composed(workspaces: readonly Workspace[] = []): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt, { persona: '' })
await ctx.plugin(AgentRegistry)
ctx.provide('workspaceRegistry', { list: () => workspaces } as never)
ctx.agents.setFactory({
createAgent: async (ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle> => {
const session = ctx.sessions.create(options.sessionId, {
...options.seed === undefined ? {} : { seed: [...options.seed] },
...options.meta === undefined ? {} : { meta: options.meta },
})
const agent = {} as Agent
const agentCtx = ownerCtx.extend({ agent })
Object.assign(agent, { id: session.id, session, status: 'idle', ctx: agentCtx })
await options.setup?.(agentCtx)
ctx.agents.register(agent)
return { agent, dispose: () => Promise.resolve() }
},
resume: () => Promise.reject(new Error('fork test sources are live')),
})
return ctx
}
/** Tail turn appended after the completed ones: left open, or closed as aborted (a stopped turn). */
type Tail = 'none' | 'open' | 'aborted'
function liveAgent(
ctx: Context,
id: string,
turns: number,
tail: Tail = 'none',
lineage: { parentSession?: SessionId; origin?: 'subagent' } = {},
): Session {
const session = ctx.sessions.create(sid(id), { meta: { cwd: '/proj', ...lineage } })
for (let turn = 1; turn <= turns; turn++) {
session.append('turn/start', { turn })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: `prompt ${String(turn)}` }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
session.append('turn/end', { turn, reason: { kind: 'completed' } })
}
if (tail !== 'none') {
session.append('turn/start', { turn: turns + 1 })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'open prompt' }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
if (tail === 'aborted') session.append('turn/end', {
turn: turns + 1,
reason: { kind: 'aborted', reason: { kind: 'user' } },
})
}
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
return session
}
const remote = (ctx: Context) => createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'default-provider', model: 'default-model' }),
cwd: '/tmp',
})
describe('sessions.fork', () => {
it('cuts at the anchored completed turn and records lineage and cwd', async () => {
const ctx = await composed()
const source = liveAgent(ctx, 'session-source', 2)
const response = await remote(ctx).fork(request({ sessionId: source.id, atSeq: 1 }))
expect(response.ok).toBe(true)
if (!response.ok) return
const child = ctx.sessions.get(response.value.sessionId)
expect(child?.events.map(event => event.type)).toEqual([
'turn/start', 'user/message', 'turn/end', 'session/end-seed',
])
expect(child?.header.parentSession).toBe(source.id)
expect(child?.header.cwd).toBe('/proj')
await ctx.fiber.dispose()
})
it('attaches a subagent fork to its nearest workspace-owning ancestor', async () => {
const accounted: SessionId[] = []
const attachSession = vi.fn<(sessionId: SessionId) => Promise<void>>()
.mockResolvedValue(undefined)
const workspace = {
sessionIds: accounted,
attachSession,
} as unknown as Workspace
const ctx = await composed([workspace])
const owner = liveAgent(ctx, 'session-owner', 1)
accounted.push(owner.id)
const child = liveAgent(ctx, 'session-child', 1, 'none', {
parentSession: owner.id,
origin: 'subagent',
})
const grandchild = liveAgent(ctx, 'session-grandchild', 1, 'none', {
parentSession: child.id,
origin: 'subagent',
})
ctx.provide('sessionQuery', {
traceSession: vi.fn(() => Promise.resolve({
target: { header: grandchild.header, live: true, persisted: false },
ancestors: [
{ header: child.header, live: true, persisted: false },
{ header: owner.header, live: true, persisted: false },
],
descendants: [],
complete: true,
root: { header: owner.header, live: true, persisted: false },
})),
} as never)
const response = await remote(ctx).fork(request({ sessionId: grandchild.id }))
expect(response.ok).toBe(true)
if (!response.ok) return
expect(attachSession).toHaveBeenCalledWith(response.value.sessionId)
expect(ctx.sessions.get(response.value.sessionId)?.header).toMatchObject({
parentSession: grandchild.id,
cwd: '/proj',
})
expect(ctx.sessions.get(response.value.sessionId)?.header.origin).toBeUndefined()
await ctx.fiber.dispose()
})
it('forks a persisted subagent without resuming its Agent', async () => {
const ctx = await composed()
const sourceId = sid('session-cold-subagent')
const parentId = sid('session-cold-parent')
const header: SessionHeader = {
version: 0,
id: sourceId,
createdAt: 1,
cwd: '/proj',
parentSession: parentId,
origin: 'subagent',
}
const events = [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{
type: 'user/message',
seq: 1,
time: 2,
data: createUserMessage({ content: [{ type: 'text', text: 'work' }], source: { kind: 'user' } }),
surfaceOp: 'append',
},
{ type: 'turn/end', seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' } } },
] as SessionEvent[]
ctx.provide('sessionPersistence', {
list: () => Promise.resolve([header]),
inspect: () => Promise.resolve({ meta: header, events }),
} as never)
ctx.provide('sessionQuery', {
traceSession: () => Promise.resolve({
target: { header, live: false, persisted: true },
ancestors: [],
descendants: [],
complete: true,
root: { header, live: false, persisted: true },
}),
} as never)
const resume = vi.spyOn(ctx.agents, 'resume')
const response = await remote(ctx).fork(request({ sessionId: sourceId }))
expect(response.ok).toBe(true)
if (!response.ok) return
expect(resume).not.toHaveBeenCalled()
expect(ctx.agents.get(sourceId)).toBeUndefined()
expect(ctx.sessions.get(response.value.sessionId)?.header).toMatchObject({
parentSession: sourceId,
cwd: '/proj',
})
expect(ctx.sessions.get(response.value.sessionId)?.header.origin).toBeUndefined()
await ctx.fiber.dispose()
})
it('uses the last completed turn only for omitted and past-end anchors', async () => {
const ctx = await composed()
const source = liveAgent(ctx, 'session-tail', 2, 'open')
const proxy = remote(ctx)
const expectedTypes = [
'turn/start', 'user/message', 'turn/end',
'turn/start', 'user/message', 'turn/end',
'session/end-seed',
]
const omitted = await proxy.fork(request({ sessionId: source.id }))
expect(omitted.ok).toBe(true)
if (omitted.ok) {
expect(ctx.sessions.get(omitted.value.sessionId)?.events.map(event => event.type))
.toEqual(expectedTypes)
}
const pastEnd = await proxy.fork(request({ sessionId: source.id, atSeq: 999 }))
expect(pastEnd.ok).toBe(true)
if (pastEnd.ok) {
expect(ctx.sessions.get(pastEnd.value.sessionId)?.events.map(event => event.type))
.toEqual(expectedTypes)
}
await ctx.fiber.dispose()
})
it('rejects invalid fork anchors before reading or creating a Session', async () => {
const ctx = await composed()
const proxy = remote(ctx)
for (const atSeq of [-1, 0.5]) {
await expect(proxy.fork(request({ sessionId: sid('missing'), atSeq })))
.resolves.toMatchObject({ ok: false, error: { code: 'bad-request' } })
}
expect(ctx.sessions.list()).toEqual([])
await ctx.fiber.dispose()
})
it('cuts through an aborted turn: stopped is closed, not open', async () => {
const ctx = await composed()
const source = liveAgent(ctx, 'session-aborted', 1, 'aborted')
// What a stopped message's fork button anchors on: the frozen node sits
// one event before its turn/end, floored client-side to that event's seq.
const anchor = (source.events.at(-1)?.seq ?? 0) - 1
const response = await remote(ctx).fork(request({ sessionId: source.id, atSeq: anchor }))
expect(response.ok).toBe(true)
if (!response.ok) return
expect(ctx.sessions.get(response.value.sessionId)?.events.map(event => event.type)).toEqual([
'turn/start', 'user/message', 'turn/end',
'turn/start', 'user/message', 'turn/end',
'session/end-seed',
])
await ctx.fiber.dispose()
})
it('rejects an in-log anchor whose turn is still open', async () => {
const ctx = await composed()
const source = liveAgent(ctx, 'session-open', 1, 'open')
const anchor = source.events.at(-1)?.seq ?? 0
const response = await remote(ctx).fork(request({ sessionId: source.id, atSeq: anchor }))
expect(response).toMatchObject({
ok: false,
error: { code: 'fork-unavailable', details: { sessionId: source.id } },
})
if (!response.ok) expect(response.error.message).toMatch(/has not completed/)
await ctx.fiber.dispose()
})
it('installs the latest logged model selection before the child can run', async () => {
const ctx = await composed()
const source = liveAgent(ctx, 'session-routed', 1)
source.append('request/header', {
header: {
config: {
provider: 'inherited-provider',
model: 'inherited-model',
reasoningEffort: ReasoningEffortId('high'),
},
},
reason: 'initial',
})
const response = await remote(ctx).fork(request({ sessionId: source.id }))
expect(response.ok).toBe(true)
if (!response.ok) return
const child = ctx.agents.get(response.value.sessionId)
if (child === undefined) throw new Error('fork did not publish the child agent')
const assembly = await child.ctx.systemPrompt.assemble()
expect(assembly.variables).toMatchObject({
provider: 'inherited-provider',
model: 'inherited-model',
})
const fallback: LlmCallConfig = { provider: 'default-provider', model: 'default-model' }
await expect(agentEvents(child.ctx, child).waterfall(
'agent/request', { turn: 1, step: 0, signal: new AbortController().signal }, () => Promise.resolve(fallback),
)).resolves.toMatchObject({
provider: 'inherited-provider',
model: 'inherited-model',
reasoningEffort: 'high',
})
await ctx.fiber.dispose()
})
})
@@ -0,0 +1,411 @@
/**
* Tool-card view computation over Session Controller history and follow: three standard card types
* arrive on the frame, a presenterless tool ships no view field, a call-only
* presenter keeps raw result content out of the view payload, and a throwing
* presenter soft-falls to no view (the event still ships). Result pairing
* works for both paged and live entries.
*/
import { describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SessionStore from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import { CallId, createMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
import { SessionHistoryController } from '@deepseek-ai/dsh-api-session-controller/src/history.ts'
import type { SessionFollowFrame } from '@deepseek-ai/dsh-api-session-controller/types'
import { createSessionTestRemote } from './test-remote.ts'
const reply = (text: string): Promise<ContentBlock[]> => Promise.resolve([{ type: 'text', text }])
function tool(name: string, presenters: Pick<ToolDefinition, 'presentCall' | 'presentResult'>): ToolDefinition {
return defineContentToolFixture({
name,
description: `tool ${name}`,
parameters: {},
execute: () => reply(`ran:${name}`),
...presenters,
})
}
/** Append a production-shaped human prompt to the session surface. */
function appendUserText(session: Session, text: string): SessionEvent {
return session.append('user/message', createUserMessage({
content: [{ type: 'text', text }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
}
/** Append a production-shaped assistant message to the session surface. */
function appendAssistantText(session: Session, text: string, step: number): SessionEvent {
return session.append('assistant/message', {
turn: 1,
step,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text }],
source: { kind: 'model', provider: 'p', model: 'm' },
}),
}, { surfaceOp: 'append' })
}
/**
* Append a plugin-owned log-only event. The host proxy is projection-only, so it
* declares no compaction vocabulary; the cast writes the real event shape without
* depending on the owning package.
*/
function appendExtension(session: Session, type: string, data: unknown): SessionEvent {
return (session.append as unknown as (type: string, data: unknown) => SessionEvent)(type, data)
}
async function harness(): Promise<{ ctx: Context }> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt, { persona: '' })
await ctx.plugin(ToolRuntime)
await ctx.plugin(AgentRegistry)
ctx.tools.register(tool('gen', {
presentCall: () => ({ card: 'generic', title: 'gen call' }),
presentResult: (_args, result) => ({ card: 'generic', title: result.isError ? 'gen failed' : 'gen done' }),
}))
ctx.tools.register(tool('term', {
presentCall: args => ({ card: 'terminal', title: (args as { cmd?: string }).cmd ?? '' }),
presentResult: () => ({ card: 'terminal', output: 'done' }),
}))
ctx.tools.register(tool('diffy', {
presentCall: () => ({ card: 'diff', title: 'Write f.txt', diffs: [{ path: 'f.txt', oldText: null, newText: 'x' }] }),
}))
ctx.tools.register(tool('call-only', {
presentCall: () => ({ card: 'generic', title: 'program', kind: 'execute', rawInput: 'return value' }),
}))
ctx.tools.register(tool('plain', {}))
ctx.tools.register(tool('boom', {
presentCall: () => { throw new Error('presenter exploded') },
}))
return { ctx }
}
/** Drain one Session follow until `count` event frames arrive. */
async function collect(
iterable: AsyncIterable<SessionFollowFrame>,
count: number,
abort: AbortController,
): Promise<SessionFollowFrame[]> {
const frames: SessionFollowFrame[] = []
for await (const frame of iterable) {
frames.push(frame)
if (frames.filter(candidate => candidate.type === 'event').length >= count) abort.abort()
}
return frames
}
/** Open follow and wait until its cursor is fixed before appending fixtures. */
async function openFollow(
history: SessionHistoryController,
sessionId: SessionId,
signal: AbortSignal,
): Promise<AsyncIterable<SessionFollowFrame>> {
const iterator = history.follow({
address: { kind: 'session', sessionId },
}, signal)[Symbol.asyncIterator]()
await expect(iterator.next()).resolves.toMatchObject({
done: false,
value: { type: 'opened' },
})
return { [Symbol.asyncIterator]: () => iterator }
}
describe('Session history view computation', () => {
it('attaches the three standard card views, omits view without a presenter, soft-falls on throw', async () => {
const { ctx } = await harness()
const session = ctx.sessions.create()
const history = new SessionHistoryController(ctx)
const abort = new AbortController()
const stream = await openFollow(history, session.id, abort.signal)
const collected = collect(stream, 9, abort)
const rawResult = `RAW_RESULT:${'x'.repeat(64 * 1024)}`
session.append('turn/start', { turn: 1 })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-gen'), name: 'gen', arguments: '{}' })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-term'), name: 'term', arguments: '{"cmd":"echo hi"}' })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-diff'), name: 'diffy', arguments: '{}' })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-call-only'), name: 'call-only', arguments: '{}' })
session.append('tool/result', {
turn: 1, step: 1,
message: createToolResultMessage({
callId: CallId('c-call-only'),
content: [{ type: 'text', text: rawResult }],
isError: false,
}),
}, { surfaceOp: 'append' })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-plain'), name: 'plain', arguments: '{}' })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-boom'), name: 'boom', arguments: '{}' })
session.append('tool/result', {
turn: 1, step: 1,
message: createToolResultMessage({
callId: CallId('c-gen'),
content: [{ type: 'text', text: 'ok' }],
isError: false,
}),
}, { surfaceOp: 'append' })
const frames = await collected
const events = frames.filter(f => f.type === 'event')
const byCall = new Map(events
.filter(f => f.event.type === 'tool/call' || f.event.type === 'tool/result')
.map(f => [
`${f.event.type}:${f.event.type === 'tool/call'
? (f.event.data as unknown as SessionEvent<'tool/call'>['data']).callId
: (f.event.data as unknown as SessionEvent<'tool/result'>['data']).message.source.callId}`,
f,
]))
expect(byCall.get('tool/call:c-gen')?.view).toEqual({ for: 'call', view: { card: 'generic', title: 'gen call' } })
expect(byCall.get('tool/call:c-term')?.view).toEqual({ for: 'call', view: { card: 'terminal', title: 'echo hi' } })
expect(byCall.get('tool/call:c-diff')?.view?.view.card).toBe('diff')
expect(byCall.get('tool/call:c-call-only')?.view).toEqual({
for: 'call',
view: { card: 'generic', title: 'program', kind: 'execute', rawInput: 'return value' },
})
const callOnlyResult = byCall.get('tool/result:c-call-only')
expect('view' in (callOnlyResult ?? {})).toBe(false)
const serializedResult = JSON.stringify(callOnlyResult)
expect(serializedResult.indexOf(rawResult)).toBeGreaterThanOrEqual(0)
expect(serializedResult.indexOf(rawResult)).toBe(serializedResult.lastIndexOf(rawResult))
// No presenter → the frame carries no view property at all.
expect('view' in (byCall.get('tool/call:c-plain') ?? {})).toBe(false)
// Throwing presenter → soft-fall: event ships, no view.
expect(byCall.get('tool/call:c-boom')).toBeDefined()
expect('view' in (byCall.get('tool/call:c-boom') ?? {})).toBe(false)
// Result pairing through the live table: presentResult saw the call's args.
expect(byCall.get('tool/result:c-gen')?.view).toEqual({ for: 'result', view: { card: 'generic', title: 'gen done' } })
})
it('pairs live results from the open-call table without rescanning Session history', async () => {
const { ctx } = await harness()
const session = ctx.sessions.create()
const history = new SessionHistoryController(ctx)
const abort = new AbortController()
const stream = await openFollow(history, session.id, abort.signal)
const iterator = stream[Symbol.asyncIterator]()
session.append('tool/call', {
turn: 1, step: 1, callId: CallId('live-fast'), name: 'term', arguments: '{"cmd":"pwd"}',
})
await expect(iterator.next()).resolves.toMatchObject({
value: { type: 'event', view: { for: 'call', view: { card: 'terminal', title: 'pwd' } } },
})
const events = vi.spyOn(session, 'events', 'get').mockImplementation(() => {
throw new Error('live result rescanned Session history')
})
try {
session.append('tool/result', {
turn: 1, step: 1,
message: createToolResultMessage({
callId: CallId('live-fast'),
content: [{ type: 'text', text: 'ok' }],
isError: false,
}),
}, { surfaceOp: 'append' })
await expect(iterator.next()).resolves.toMatchObject({
value: { type: 'event', view: { for: 'result', view: { card: 'terminal', output: 'done' } } },
})
} finally {
events.mockRestore()
abort.abort()
await iterator.next()
await ctx.fiber.dispose()
}
})
it('serves history entries with call/result views, backscan pairing, and soft-falls', async () => {
const { ctx } = await harness()
const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const session = ctx.sessions.create()
// history resolves the agent first; a live structural stub is enough (only
// .session is read on this path).
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
session.append('turn/start', { turn: 1 })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('h-term'), name: 'term', arguments: '{"cmd":"ls"}' })
// meta rides through to presentResult's ToolResult (the spread arm).
session.append('tool/result', {
turn: 1, step: 1,
message: createToolResultMessage({
callId: CallId('h-term'),
content: [{ type: 'text', text: 'ok' }],
isError: false,
}),
meta: { n: 1 },
}, { surfaceOp: 'append' })
// Unpaired result: no tool/call with this id anywhere in the page.
session.append('tool/result', {
turn: 1, step: 1,
message: createToolResultMessage({
callId: CallId('h-orphan'),
content: [{ type: 'text', text: 'x' }],
isError: false,
}),
}, { surfaceOp: 'append' })
// Paired, but the call's stored arguments do not parse: backscan soft-falls.
session.append('tool/call', { turn: 1, step: 1, callId: CallId('h-bad'), name: 'term', arguments: '{broken' })
session.append('tool/result', {
turn: 1, step: 1,
message: createToolResultMessage({
callId: CallId('h-bad'),
content: [{ type: 'text', text: 'y' }],
isError: false,
}),
}, { surfaceOp: 'append' })
// Presenterless tool: pairing succeeds but presentResult is absent.
session.append('tool/call', { turn: 1, step: 1, callId: CallId('h-plain'), name: 'plain', arguments: '{}' })
session.append('tool/result', {
turn: 1, step: 1,
message: createToolResultMessage({
callId: CallId('h-plain'),
content: [{ type: 'text', text: 'z' }],
isError: false,
}),
}, { surfaceOp: 'append' })
const response = await remote.page({
address: { kind: 'session', sessionId: session.id },
throughSeq: session.seq - 1,
})
expect(response.ok).toBe(true)
if (!response.ok) throw new Error('unreachable')
const entries = response.value.events
const byKey = new Map(entries
.filter(entry => entry.event.type === 'tool/call' || entry.event.type === 'tool/result')
.map(entry => [
`${entry.event.type}:${entry.event.type === 'tool/call'
? (entry.event.data as unknown as SessionEvent<'tool/call'>['data']).callId
: (entry.event.data as unknown as SessionEvent<'tool/result'>['data']).message.source.callId}`,
entry,
]))
expect(byKey.get('tool/call:h-term')?.view).toEqual({ for: 'call', view: { card: 'terminal', title: 'ls' } })
expect(byKey.get('tool/result:h-term')?.view).toEqual({ for: 'result', view: { card: 'terminal', output: 'done' } })
expect('view' in (byKey.get('tool/result:h-orphan') ?? {})).toBe(false)
expect('view' in (byKey.get('tool/result:h-bad') ?? {})).toBe(false)
expect('view' in (byKey.get('tool/result:h-plain') ?? {})).toBe(false)
})
it('counts only append-origin messages toward maxMessages and keeps each compaction summary with its replacement', async () => {
const { ctx } = await harness()
const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const session = ctx.sessions.create()
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
session.append('turn/start', { turn: 1 })
const first = appendUserText(session, 'first prompt')
appendAssistantText(session, 'first reply', 1)
const third = appendUserText(session, 'second prompt')
appendAssistantText(session, 'second reply', 2)
const shadowed = [...session.surface.nodes]
// A compaction transaction: a log-only summary record immediately followed by the
// replacement that shadows the range.
const summary = appendExtension(session, 'compaction/summary', {
summary: [{ type: 'text', text: 'summary' }],
shadowedRange: { start: shadowed[0], end: shadowed.at(-1) },
shadowedSeqs: shadowed,
shadowedTokenCount: 0,
provider: 'p',
model: 'm',
})
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: '<context_checkpoint>summary</context_checkpoint>' }],
source: { kind: 'plugin', plugin: 'compact' },
}), {
surfaceOp: { op: 'replace', start: shadowed[0] as number, end: shadowed.at(-1) as number },
sourceEventSeqs: [...shadowed, summary.seq],
})
const response = await remote.page({
address: { kind: 'session', sessionId: session.id },
throughSeq: session.seq - 1,
maxMessages: 2,
})
if (!response.ok) throw new Error('unreachable')
const page = response.value.events.map(entry => entry.event)
// Two append-origin messages fill the page even though a replacement copy of
// the same event type sits in the window: the copy is model-only.
const messages = page.filter(event => event.type === 'user/message' || event.type === 'assistant/message')
expect(messages.map(event => event.seq)).toEqual([third.seq, third.seq + 1, third.seq + 3])
expect(page.some(event => event.seq === first.seq)).toBe(false)
expect(response.value.hasMore).toBe(true)
// The range stays contiguous, so the checkpoint's summary record is readable on
// the same page as the checkpoint itself.
const summaryIndex = page.findIndex(event => event.seq === summary.seq)
expect(summaryIndex).toBeGreaterThan(-1)
expect(page[summaryIndex + 1]?.seq).toBe(summary.seq + 1)
expect(page.map(event => event.seq)).toEqual(page.map((_event, index) => third.seq + index))
})
it('paginates a message with many provenance sources without variadic argument expansion', async () => {
const { ctx } = await harness()
const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const session = ctx.sessions.create()
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
session.append('turn/start', { turn: 1 })
const sources = Array.from({ length: 128 }, (_unused, index) => session.append('assistant/chunk', {
turn: 1,
step: 1,
chunk: { type: 'text-delta', index, text: 'x' },
}).seq)
const message = session.append('assistant/message', {
turn: 1,
step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'x'.repeat(sources.length) }],
source: { kind: 'model', provider: 'p', model: 'm' },
}),
}, { surfaceOp: 'append', sourceEventSeqs: sources })
const scalarMin = Math.min
const min = vi.spyOn(Math, 'min').mockImplementation((...values) => {
if (values.length > 2) throw new RangeError('variadic minimum rejected by regression harness')
return scalarMin(...values)
})
try {
const response = await remote.page({
address: { kind: 'session', sessionId: session.id },
throughSeq: message.seq,
maxMessages: 1,
})
if (!response.ok) throw new Error('unreachable')
expect(response.value.events.map(entry => entry.event.seq)).toEqual([...sources, message.seq])
expect(response.value.hasMore).toBe(true)
} finally {
min.mockRestore()
}
})
it('pairs a followed result after turn/end from the addressed Session log', async () => {
const { ctx } = await harness()
const session = ctx.sessions.create()
const history = new SessionHistoryController(ctx)
const abort = new AbortController()
const stream = await openFollow(history, session.id, abort.signal)
const collected = collect(stream, 4, abort)
session.append('turn/start', { turn: 1 })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-late'), name: 'term', arguments: '{"cmd":"tail"}' })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
session.append('tool/result', {
turn: 1, step: 1,
message: createToolResultMessage({
callId: CallId('c-late'),
content: [{ type: 'text', text: 'ok' }],
isError: false,
}),
}, { surfaceOp: 'append' })
const frames = await collected
const result = frames.find(f => f.type === 'event' && f.event.type === 'tool/result')
expect(result?.type === 'event' && result.view).toEqual({ for: 'result', view: { card: 'terminal', output: 'done' } })
})
})
@@ -0,0 +1,74 @@
/**
* The summary blank bit means "conversation not started" (no turn has run),
* not "log empty": standalone plugin events — command lifecycle records,
* plan/mode, permission knob events, session titles — never flip it, so running /plan or /goal on a
* fresh session keeps it list-hidden and reusable, while the first accepted
* prompt's turn/start clears it. The host/session-added frame shares the
* same predicate function (covered by the workspace spec's frame assertion).
*/
import { describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SessionStore from '@deepseek-ai/dsh-session'
import type { Session } from '@deepseek-ai/dsh-session'
import { CommandId } from '@deepseek-ai/dsh-commands/brand'
// Side-effect type imports: the configuration-event SessionEventMap merges.
import type {} from '@deepseek-ai/dsh-permission-presets'
import type {} from '@deepseek-ai/dsh-sandbox-policy'
import { createSessionTestRemote, type TestSessionRemote } from './test-remote.ts'
async function harness(): Promise<{ ctx: Context; remote: TestSessionRemote; attach: (session: Session) => void }> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
return {
ctx,
remote: createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }),
attach: (session) => {
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
},
}
}
/** Append the standalone (non-conversation) event family a fresh session can accumulate. */
function appendStandalone(session: Session): void {
session.append('command/run', {
commandId: CommandId('blank-cmd-1'), name: 'plan', args: '', source: { kind: 'user' },
})
session.append('plan/mode', { active: true })
session.append('command/done', { commandId: CommandId('blank-cmd-1'), kind: 'success', text: 'Plan mode on.' })
session.append('session/title', {
title: 'standalone title', messageSeqs: [], source: { kind: 'fallback' },
})
// Permission configuration events from a /permission switch on a fresh session.
session.append('permission/preset', { preset: 'danger-full-access' })
session.append('sandbox/mode', { mode: 'danger-full-access' })
}
async function listBlank(remote: TestSessionRemote, id: string): Promise<boolean | undefined> {
const result = await remote.list({})
if (!result.ok) throw new Error('list failed')
return result.value.items.find(item => item.sessionId === id)?.blank
}
describe('summary blank = conversation not started', () => {
it('standalone events (command lifecycle, plan/mode, title) keep the session blank', async () => {
const { ctx, remote, attach } = await harness()
const session = ctx.sessions.create()
attach(session)
expect(await listBlank(remote, session.id)).toBe(true)
appendStandalone(session)
expect(await listBlank(remote, session.id)).toBe(true)
})
it('the first turn clears blank', async () => {
const { ctx, remote, attach } = await harness()
const session = ctx.sessions.create()
attach(session)
appendStandalone(session)
session.append('turn/start', { turn: 0 })
expect(await listBlank(remote, session.id)).toBe(false)
})
})
@@ -0,0 +1,663 @@
/**
* Session Controller model-directory and selection behavior: dynamic provider grouping,
* provider-local catalog failures, logged-selection restoration without stale
* catalog injection, advisory pass-through models, and the prompt-assembly
* boundary for a running selection change.
*/
import { describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AttachmentStore from '@deepseek-ai/dsh-attachment'
import LlmRuntime, { LlmAdapter, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import type {
GenerateOptions, LlmCallConfig, LlmModelInfo, LlmModelReasoningInfo, LlmProviderInfo,
LlmResolvedModelInfo, StreamChunk,
UserMessage,
} from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import type { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionPromptRequest, SessionRequestId } from '../src/types.ts'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol'
import { createSessionTestRemote } from './test-remote.ts'
function request<P>(payload: P): P {
return payload
}
let nextRequestId = 1
function promptRequest(
payload: Omit<SessionPromptRequest, 'requestId'>,
): SessionPromptRequest {
return {
...payload,
requestId: `models-${String(nextRequestId++)}` as SessionRequestId,
}
}
class CatalogAdapter extends LlmAdapter {
constructor(
private readonly name: string,
private readonly models: readonly LlmModelInfo[] | Error,
private readonly reasoning?: LlmModelReasoningInfo,
private readonly exactError?: Error,
) {
super()
}
override providerInfo(provider: string): LlmProviderInfo {
return { id: provider, name: this.name }
}
override listModels(): Promise<readonly LlmModelInfo[]> {
return this.models instanceof Error
? Promise.reject(this.models)
: Promise.resolve(this.models)
}
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
if (this.exactError !== undefined) return Promise.reject(this.exactError)
return Promise.resolve({
provider,
id: model,
name: model,
...this.reasoning === undefined ? {} : { reasoning: this.reasoning },
})
}
override async *stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
// Catalog tests never enter provider streaming.
}
}
const REASONING: LlmModelReasoningInfo = {
efforts: [
{ id: ReasoningEffortId('off'), name: 'Off' },
{ id: ReasoningEffortId('high'), name: 'High' },
{ id: ReasoningEffortId('max'), name: 'Max' },
],
defaultEffort: ReasoningEffortId('high'),
}
async function harness(logged?: {
provider: string
model: string
reasoningEffort?: ReasoningEffortId
}): Promise<{
ctx: Context
agent: Agent
sessionId: SessionId
}> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt, { persona: '' })
await ctx.plugin(LlmRuntime)
await ctx.plugin(AgentRegistry)
ctx.llm.registerAdapter(['deepseek-official'], new CatalogAdapter('DeepSeek', [
{ provider: 'deepseek-official', id: 'deepseek-chat', name: 'DeepSeek Chat' },
{ provider: 'deepseek-official', id: 'deepseek-reasoner', name: 'DeepSeek Reasoner', description: 'Reasoning model' },
], REASONING))
ctx.llm.registerAdapter(['broken'], new CatalogAdapter('Broken Provider', new Error('catalog offline')))
ctx.llm.registerAdapter(['metadata-broken'], new CatalogAdapter('Metadata Broken', [
{ provider: 'metadata-broken', id: 'listed', name: 'Listed' },
], undefined, new Error('reasoning metadata offline')))
ctx.llm.registerAdapter(['remote-rejected'], new CatalogAdapter(
'Remote Rejected',
[],
undefined,
new TypertRemoteFailure({
code: 'fixture-rejected',
message: 'fixture rejected the selection',
details: { provider: 'remote-rejected' },
}),
))
ctx.llm.registerAdapter(['empty'], new CatalogAdapter('Empty Provider', []))
ctx.llm.registerAdapter(['duplicate'], new CatalogAdapter('Duplicate Provider', [
{ provider: 'duplicate', id: 'same', name: 'Same' },
{ provider: 'duplicate', id: 'same', name: 'Same Again' },
]))
const session = ctx.sessions.create()
if (logged !== undefined) {
session.append('request/header', { header: { config: logged }, reason: 'initial' })
}
const agent = {
id: session.id,
session,
status: 'running',
ctx,
inbox: { nextTurn: [], nextStep: [] },
} as unknown as Agent
ctx.agents.register(agent)
return { ctx, agent, sessionId: session.id }
}
function expectValue<T>(result: { ok: true; value: T } | { ok: false }): T {
if (!result.ok) throw new Error('expected successful response')
return result.value
}
function registerTextOnly(ctx: Context): void {
ctx.llm.registerAdapter(['text-only'], new class extends CatalogAdapter {
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
return Promise.resolve({ provider, id: model, name: model, inputModalities: ['text'] })
}
}('Text Only', []))
}
describe('Web session model selection', () => {
it('validates an ordered image batch before persisting any member', async () => {
const { ctx, agent, sessionId } = await harness()
const validateImage = vi.fn((_input: { data: Uint8Array }) => Promise.resolve())
const saveImage = vi.fn((input: { data: Uint8Array; mediaType: 'image/png'; name?: string }) => Promise.resolve({
attachmentId: `att-${String(input.data[0])}`,
mediaType: input.mediaType,
bytes: input.data.byteLength,
width: 1,
height: 1,
...input.name === undefined ? {} : { name: input.name },
}))
const attachments = {
imageLimits: {
maxImageBytes: 4,
maxImagesPerMessage: 2,
maxMessageImageBytes: 4,
maxImagePixels: 4,
maxImageDimension: 2000,
mediaTypes: ['image/png'],
},
validateImage,
saveImage,
}
ctx.provide('attachments', Object.setPrototypeOf(attachments, AttachmentStore.prototype) as never)
const followup = vi.fn()
Object.assign(agent, { followup })
const remote = createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
cwd: '/tmp',
})
const result = await remote.prompt(promptRequest({
sessionId,
mode: 'queue' as const,
content: [
{ type: 'image' as const, mediaType: 'image/png' as const, data: 'AQ==', name: 'first.png' },
{ type: 'text' as const, text: 'compare' },
{ type: 'image' as const, mediaType: 'image/png' as const, data: 'Ag==' },
],
}))
expect(result.ok).toBe(true)
expect(validateImage.mock.calls.map(([input]) => [...input.data])).toEqual([[1], [2]])
expect(saveImage.mock.calls.map(([input]) => [...input.data])).toEqual([[1], [2]])
expect((followup.mock.calls[0]?.[0] as UserMessage).content).toEqual([
{
type: 'image',
attachment: {
attachmentId: 'att-1', mediaType: 'image/png', bytes: 1, width: 1, height: 1, name: 'first.png',
},
},
{ type: 'text', text: 'compare' },
{ type: 'image', attachment: { attachmentId: 'att-2', mediaType: 'image/png', bytes: 1, width: 1, height: 1 } },
])
const denied = await remote.prompt(promptRequest({
sessionId,
mode: 'queue' as const,
content: Array.from({ length: 3 }, () => ({
type: 'image' as const, mediaType: 'image/png' as const, data: 'AQ==',
})),
}))
expect(denied).toMatchObject({
ok: false,
error: { code: 'attachment-error', details: { reason: 'TOO_MANY_IMAGES' } },
})
expect(saveImage).toHaveBeenCalledTimes(2)
await ctx.fiber.dispose()
})
it('allows a text-only selection while durable or pending images remain available for later models', async () => {
const { ctx, agent, sessionId } = await harness()
registerTextOnly(ctx)
const remote = createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
cwd: '/tmp',
})
const image = {
type: 'image' as const,
attachment: { attachmentId: 'att-history', mediaType: 'image/png' as const, bytes: 1, width: 1, height: 1 },
}
agent.session.append('user/message', {
id: 'image-message', role: 'user', source: { kind: 'user' }, content: [image],
} as never, { surfaceOp: 'append' })
expect(expectValue(await remote.selectModel(request({
sessionId, provider: 'text-only', model: 'plain',
}))).selected).toEqual({ provider: 'text-only', model: 'plain' })
agent.session.append('user/message', {
id: 'summary', role: 'user', source: { kind: 'plugin', plugin: 'compact' },
content: [{ type: 'text', text: 'image summarized' }],
} as never, {
surfaceOp: { op: 'replace', start: 0, end: agent.session.events.length - 1 },
sourceEventSeqs: agent.session.events.map(event => event.seq),
})
;(agent.inbox.nextTurn as UserMessage[]).push({
id: 'pending-image', role: 'user', source: { kind: 'user' }, content: [image],
} as never)
expect(expectValue(await remote.selectModel(request({
sessionId, provider: 'text-only', model: 'plain',
}))).selected).toEqual({ provider: 'text-only', model: 'plain' })
await ctx.fiber.dispose()
})
it('authorizes attachment bytes only when the session event stream references the id', async () => {
const { ctx, agent, sessionId } = await harness()
const ref = {
attachmentId: 'att-authorized', mediaType: 'image/png' as const, bytes: 2, width: 1, height: 1,
}
const readImage = vi.fn(() => Promise.resolve({ ref, data: Uint8Array.of(1, 2) }))
ctx.provide('attachments', { readImage } as never)
const remote = createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
cwd: '/tmp',
})
agent.session.append('agent/inbox/spliced', {
target: 'next-turn',
start: 0,
inserted: [{
id: 'queued-image', role: 'user', source: { kind: 'user' },
content: [{ type: 'image', attachment: ref }],
}],
} as never)
const allowed = await remote.attachment(request({
sessionId, attachmentId: 'att-authorized' as never,
}))
expect(allowed).toMatchObject({ ok: true, value: { attachment: ref, data: 'AQI=' } })
const denied = await remote.attachment(request({
sessionId, attachmentId: 'att-other' as never,
}))
expect(denied).toMatchObject({
ok: false,
error: { code: 'attachment-error', details: { reason: 'ATTACHMENT_NOT_REFERENCED' } },
})
expect(readImage).toHaveBeenCalledOnce()
await ctx.fiber.dispose()
})
it('groups successful providers and leaves an unlisted current selection out of the catalog', async () => {
const { ctx, sessionId } = await harness({
provider: 'deepseek-official',
model: 'private-preview',
reasoningEffort: ReasoningEffortId('max'),
})
const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp' })
const catalog = expectValue(await remote.models(request({ sessionId })))
expect(catalog.current).toEqual({
provider: 'deepseek-official',
model: 'private-preview',
reasoningEffort: 'max',
})
expect(catalog.groups).toEqual([{
id: 'deepseek-official',
name: 'DeepSeek',
models: [
{ id: 'deepseek-chat', name: 'DeepSeek Chat', reasoning: REASONING },
{
id: 'deepseek-reasoner',
name: 'DeepSeek Reasoner',
description: 'Reasoning model',
reasoning: REASONING,
},
],
}])
expect(catalog.failures).toEqual([
{ id: 'broken', name: 'Broken Provider', message: 'catalog offline' },
{ id: 'metadata-broken', name: 'Metadata Broken', message: 'reasoning metadata offline' },
{
id: 'duplicate',
name: 'Duplicate Provider',
message: 'adapter returned invalid or duplicate model metadata for provider "duplicate"',
},
])
await ctx.fiber.dispose()
})
it('preserves optional catalog metadata and string provider failures', async () => {
const { ctx, sessionId } = await harness()
ctx.llm.registerAdapter(['plain'], new CatalogAdapter('Plain', [
{ provider: 'plain', id: 'plain-model', name: 'Plain Model' },
]))
ctx.llm.registerAdapter(['described-reasoning'], new CatalogAdapter('Described Reasoning', [
{ provider: 'described-reasoning', id: 'reasoning-model', name: 'Reasoning Model' },
], {
efforts: [{ id: ReasoningEffortId('high'), name: 'High', description: 'More thinking' }],
}))
ctx.llm.registerAdapter(['string-failure'], new class extends CatalogAdapter {
override listModels(): Promise<readonly LlmModelInfo[]> {
// oxlint-disable-next-line typescript/prefer-promise-reject-errors -- non-Error provider normalization is the scenario.
return Promise.reject('string catalog failure')
}
}('String Failure', []))
const remote = createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
cwd: '/tmp',
})
const catalog = expectValue(await remote.models(request({ sessionId })))
expect(catalog.groups).toEqual(expect.arrayContaining([
{ id: 'plain', name: 'Plain', models: [{ id: 'plain-model', name: 'Plain Model' }] },
{
id: 'described-reasoning',
name: 'Described Reasoning',
models: [{
id: 'reasoning-model',
name: 'Reasoning Model',
reasoning: {
efforts: [{ id: 'high', name: 'High', description: 'More thinking' }],
},
}],
},
]))
expect(catalog.failures).toContainEqual({
id: 'string-failure', name: 'String Failure', message: 'string catalog failure',
})
await ctx.fiber.dispose()
})
it('accepts an advisory-unlisted model, rejects an unavailable provider, and switches only after the next assembly', async () => {
const { ctx, agent, sessionId } = await harness()
const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp' })
const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 }
const signal = new AbortController().signal
expect(expectValue(await remote.models(request({ sessionId }))).current)
.toEqual({ provider: 'deepseek-official', model: 'deepseek-chat' })
expect((await ctx.systemPrompt.assemble()).variables)
.toMatchObject({ provider: 'deepseek-official', model: 'deepseek-chat' })
const selected = expectValue(await remote.selectModel(request({
sessionId,
provider: 'deepseek-official',
model: 'private-preview',
reasoningEffort: 'max',
})))
expect(selected.selected).toEqual({
provider: 'deepseek-official',
model: 'private-preview',
reasoningEffort: 'max',
})
await expect(agentEvents(ctx, agent).waterfall(
'agent/request', { turn: 1, step: 0, signal }, () => Promise.resolve(seed),
)).resolves.toMatchObject({ provider: 'deepseek-official', model: 'deepseek-chat' })
expect((await ctx.systemPrompt.assemble()).variables)
.toMatchObject({ provider: 'deepseek-official', model: 'private-preview' })
await expect(agentEvents(ctx, agent).waterfall(
'agent/request', { turn: 1, step: 1, signal }, () => Promise.resolve(seed),
)).resolves.toMatchObject({
provider: 'deepseek-official',
model: 'private-preview',
reasoningEffort: 'max',
})
const unsupported = await remote.selectModel(request({
sessionId,
provider: 'deepseek-official',
model: 'private-preview',
reasoningEffort: 'medium',
}))
expect(unsupported).toMatchObject({
ok: false,
error: {
code: 'model-unavailable',
message: 'provider "deepseek-official" model "private-preview" does not support reasoning effort "medium"',
},
})
const rejected = await remote.selectModel(request({
sessionId,
provider: 'missing',
model: 'model',
}))
expect(rejected).toEqual({
ok: false,
error: {
code: 'model-unavailable',
message: 'no adapter registered for provider "missing"',
details: { provider: 'missing', model: 'model' },
},
})
expect(await remote.selectModel(request({
sessionId,
provider: 'remote-rejected',
model: 'model',
}))).toEqual({
ok: false,
error: {
code: 'fixture-rejected',
message: 'fixture rejected the selection',
details: { provider: 'remote-rejected' },
},
})
expect(expectValue(await remote.models(request({ sessionId }))).current)
.toEqual({ provider: 'deepseek-official', model: 'private-preview', reasoningEffort: 'max' })
await ctx.fiber.dispose()
})
it('reads the Agent default live for a session whose log names no selection', async () => {
const { ctx, sessionId } = await harness()
let stored = { provider: 'deepseek-official', model: 'deepseek-chat' }
const remote = createSessionTestRemote(ctx, {
defaultModelSelection: () => stored,
cwd: '/tmp',
})
expect(expectValue(await remote.models(request({ sessionId }))).current)
.toEqual({ provider: 'deepseek-official', model: 'deepseek-chat' })
// The default moving after the session exists still reaches it: New
// Session reuses a blank session rather than minting another, so a seed
// captured at creation would show the superseded model there.
stored = { provider: 'deepseek-official', model: 'deepseek-reasoner' }
expect(expectValue(await remote.models(request({ sessionId }))).current)
.toEqual({ provider: 'deepseek-official', model: 'deepseek-reasoner' })
await ctx.fiber.dispose()
})
it('keeps a session on its logged selection when the Agent default differs', async () => {
const { ctx, sessionId } = await harness({
provider: 'deepseek-official',
model: 'deepseek-chat',
})
let stored = { provider: 'deepseek-official', model: 'deepseek-chat' }
const remote = createSessionTestRemote(ctx, {
defaultModelSelection: () => stored,
cwd: '/tmp',
})
stored = { provider: 'duplicate', model: 'same' }
expect(expectValue(await remote.models(request({ sessionId }))).current)
.toEqual({ provider: 'deepseek-official', model: 'deepseek-chat' })
await ctx.fiber.dispose()
})
it('saves an accepted selection as the default and survives a storage failure', async () => {
const { ctx, sessionId } = await harness()
const saved: unknown[] = []
let reject = false
const remote = createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
saveDefaultModelSelection: (selection) => {
saved.push(selection)
return reject ? Promise.reject(new Error('read-only document')) : Promise.resolve()
},
cwd: '/tmp',
})
expectValue(await remote.selectModel(request({
sessionId, provider: 'deepseek-official', model: 'deepseek-reasoner', reasoningEffort: 'max',
})))
expect(saved).toEqual([
{ provider: 'deepseek-official', model: 'deepseek-reasoner', reasoningEffort: 'max' },
])
// A refused selection never becomes anyone's default.
await remote.selectModel(request({ sessionId, provider: 'missing', model: 'model' }))
expect(saved).toHaveLength(1)
// Storage failing is not the selection failing: the switch already applies
// to this session, so the call still succeeds.
reject = true
const stillAccepted = expectValue(await remote.selectModel(request({
sessionId, provider: 'deepseek-official', model: 'deepseek-chat',
})))
expect(stillAccepted.selected).toEqual({ provider: 'deepseek-official', model: 'deepseek-chat', reasoningEffort: 'high' })
expect(expectValue(await remote.models(request({ sessionId }))).current)
.toEqual({ provider: 'deepseek-official', model: 'deepseek-chat', reasoningEffort: 'high' })
await ctx.fiber.dispose()
})
it('refuses a prompt no adapter can route, and reports it on the directory', async () => {
const { ctx, sessionId } = await harness()
const remote = createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'deleted-gateway', model: 'deleted-model' }),
cwd: '/tmp',
})
// The client disabling its input is an affordance; this method stays
// callable, so the refusal has to live here.
const refused = await remote.prompt(promptRequest({
sessionId, mode: 'queue' as const, content: [{ type: 'text' as const, text: 'hi' }],
}))
expect(refused).toMatchObject({
ok: false,
error: { code: 'model-unavailable', details: { provider: 'deleted-gateway', model: 'deleted-model' } },
})
expect(expectValue(await remote.models(request({ sessionId }))).routable).toBe(false)
// An advisory-unlisted model on a live route is NOT this: the route
// serves it, so the prompt goes through and nothing blocks.
expectValue(await remote.selectModel(request({
sessionId, provider: 'deepseek-official', model: 'unlisted-but-served',
})))
const catalog = expectValue(await remote.models(request({ sessionId })))
expect(catalog.routable).toBe(true)
expect(catalog.groups.flatMap(group => group.models.map(model => model.id)))
.not.toContain('unlisted-but-served')
await ctx.fiber.dispose()
})
it('serves a session and its catalog when the stored default names a route that is gone', async () => {
const { ctx, sessionId } = await harness()
const remote = createSessionTestRemote(ctx, {
// What a Models-page removal leaves behind: the settings document still
// names the route the user last picked, and nothing serves it.
defaultModelSelection: () => ({ provider: 'deleted-gateway', model: 'deleted-model' }),
cwd: '/tmp',
})
const catalog = expectValue(await remote.models(request({ sessionId })))
// Passed through rather than repaired: matching no group is precisely what
// makes the composer seat prompt for a selection instead of naming a model
// the deployment cannot reach.
expect(catalog.current).toEqual({ provider: 'deleted-gateway', model: 'deleted-model' })
expect(catalog.groups.flatMap(group => group.models.map(model => `${group.id}/${model.id}`)))
.not.toContain('deleted-gateway/deleted-model')
await ctx.fiber.dispose()
})
it('maps image admission failures and accepts image-capable selections', async () => {
const { ctx, agent, sessionId } = await harness()
registerTextOnly(ctx)
ctx.llm.registerAdapter(['image-capable'], new class extends CatalogAdapter {
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
return Promise.resolve({
provider, id: model, name: model, inputModalities: ['text', 'image'],
})
}
}('Image Capable', []))
ctx.llm.registerAdapter(['string-error'], new class extends CatalogAdapter {
override resolveModel(): Promise<LlmResolvedModelInfo> {
// oxlint-disable-next-line typescript/prefer-promise-reject-errors -- non-Error provider normalization is the scenario.
return Promise.reject('string selection failure')
}
}('String Error', []))
let saveMode: 'success' | 'error' | 'remote' = 'success'
const savedRef = {
attachmentId: 'saved-image', mediaType: 'image/png' as const, bytes: 1, width: 1, height: 1,
}
ctx.provide('attachments', {
saveImages: () => {
if (saveMode === 'error') return Promise.reject(new Error('image store offline'))
if (saveMode === 'remote') {
return Promise.reject(new TypertRemoteFailure({
code: 'fixture-rejected', message: 'fixture rejected', details: {},
}))
}
return Promise.resolve([savedRef])
},
} as never)
const followup = vi.fn()
Object.assign(agent, { followup })
const remote = createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
cwd: '/tmp',
})
const image = { type: 'image' as const, mediaType: 'image/png' as const, data: 'AQ==' }
expectValue(await remote.selectModel(request({
sessionId, provider: 'text-only', model: 'plain',
})))
expect(await remote.prompt(promptRequest({
sessionId, mode: 'queue', content: [image],
}))).toMatchObject({
ok: false,
error: { code: 'attachment-error', details: { reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' } },
})
expectValue(await remote.selectModel(request({
sessionId, provider: 'image-capable', model: 'vision',
})))
expect(await remote.prompt(promptRequest({
sessionId, mode: 'queue', content: [{ ...image, data: '' }],
}))).toMatchObject({
ok: false,
error: { code: 'attachment-error', details: { reason: 'INVALID_IMAGE_BASE64' } },
})
saveMode = 'error'
expect(await remote.prompt(promptRequest({
sessionId, mode: 'queue', content: [image],
}))).toMatchObject({ ok: false, error: { code: 'agent-busy' } })
saveMode = 'remote'
expect(await remote.prompt(promptRequest({
sessionId, mode: 'queue', content: [image],
}))).toMatchObject({ ok: false, error: { code: 'fixture-rejected' } })
saveMode = 'success'
expectValue(await remote.prompt(promptRequest({ sessionId, mode: 'queue', content: [image] })))
expect(followup).toHaveBeenCalledOnce()
;(agent.inbox.nextTurn as UserMessage[]).push({
id: 'pending-image', role: 'user', source: { kind: 'user' },
content: [{ type: 'image', attachment: savedRef }],
} as never)
expectValue(await remote.selectModel(request({
sessionId, provider: 'deepseek-official', model: 'deepseek-chat',
})))
expectValue(await remote.selectModel(request({
sessionId, provider: 'image-capable', model: 'vision',
})))
expect(await remote.selectModel(request({
sessionId, provider: 'metadata-broken', model: 'broken',
}))).toMatchObject({
ok: false, error: { code: 'model-unavailable', message: 'reasoning metadata offline' },
})
expect(await remote.selectModel(request({
sessionId, provider: 'string-error', model: 'broken',
}))).toMatchObject({
ok: false,
error: { code: 'model-unavailable', message: 'string selection failure' },
})
await ctx.fiber.dispose()
})
})
@@ -0,0 +1,165 @@
/** Session creation and adoption rules for Agent preset identity. */
import { mkdtempSync, realpathSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent, AgentFactory } from '@deepseek-ai/dsh-agent'
import { UnknownPresetError } from '@deepseek-ai/dsh-agent-presets'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { Session } from '@deepseek-ai/dsh-session'
import { describe, expect, it } from 'vitest'
import { createSessionTestRemote } from './test-remote.ts'
function stubAgent(session: Session): Agent {
return { id: session.id, session, status: 'idle' } as unknown as Agent
}
function roster(ids: readonly string[]): unknown {
const presetOf = (id: string): object => ({
id,
trust: 'system',
path: `/presets/${id}/agent.cordis.yml`,
})
return {
defaultId: ids[0],
resolve: (id?: string) => {
const wanted = id ?? ids[0] ?? ''
if (!ids.includes(wanted)) return Promise.reject(new UnknownPresetError(wanted, ids))
return Promise.resolve(presetOf(wanted))
},
mount: (_ctx: Context, id?: string) => Promise.resolve(presetOf(id ?? ids[0] ?? '')),
}
}
async function harness(presets?: readonly string[]) {
const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-')))
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
ctx.provide('sessionPersistence', { list: () => Promise.resolve([]) } as never)
if (presets !== undefined) ctx.provide('agentPresets', roster(presets) as never)
const factory: AgentFactory = {
async createAgent(_ownerCtx, options) {
const session = ctx.sessions.create(
options.sessionId,
options.meta === undefined ? {} : { meta: options.meta },
)
const agent = stubAgent(session)
const agentCtx = ctx.extend({ agent })
;(agent as { ctx?: Context }).ctx = agentCtx
await options.setup?.(agentCtx)
const unregister = ctx.agents.register(agent)
return { agent, dispose: () => { unregister(); return Promise.resolve() } }
},
async resume() {
throw new Error('test harness has no persisted sessions')
},
}
ctx.agents.setFactory(factory)
const remote = createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'test', model: 'test-model' }),
cwd,
})
return { ctx, remote }
}
describe('session.create Agent preset identity', () => {
it('records the requested preset on the Session header', async () => {
const { ctx, remote } = await harness(['standard', 'minimal'])
const created = await remote.create({ sessionId: SessionId('s1'), agentPreset: 'minimal' })
expect(created.ok).toBe(true)
expect(ctx.sessions.get(SessionId('s1'))?.header.agentPreset).toBe('minimal')
})
it('records the roster default when the caller names no preset', async () => {
const { ctx, remote } = await harness(['standard', 'minimal'])
await remote.create({ sessionId: SessionId('s2') })
expect(ctx.sessions.get(SessionId('s2'))?.header.agentPreset).toBe('standard')
})
it('rejects an unknown preset', async () => {
const { remote } = await harness(['standard'])
const response = await remote.create({ sessionId: SessionId('s3'), agentPreset: 'nope' })
expect(response).toMatchObject({ ok: false, error: { code: 'agent-preset-not-found' } })
})
it('refuses to adopt a live Session under a different preset', async () => {
const { remote } = await harness(['standard', 'minimal'])
await remote.create({ sessionId: SessionId('s4'), agentPreset: 'minimal' })
const response = await remote.create({ sessionId: SessionId('s4'), agentPreset: 'standard' })
expect(response).toMatchObject({
ok: false,
error: {
code: 'agent-preset-conflict',
details: {
sessionId: 's4',
requestedPreset: 'standard',
existingPreset: 'minimal',
},
},
})
})
it('adopts a live Session under the preset selected in its log', async () => {
const { ctx, remote } = await harness(['standard', 'minimal'])
await remote.create({ sessionId: SessionId('s4b'), agentPreset: 'standard' })
ctx.sessions.get(SessionId('s4b'))?.append('agent-preset/selected', { agentPreset: 'minimal' })
const adopted = await remote.create({ sessionId: SessionId('s4b'), agentPreset: 'minimal' })
const stale = await remote.create({ sessionId: SessionId('s4b'), agentPreset: 'standard' })
expect(adopted).toMatchObject({ ok: true, value: { agentPreset: 'minimal' } })
expect(stale).toMatchObject({
ok: false,
error: { details: { existingPreset: 'minimal' } },
})
})
it('adopts a live Session unchanged when the caller names no preset', async () => {
const { remote } = await harness(['standard', 'minimal'])
await remote.create({ sessionId: SessionId('s5'), agentPreset: 'minimal' })
await expect(remote.create({ sessionId: SessionId('s5') }))
.resolves.toMatchObject({ ok: true })
})
it('leaves the header preset-less when no roster is composed', async () => {
const { ctx, remote } = await harness()
await remote.create({ sessionId: SessionId('s6') })
expect(ctx.sessions.get(SessionId('s6'))?.header.agentPreset).toBeUndefined()
})
it('explains why a preset-less Session cannot be adopted under one', async () => {
const { remote } = await harness()
await remote.create({ sessionId: SessionId('s7') })
const response = await remote.create({ sessionId: SessionId('s7'), agentPreset: 'standard' })
expect(response).toMatchObject({
ok: false,
error: {
code: 'agent-preset-conflict',
details: {
sessionId: 's7',
requestedPreset: 'standard',
},
},
})
if (response.ok) throw new Error('unreachable')
expect('existingPreset' in response.error.details).toBe(false)
expect(response.error.message).toContain('records no agent preset')
})
})
@@ -0,0 +1,522 @@
/**
* Session Controller projection paths: the history tail page's
* projections block reads the registry's watermark snapshot (asOfSeq = last
* event seq, one consistent cut); loadOlder pages never carry the block; a
* composition without the registry serves histories without it; a disposed
* registration's key leaves subsequent responses; and every unit change is
* pushed through the control stream.
*/
import { describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { z } from 'zod'
import AgentRegistry, { agentEvents, Inbox } from '@deepseek-ai/dsh-agent'
import { AttachmentStore } from '@deepseek-ai/dsh-attachment'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { Session } from '@deepseek-ai/dsh-session'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
import { SessionControlController } from '@deepseek-ai/dsh-api-session-controller/src/control.ts'
import type { SessionControlFrame } from '@deepseek-ai/dsh-api-session-controller/types'
import { createSessionTestRemote, type TestSessionRemote } from './test-remote.ts'
declare module '@deepseek-ai/dsh-session-projection/types' {
interface SessionProjectionStateMap {
'test/last-user': LastUserState
'test/internal-count': number
}
interface SessionProjectionMap {
'test/last-user': { text: string } | null
}
}
function request<P>(payload: P): P {
return payload
}
function page(
remote: TestSessionRemote,
request: { sessionId: SessionId; throughSeq: number; beforeSeq?: number; maxMessages?: number },
) {
return remote.page({
address: { kind: 'session', sessionId: request.sessionId },
throughSeq: request.throughSeq,
...(request.beforeSeq === undefined ? {} : { beforeSeq: request.beforeSeq }),
...(request.maxMessages === undefined ? {} : { maxMessages: request.maxMessages }),
})
}
/** Whole-value unit folding the latest user/message text; null before the first. */
type LastUserState = { text: string } | null
const lastUserUnit = () => ({
key: 'test/last-user',
stateSchema: z.union([z.object({ text: z.string() }), z.null()]),
init: () => null,
apply: (state, event) => (event.type === 'user/message'
? { text: (event.data.content[0] as { text?: string }).text ?? '' }
: state),
wire: {
viewSchema: z.union([z.object({ text: z.string() }), z.null()]),
view: state => state,
},
stateVersion: 1,
}) satisfies ProjectionDefinition<'test/last-user', LastUserState>
const internalCountUnit = () => ({
key: 'test/internal-count',
stateSchema: z.number().int().nonnegative(),
init: () => 0,
apply: (state: number) => state + 1,
stateVersion: 1,
}) satisfies ProjectionDefinition<'test/internal-count', number>
async function harness(withRegistry: boolean): Promise<{ ctx: Context; session: Session }> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
if (withRegistry) await ctx.plugin(SessionProjectionRegistry)
const session = ctx.sessions.create()
const agent = {
id: session.id,
session,
inbox: { nextTurn: [], nextStep: [], hasPending: false } as never,
status: 'idle',
ctx,
} as unknown as Agent
if (withRegistry) Object.assign(agent, { inbox: new Inbox(ctx, agent.session, agentEvents(ctx, agent)) })
ctx.agents.register(agent)
return { ctx, session }
}
/** Append `count` user messages so the log has paginable message boundaries. */
function seedMessages(session: Session, count: number): void {
for (let i = 0; i < count; i++) {
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: `m${i}` }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
}
}
const remote = (ctx: Context) => createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
describe('session.history projections block', () => {
it('serves the unit value on the tail page with asOfSeq = last event seq', async () => {
const { ctx, session } = await harness(true)
ctx.sessionProjections.register(lastUserUnit())
seedMessages(session, 3)
const response = await page(remote(ctx), request({ sessionId: session.id, throughSeq: session.seq - 1 }))
expect(response.ok).toBe(true)
if (!response.ok) throw new Error('unreachable')
const { events, projections } = response.value
expect(projections).toBeDefined()
expect(projections?.asOfSeq).toBe(session.seq - 1)
expect(projections?.values['test/last-user']).toEqual({ text: 'm2' })
// asOfSeq IS the window tail: the last served event carries it.
expect(events.at(-1)?.event.seq).toBe(projections?.asOfSeq)
})
it('reconstructs a cold persisted queue without publishing or resuming an Agent', async () => {
const { ctx } = await harness(true)
const coldId = SessionId('cold-persisted-queue')
const meta = { version: 0 as const, id: coldId, createdAt: 1, cwd: '/tmp' }
const message = createUserMessage({
content: [{ type: 'text', text: 'survive process restart' }],
source: { kind: 'user' },
})
const events = [{
type: 'agent/inbox/spliced',
seq: 0,
time: 2,
data: { target: 'next-turn', start: 0, inserted: [message] },
}] as const
ctx.provide('sessionPersistence', {
list: () => Promise.resolve([meta]),
inspect: () => Promise.resolve({ meta, events }),
} as never)
const response = await page(remote(ctx), request({ sessionId: coldId, throughSeq: 0 }))
if (!response.ok) throw new Error('history failed')
expect(response.value.projections?.values.inbox).toEqual({
'next-turn': [message],
'next-step': [],
})
expect(ctx.agents.get(coldId)).toBeUndefined()
expect(ctx.sessions.get(coldId)).toBeUndefined()
})
it('removes claimed steering from the pending Inbox projection immediately', async () => {
const { ctx, session } = await harness(true)
const proxy = remote(ctx)
const message = createUserMessage({
content: [{ type: 'text', text: 'apply this now' }],
source: { kind: 'user' },
})
const agent = ctx.agents.get(session.id)
if (agent === undefined) throw new Error('missing Agent')
agent.inbox.append('next-step', message)
agent.inbox.claim('next-step', 1)
const during = await page(proxy, request({ sessionId: session.id, throughSeq: session.seq - 1 }))
if (!during.ok) throw new Error('history failed')
expect(during.value.projections?.values.inbox).toEqual({
'next-turn': [],
'next-step': [],
})
session.append('user/message', message, { surfaceOp: 'append' })
const settled = await page(proxy, request({ sessionId: session.id, throughSeq: session.seq - 1 }))
if (!settled.ok) throw new Error('history failed')
expect(settled.value.projections?.values.inbox).toEqual({
'next-turn': [],
'next-step': [],
})
const rejected = createUserMessage({
content: [{ type: 'text', text: 'reject this pre-step' }],
source: { kind: 'user' },
})
session.append('turn/start', { turn: 1 })
agent.inbox.append('next-step', rejected)
agent.inbox.claim('next-step', 1)
session.append('turn/end', { turn: 1, reason: { kind: 'blocked' } })
const closed = await page(proxy, request({ sessionId: session.id, throughSeq: session.seq - 1 }))
if (!closed.ok) throw new Error('history failed')
expect(closed.value.projections?.values.inbox).toEqual({
'next-turn': [],
'next-step': [],
})
})
it('cuts attached projections and events at the requested follow cursor', async () => {
const { ctx, session } = await harness(true)
ctx.sessionProjections.register(lastUserUnit())
seedMessages(session, 2)
const response = await page(remote(ctx), request({ sessionId: session.id, throughSeq: 0 }))
if (!response.ok) throw new Error('history failed')
expect(response.value.events.map(entry => entry.event.seq)).toEqual([0])
expect(response.value.projections?.asOfSeq).toBe(0)
expect(response.value.projections?.values).toEqual(
expect.objectContaining({ 'test/last-user': { text: 'm0' } }),
)
})
it('projects an empty log at cursor -1', async () => {
const { ctx, session } = await harness(true)
ctx.sessionProjections.register(lastUserUnit())
const response = await page(remote(ctx), request({ sessionId: session.id, throughSeq: -1 }))
if (!response.ok) throw new Error('history failed')
expect(response.value.events).toEqual([])
expect(response.value.projections?.asOfSeq).toBe(-1)
expect(response.value.projections?.values).toEqual(
expect.objectContaining({ 'test/last-user': null }),
)
})
it('publishes the attachments imageLimits as a constant unit while both seams are composed', async () => {
const { ctx, session } = await harness(true)
const limits = {
maxImageBytes: 5 * 1024 * 1024,
maxImagesPerMessage: 20,
maxMessageImageBytes: 100 * 1024 * 1024,
maxImagePixels: 40_000_000,
maxImageDimension: 2000,
mediaTypes: ['image/png'] as const,
}
await ctx.plugin(class extends AttachmentStore {
readonly imageLimits = limits
validateImage(): Promise<void> { return Promise.resolve() }
saveImage(): Promise<never> { return Promise.reject(new Error('unused')) }
readImage(): Promise<never> { return Promise.reject(new Error('unused')) }
})
const gateway = remote(ctx)
seedMessages(session, 2)
const response = await page(gateway, request({ sessionId: session.id, throughSeq: session.seq - 1 }))
if (!response.ok) throw new Error('history failed')
expect(response.value.projections?.values['imageLimits']).toEqual(limits)
// Constant unit: appending events must never broadcast an imageLimits projection.
await new Promise(resolve => setTimeout(resolve, 0))
const abort = new AbortController()
const iterator = gateway.control(abort.signal)[Symbol.asyncIterator]()
await iterator.next()
const next = iterator.next()
seedMessages(session, 1)
await new Promise(resolve => setTimeout(resolve, 0))
await expect(next).resolves.toMatchObject({
done: false,
value: { type: 'projection', key: 'sessionListMetadata' },
})
const extra = iterator.next()
const quiet = Symbol('quiet')
expect(await Promise.race([
extra,
new Promise<typeof quiet>(resolve => setTimeout(() => { resolve(quiet) }, 0)),
])).toBe(quiet)
abort.abort()
await expect(extra).resolves.toEqual({ done: true, value: undefined })
})
it('leaves the imageLimits key absent while no attachment service is composed', async () => {
const { ctx, session } = await harness(true)
seedMessages(session, 1)
const response = await page(remote(ctx), request({ sessionId: session.id, throughSeq: session.seq - 1 }))
if (!response.ok) throw new Error('history failed')
expect(response.value.projections).toBeDefined()
expect('imageLimits' in (response.value.projections?.values ?? {})).toBe(false)
})
it('never carries the block on loadOlder pages (beforeSeq present)', async () => {
const { ctx, session } = await harness(true)
ctx.sessionProjections.register(lastUserUnit())
seedMessages(session, 5)
const older = await page(remote(ctx), request({
sessionId: session.id, throughSeq: session.seq - 1, beforeSeq: 3, maxMessages: 2,
}))
expect(older.ok).toBe(true)
if (!older.ok) throw new Error('unreachable')
expect('projections' in older.value).toBe(false)
})
it('serves no block when the composition has no projection registry', async () => {
const { ctx, session } = await harness(false)
seedMessages(session, 2)
const response = await page(remote(ctx), request({ sessionId: session.id, throughSeq: session.seq - 1 }))
expect(response.ok).toBe(true)
if (!response.ok) throw new Error('unreachable')
expect('projections' in response.value).toBe(false)
})
it('never exposes a host-only unit through history, listing, or push frames', async () => {
const { ctx, session } = await harness(true)
ctx.sessionProjections.register(internalCountUnit())
const proxy = remote(ctx)
await new Promise(resolve => setTimeout(resolve, 0))
const abort = new AbortController()
const iterator = proxy.control(abort.signal)[Symbol.asyncIterator]()
const baseline = await iterator.next()
if (baseline.done || baseline.value.type !== 'baseline') {
throw new Error('control stream ended before its baseline')
}
expect('test/internal-count' in (baseline.value.value.projections[session.id]?.values ?? {}))
.toBe(false)
seedMessages(session, 1)
const changed = await iterator.next()
expect(changed).toMatchObject({
done: false,
value: { type: 'projection', key: 'sessionListMetadata' },
})
abort.abort()
await iterator.return?.()
const history = await page(proxy, request({ sessionId: session.id, throughSeq: session.seq - 1 }))
if (!history.ok) throw new Error('history failed')
expect('test/internal-count' in (history.value.projections?.values ?? {})).toBe(false)
const listing = await proxy.list(request({}))
if (!listing.ok) throw new Error('listing failed')
const row = listing.value.items.find(item => item.sessionId === session.id)
expect('test/internal-count' in (row?.projections?.values ?? {})).toBe(false)
})
it('drops a disposed registration from subsequent tail pages (empty block, key absent)', async () => {
const { ctx, session } = await harness(true)
const dispose = ctx.sessionProjections.register(lastUserUnit())
seedMessages(session, 1)
const proxy = remote(ctx)
const before = await page(proxy, request({ sessionId: session.id, throughSeq: session.seq - 1 }))
if (!before.ok) throw new Error('unreachable')
expect(before.value.projections?.values['test/last-user']).toEqual({ text: 'm0' })
dispose()
const after = await page(proxy, request({ sessionId: session.id, throughSeq: session.seq - 1 }))
if (!after.ok) throw new Error('unreachable')
// The registry stays mounted; only the disposed key leaves while the
// gateway-owned Session-list unit remains.
expect(after.value.projections?.asOfSeq).toBe(session.seq - 1)
expect('test/last-user' in (after.value.projections?.values ?? {})).toBe(false)
expect(after.value.projections?.values.sessionListMetadata).toEqual({
blank: true,
lastPromptAt: session.events.at(-1)?.time,
})
})
it('removes the gateway-owned Session-list unit when the gateway fiber unloads', async () => {
const { ctx, session } = await harness(true)
expect('sessionListMetadata' in ctx.sessionProjections.snapshot(session).values).toBe(false)
const fiber = ctx.plugin(Object.assign((gatewayCtx: Context) => {
createSessionTestRemote(gatewayCtx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
}, { inject: ['sessions', 'agents', 'sessionProjections'] }))
await fiber.await()
await vi.waitFor(() => {
expect(ctx.sessionProjections.snapshot(session).values.sessionListMetadata)
.toEqual({ blank: true, lastPromptAt: null })
})
await fiber.dispose()
expect('sessionListMetadata' in ctx.sessionProjections.snapshot(session).values).toBe(false)
})
})
describe('session.list projections column', () => {
it('serves attached rows from the live registry cut, watermarked for client seeding', async () => {
const { ctx, session } = await harness(true)
ctx.sessionProjections.register(lastUserUnit())
const gateway = remote(ctx)
await new Promise(resolve => setTimeout(resolve, 0))
session.append('turn/start', { turn: 1 })
seedMessages(session, 1)
const response = await gateway.list(request({}))
if (!response.ok) throw new Error('unreachable')
const row = response.value.items.find(item => item.sessionId === session.id)
expect(row?.projections?.values['test/last-user']).toEqual({ text: 'm0' })
expect(row?.projections?.values.sessionListMetadata).toEqual({
blank: false,
lastPromptAt: session.events.at(-1)?.time,
})
expect(row?.projections?.asOfSeq).toBe(session.seq - 1)
})
it('omits the column entirely when no registry is mounted', async () => {
const { ctx, session } = await harness(false)
seedMessages(session, 1)
const response = await remote(ctx).list(request({}))
if (!response.ok) throw new Error('unreachable')
const row = response.value.items.find(item => item.sessionId === session.id)
expect(row).toBeDefined()
expect(row !== undefined && 'projections' in row).toBe(false)
})
it('serves cold rows from the persisted projection cache with zero log loads', async () => {
const { ctx } = await harness(true)
const coldId = SessionId('session-cold-listing')
const load = () => { throw new Error('list must not load event logs') }
ctx.provide('sessionPersistence', {
list: async () => [{ version: 0, id: coldId, createdAt: 5, cwd: '/tmp' }],
locate: () => undefined,
load,
inspect: load,
readFrom: load,
} as never)
ctx.provide('sessionProjectionCache', {
// The carrier hands the listed header through as the identity witness.
cachedSnapshot: (meta: { id: unknown; createdAt: number }) =>
(meta.id === coldId && meta.createdAt === 5
? { asOfSeq: 7, values: { 'test/last-user': { text: 'cached' } } }
: undefined),
} as never)
const response = await remote(ctx).list(request({}))
if (!response.ok) throw new Error('unreachable')
const row = response.value.items.find(item => item.sessionId === coldId)
expect(row?.running).toBe(false)
expect(row?.projections).toEqual({ asOfSeq: 7, values: { 'test/last-user': { text: 'cached' } } })
})
it('cold rows without a cache plugin (or without a stored row) just lack the column', async () => {
const { ctx } = await harness(true)
const coldId = SessionId('session-cold-uncached')
ctx.provide('sessionPersistence', {
list: async () => [{ version: 0, id: coldId, createdAt: 5, cwd: '/tmp' }],
locate: () => undefined,
} as never)
const response = await remote(ctx).list(request({}))
if (!response.ok) throw new Error('unreachable')
const row = response.value.items.find(item => item.sessionId === coldId)
expect(row).toBeDefined()
expect(row !== undefined && 'projections' in row).toBe(false)
})
it('a throwing column read degrades that row, never the listing', async () => {
const { ctx, session } = await harness(true)
ctx.sessionProjections.register({
...lastUserUnit(),
wire: {
viewSchema: z.union([z.object({ text: z.string() }), z.null()]),
view: () => { throw new Error('unit exploded') },
},
})
seedMessages(session, 1)
const response = await remote(ctx).list(request({}))
if (!response.ok) throw new Error('unreachable')
const row = response.value.items.find(item => item.sessionId === session.id)
expect(row).toBeDefined()
expect(row !== undefined && 'projections' in row).toBe(false)
})
})
describe('Session control projection frames', () => {
/** Drain frames until `count` projection replacements arrive. */
async function collect(
iterable: AsyncIterable<SessionControlFrame>,
count: number,
abort: AbortController,
): Promise<SessionControlFrame[]> {
const frames: SessionControlFrame[] = []
for await (const frame of iterable) {
frames.push(frame)
if (frames.filter(candidate => candidate.type === 'projection').length >= count) abort.abort()
}
return frames
}
it('broadcasts a frame per changed unit with the causing seq, and none for same-reference applies', async () => {
const { ctx, session } = await harness(true)
ctx.sessionProjections.register(lastUserUnit())
const proxy = remote(ctx)
// The controller's onChanged subscription lives in an inject child whose
// fiber activates asynchronously; yield until it lands before appending.
await new Promise(resolve => setTimeout(resolve, 0))
const abort = new AbortController()
const stream = proxy.control(abort.signal)
const collected = collect(stream, 5, abort)
const now = vi.spyOn(Date, 'now').mockReturnValue(100)
seedMessages(session, 1)
now.mockReturnValue(200)
session.append('turn/start', { turn: 1 })
now.mockReturnValue(300)
seedMessages(session, 1)
now.mockRestore()
const frames = await collected
const pushes = frames.filter(
(f): f is Extract<SessionControlFrame, { type: 'projection' }> =>
f.type === 'projection' && f.key === 'test/last-user',
)
expect(pushes).toEqual([
{ type: 'projection', sessionId: session.id, key: 'test/last-user', value: { text: 'm0' }, seq: 0 },
{ type: 'projection', sessionId: session.id, key: 'test/last-user', value: { text: 'm0' }, seq: 2 },
])
expect(frames.filter(
(f): f is Extract<SessionControlFrame, { type: 'projection' }> =>
f.type === 'projection' && f.key === 'sessionListMetadata',
)).toEqual([
{ type: 'projection', sessionId: session.id, key: 'sessionListMetadata', value: { blank: true, lastPromptAt: 100 }, seq: 0 },
{ type: 'projection', sessionId: session.id, key: 'sessionListMetadata', value: { blank: false, lastPromptAt: 100 }, seq: 1 },
{ type: 'projection', sessionId: session.id, key: 'sessionListMetadata', value: { blank: false, lastPromptAt: 300 }, seq: 2 },
])
// Frame seq aligns with the tail block's asOfSeq vocabulary (higher-seq-wins compatible).
const tail = await page(proxy, request({ sessionId: session.id, throughSeq: session.seq - 1 }))
if (!tail.ok) throw new Error('unreachable')
expect(tail.value.projections?.asOfSeq).toBe(pushes.at(-1)?.seq)
})
it('emits no projection frames when the composition has no registry', async () => {
const { ctx, session } = await harness(false)
const control = new SessionControlController(ctx)
const abort = new AbortController()
const iterator = control.control(abort.signal)[Symbol.asyncIterator]()
const baseline = await iterator.next()
const next = iterator.next()
seedMessages(session, 2)
await new Promise(resolve => setTimeout(resolve, 0))
abort.abort()
if (baseline.done) throw new Error('Control stream ended before its baseline')
expect(baseline.value.type).toBe('baseline')
await expect(next).resolves.toEqual({ done: true, value: undefined })
})
})
@@ -0,0 +1,124 @@
/**
* Session Controller rename delegation through the composed SessionTitleService. The
* agent factory is a structural stub whose createAgent forwards seed/meta into
* the real SessionStore, and whose resume never runs (every source here is
* already attached). Cold-session resolution is the shared `agentFor` path —
* remote-proxy-cold.spec.ts owns the resume evidence for every unary that rides
* it, rename included.
*/
import { describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import SessionStore from '@deepseek-ai/dsh-session'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import SessionTitleService from '@deepseek-ai/dsh-session-title'
import type { Session, SessionId } from '@deepseek-ai/dsh-session'
import { createSessionTestRemote } from './test-remote.ts'
const sid = (id: string): SessionId => id as SessionId
function request<P>(payload: P): P {
return payload
}
async function composed(withTitles = true): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
if (withTitles) {
await ctx.plugin(SessionTitleService, { fallbackMaxWords: 5, fallbackMaxBytes: 40, maxTitleBytes: 40 })
}
// Store-backed structural factory: create builds the session with the
// forwarded seed/meta (the store validates the balanced prefix) and
// registers an idle agent stub over it.
ctx.agents.setFactory({
createAgent: (ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle> => {
const session = ctx.sessions.create(options.sessionId, {
...options.seed === undefined ? {} : { seed: [...options.seed] },
...options.meta === undefined ? {} : { meta: options.meta },
})
const agent = { id: session.id, session, status: 'idle', ctx: ownerCtx } as Agent
ctx.agents.register(agent)
return Promise.resolve({ agent, dispose: () => Promise.resolve() })
},
resume: () => Promise.reject(new Error('resume must not run: every source is attached')),
})
return ctx
}
/** Register one live agent whose log holds `turns` completed turns. */
function liveAgent(ctx: Context, id: string, turns: number): Session {
const session = ctx.sessions.create(sid(id), { meta: { cwd: '/proj' } })
for (let turn = 1; turn <= turns; turn++) {
session.append('turn/start', { turn })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: `prompt ${String(turn)}` }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
session.append('turn/end', { turn, reason: { kind: 'completed' } })
}
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
return session
}
const remote = (ctx: Context) => createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
describe('sessions.rename', () => {
it('accepts through the composed title service: normalized user-source event, echoed seq', async () => {
const ctx = await composed()
const source = liveAgent(ctx, 'session-rename', 1)
const renamed = await remote(ctx).rename(request({ sessionId: source.id, title: ' new name ' }))
expect(renamed.ok).toBe(true)
if (!renamed.ok) return
expect(renamed.value.title).toBe('new name')
const event = source.events.findLast(item => item.type === 'session/title')
expect(event?.seq).toBe(renamed.value.seq)
expect(event?.data).toMatchObject({ title: 'new name', source: { kind: 'user' } })
})
it('maps only an empty-normalizing title to title-invalid, with a presentable message', async () => {
const ctx = await composed()
const source = liveAgent(ctx, 'session-rename-bad', 1)
// U+200B passes a client-side trim gate but normalizes to empty host-side.
const response = await remote(ctx).rename(request({ sessionId: source.id, title: ' ' }))
expect(response.ok).toBe(false)
if (!response.ok) {
expect(response.error).toMatchObject({
code: 'title-invalid',
details: { sessionId: source.id },
})
// The message renders verbatim in the rename dialog's alert.
expect(response.error.message).toBe('session title must contain visible characters')
}
})
it('maps a non-validation rename failure (stale session object) to internal, not title-invalid', async () => {
const ctx = await composed()
// The registered agent holds a session object from another store: the
// title service's liveness check throws a plain Error, which must not
// read as the user's fault.
const foreign = await composed(false)
const stale = liveAgent(foreign, 'session-rename-stale', 1)
ctx.agents.register({ id: stale.id, session: stale, status: 'idle', ctx } as Agent)
const response = await remote(ctx).rename(request({ sessionId: stale.id, title: 'name' }))
expect(response.ok).toBe(false)
if (!response.ok) expect(response.error.code).toBe('internal')
})
it('answers internal when the composition mounts no session-title service', async () => {
const ctx = await composed(false)
const source = liveAgent(ctx, 'session-no-titles', 1)
const response = await remote(ctx).rename(request({ sessionId: source.id, title: 'name' }))
expect(response.ok).toBe(false)
if (!response.ok) {
expect(response.error.code).toBe('internal')
expect(response.error.message).toMatch(/mounts no session-title service/)
}
})
})
@@ -0,0 +1,891 @@
/**
* Session Controller search projection: list-equivalent visibility, fixed message
* filters and result bound, cancellation mapping, and unavailable/failure
* behavior.
*/
import { describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { stat } from 'node:fs/promises'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import {
SessionQueryError,
type SessionSearchHit,
type SessionSearchRequest,
} from '@deepseek-ai/dsh-session-query'
import { createSessionTestRemote } from './test-remote.ts'
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs/promises')>()
return { ...actual, stat: vi.fn(actual.stat) }
})
const sid = (value: string): SessionId => value as SessionId
const defaults = { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }
function request(query: string): { query: string } {
return { query }
}
function header(id: string, cwd: string | null = '/project'): SessionHeader {
return {
version: 0,
id: sid(id),
createdAt: 100,
...(cwd === null ? {} : { cwd }),
}
}
function hit(id: string, index = 0): SessionSearchHit {
const session = header(id)
return {
header: session,
live: true,
persisted: false,
bestMatch: {
sessionId: session.id,
seq: index,
type: 'user/message',
time: 200 + index,
surface: 'current',
snippet: `match ${index}`,
},
}
}
async function baseContext(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
return ctx
}
describe('session.search', () => {
it('searches only list-visible ids and current conversation-message events', async () => {
const ctx = await baseContext()
const live = ctx.sessions.create(sid('live'), { meta: header('live', '/live') })
live.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'live text' }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
const cold = header('cold', '/cold')
const legacy = header('legacy', null)
ctx.provide('sessionPersistence', {
list: () => Promise.resolve([cold, legacy]),
locate: () => undefined,
} as never)
const searchSessions = vi.fn((
_request: SessionSearchRequest,
_exec?: { signal?: AbortSignal },
) => Promise.resolve({
items: [
{
header: legacy,
live: false,
persisted: true,
bestMatch: {
sessionId: legacy.id,
seq: 3,
type: 'user/message' as const,
time: 190,
surface: 'current' as const,
snippet: 'must remain hidden',
},
},
{
header: cold,
live: false,
persisted: true,
bestMatch: {
sessionId: cold.id,
seq: 4,
type: 'assistant/message' as const,
time: 200,
surface: 'current' as const,
snippet: 'the matching answer',
},
},
],
}))
ctx.provide('sessionQuery', { searchSessions } as never)
const remote = createSessionTestRemote(ctx, defaults)
const signal = new AbortController().signal
const response = await remote.search(request(' matching answer '), signal)
expect(response).toEqual({
ok: true,
value: {
items: [{ sessionId: 'cold', snippet: 'the matching answer' }],
hasMore: false,
},
})
expect(searchSessions).toHaveBeenCalledOnce()
const [query, exec] = searchSessions.mock.calls[0] as unknown as [
SessionSearchRequest,
{ signal: AbortSignal },
]
expect(query).toEqual({
query: 'matching answer',
eventFilters: [
{
kind: 'type',
values: ['user/message', 'assistant/message'],
},
{ kind: 'surface', values: ['current'] },
],
limit: 20,
})
expect(exec.signal).toBe(signal)
})
it('rejects invalid wire queries before invoking the search provider', async () => {
const ctx = await baseContext()
ctx.sessions.create(sid('visible'), { meta: header('visible') })
const searchSessions = vi.fn()
ctx.provide('sessionQuery', { searchSessions } as never)
const remote = createSessionTestRemote(ctx, defaults)
for (const query of ['', ' ', 'contains\0nul', 'x'.repeat(501)]) {
await expect(remote.search(request(query), new AbortController().signal))
.resolves.toMatchObject({ ok: false, error: { code: 'bad-request' } })
}
expect(searchSessions).not.toHaveBeenCalled()
await ctx.fiber.dispose()
})
it('returns an empty page without invoking the index when no session is visible', async () => {
const ctx = await baseContext()
const searchSessions = vi.fn()
ctx.provide('sessionQuery', { searchSessions } as never)
const remote = createSessionTestRemote(ctx, defaults)
const response = await remote.search(
request('anything'),
new AbortController().signal,
)
expect(response).toEqual({
ok: true,
value: { items: [], hasMore: false },
})
expect(searchSessions).not.toHaveBeenCalled()
})
it('rejects snippets whose recorded provider violates the Host filters', async () => {
const ctx = await baseContext()
const visible = hit('visible')
ctx.sessions.create(visible.header.id, { meta: visible.header })
const withBestMatch = (
index: number,
bestMatch: Partial<SessionSearchHit['bestMatch']>,
): SessionSearchHit => {
const base = hit('visible', index)
return { ...base, bestMatch: { ...base.bestMatch, ...bestMatch } }
}
ctx.provide('sessionQuery', {
searchSessions: () => Promise.resolve({
items: [
withBestMatch(0, { sessionId: sid('hidden') }),
withBestMatch(1, { surface: 'shadowed' }),
withBestMatch(2, { type: 'tool/result' }),
withBestMatch(3, { type: 'user/message', snippet: 'allowed snippet' }),
],
}),
} as never)
const response = await createSessionTestRemote(ctx, defaults).search(
request('match'),
new AbortController().signal,
)
expect(response).toEqual({
ok: true,
value: {
items: [{ sessionId: 'visible', snippet: 'allowed snippet' }],
hasMore: false,
},
})
})
it('pages the globally ranked stream until the 20-item Host boundary is known', async () => {
const ctx = await baseContext()
const items = Array.from({ length: 22 }, (_, index) => hit(`visible-${index}`, index))
for (const item of items) {
ctx.sessions.create(item.header.id, { meta: item.header })
}
const searchSessions = vi.fn()
.mockResolvedValueOnce({
items: [hit('hidden-ranked-first'), ...items.slice(0, 19)],
nextCursor: 'page-2',
})
.mockResolvedValueOnce({ items: items.slice(19) })
ctx.provide('sessionQuery', {
searchSessions,
} as never)
const response = await createSessionTestRemote(ctx, defaults).search(
request('match'),
new AbortController().signal,
)
expect(response).toMatchObject({
ok: true,
value: { hasMore: true },
})
if (!response.ok) throw new Error('unreachable')
expect(response.value.items).toHaveLength(20)
expect(response.value.items.at(-1)?.sessionId).toBe('visible-19')
expect(searchSessions).toHaveBeenCalledTimes(2)
expect(searchSessions.mock.calls[1]?.[0]).toMatchObject({ cursor: 'page-2' })
})
it('learns a provider maxLimit of 10 and collects the 20-item result plus lookahead', async () => {
const ctx = await baseContext()
const items = Array.from({ length: 21 }, (_, index) => hit(`visible-${index}`, index))
for (const item of items) {
ctx.sessions.create(item.header.id, { meta: item.header })
}
const invalidLimit = new SessionQueryError(
'provider accepts at most 10 items',
'SESSION_QUERY_INVALID_LIMIT',
)
const searchSessions = vi.fn((providerRequest: SessionSearchRequest) => {
const limit = providerRequest.limit
if (limit === undefined) throw new Error('Host search must request an explicit provider limit')
if (limit > 10) return Promise.reject(invalidLimit)
const offset = providerRequest.cursor === undefined
? 0
: Number.parseInt(providerRequest.cursor.slice('offset-'.length), 10)
const end = Math.min(items.length, offset + limit)
return Promise.resolve({
items: items.slice(offset, end),
...end < items.length ? { nextCursor: `offset-${end}` } : {},
})
})
ctx.provide('sessionQuery', { searchSessions } as never)
const response = await createSessionTestRemote(ctx, defaults).search(
request('adaptive-page-limit'),
new AbortController().signal,
)
expect(response).toMatchObject({
ok: true,
value: { hasMore: true },
})
if (!response.ok) throw new Error('unreachable')
expect(response.value.items.map(item => item.sessionId))
.toEqual(items.slice(0, 20).map(item => item.header.id))
expect(searchSessions.mock.calls.map(([providerRequest]) => ({
limit: providerRequest.limit,
cursor: providerRequest.cursor,
}))).toEqual([
{ limit: 20, cursor: undefined },
{ limit: 10, cursor: undefined },
{ limit: 10, cursor: 'offset-10' },
{ limit: 10, cursor: 'offset-20' },
])
})
it('counts a page-limit probe inside the 100-call budget', async () => {
const ctx = await baseContext()
ctx.sessions.create(sid('visible'), { meta: header('visible') })
const invalidLimit = new SessionQueryError(
'provider accepts at most 10 items',
'SESSION_QUERY_INVALID_LIMIT',
)
const searchSessions = vi.fn((providerRequest: SessionSearchRequest) => {
if (searchSessions.mock.calls.length === 1) {
expect(providerRequest).toMatchObject({ limit: 20 })
return Promise.reject(invalidLimit)
}
expect(providerRequest.limit).toBe(10)
return Promise.resolve({
items: [],
nextCursor: `page-${searchSessions.mock.calls.length}`,
})
})
ctx.provide('sessionQuery', { searchSessions } as never)
const response = await createSessionTestRemote(ctx, defaults).search(
request('endless-pages'),
new AbortController().signal,
)
expect(response.ok).toBe(false)
if (response.ok) throw new Error('unreachable')
expect(response.error).toMatchObject({ code: 'internal' })
expect(response.error.message).toContain('100-call work budget')
expect(searchSessions).toHaveBeenCalledTimes(100)
})
it('restarts a stale continuation with its learned limit and original visibility snapshot', async () => {
const ctx = await baseContext()
const oldOnly = hit('old-only', 0)
const shared = hit('shared', 1)
const freshFirst = hit('fresh-first', 2)
const freshLast = hit('fresh-last', 3)
for (const item of [oldOnly, shared, freshFirst, freshLast]) {
ctx.sessions.create(item.header.id, { meta: item.header })
}
const late = hit('late-visible', 4)
const stale = new SessionQueryError(
'provider generation changed',
'SESSION_QUERY_STALE_CURSOR',
)
const invalidLimit = new SessionQueryError(
'provider accepts at most 10 items',
'SESSION_QUERY_INVALID_LIMIT',
)
const searchSessions = vi.fn((providerRequest: SessionSearchRequest) => {
switch (searchSessions.mock.calls.length) {
case 1:
expect(providerRequest).toMatchObject({ limit: 20 })
expect(providerRequest).not.toHaveProperty('cursor')
return Promise.reject(invalidLimit)
case 2:
expect(providerRequest).toMatchObject({ limit: 10 })
expect(providerRequest).not.toHaveProperty('cursor')
return Promise.resolve({
items: [oldOnly, shared],
nextCursor: 'old-cursor',
})
case 3:
expect(providerRequest).toMatchObject({ limit: 10 })
expect(providerRequest.cursor).toBe('old-cursor')
ctx.sessions.create(late.header.id, { meta: late.header })
return Promise.reject(stale)
case 4:
expect(providerRequest).toMatchObject({ limit: 10 })
expect(providerRequest).not.toHaveProperty('cursor')
return Promise.resolve({
items: [freshFirst, shared],
nextCursor: 'old-cursor',
})
case 5:
expect(providerRequest).toMatchObject({ limit: 10 })
expect(providerRequest.cursor).toBe('old-cursor')
return Promise.resolve({ items: [freshLast, late] })
default:
return Promise.reject(new Error('unexpected provider call'))
}
})
ctx.provide('sessionQuery', { searchSessions } as never)
const response = await createSessionTestRemote(ctx, defaults).search(
request('stale-restart'),
new AbortController().signal,
)
expect(response).toEqual({
ok: true,
value: {
items: [
{ sessionId: 'fresh-first', snippet: 'match 2' },
{ sessionId: 'shared', snippet: 'match 1' },
{ sessionId: 'fresh-last', snippet: 'match 3' },
],
hasMore: false,
},
})
expect(searchSessions).toHaveBeenCalledTimes(5)
})
it('counts continuous stale restarts against the 100-call budget', async () => {
const ctx = await baseContext()
const partial = hit('partial')
ctx.sessions.create(partial.header.id, { meta: partial.header })
const stale = new SessionQueryError(
'provider generation changed',
'SESSION_QUERY_STALE_CURSOR',
)
const searchSessions = vi.fn((providerRequest: SessionSearchRequest) => {
if (searchSessions.mock.calls.length > 100) {
return Promise.reject(new Error('provider was called after the shared budget'))
}
if (providerRequest.cursor !== undefined) return Promise.reject(stale)
return Promise.resolve({
items: [partial],
nextCursor: `cursor-${searchSessions.mock.calls.length}`,
})
})
ctx.provide('sessionQuery', { searchSessions } as never)
const response = await createSessionTestRemote(ctx, defaults).search(
request('stale-churn'),
new AbortController().signal,
)
expect(response.ok).toBe(false)
if (response.ok) throw new Error('unreachable')
expect(response.error.code).toBe('internal')
expect(response.error.message).toContain('100-call work budget')
expect(response).not.toHaveProperty('value')
expect(searchSessions).toHaveBeenCalledTimes(100)
})
it('gives abort priority over a coincident stale continuation failure', async () => {
const ctx = await baseContext()
ctx.sessions.create(sid('visible'), { meta: header('visible') })
const controller = new AbortController()
const stale = new SessionQueryError(
'provider generation changed',
'SESSION_QUERY_STALE_CURSOR',
)
const searchSessions = vi.fn()
.mockResolvedValueOnce({ items: [], nextCursor: 'stale-cursor' })
.mockImplementationOnce(() => {
controller.abort()
return Promise.reject(stale)
})
ctx.provide('sessionQuery', { searchSessions } as never)
const response = await createSessionTestRemote(ctx, defaults).search(
request('abort-stale'),
controller.signal,
)
expect(response).toMatchObject({
ok: false,
error: { code: 'cancelled' },
})
expect(searchSessions).toHaveBeenCalledTimes(2)
})
it('does not retry a stale first-page failure', async () => {
const ctx = await baseContext()
ctx.sessions.create(sid('visible'), { meta: header('visible') })
const searchSessions = vi.fn(() => Promise.reject(new SessionQueryError(
'provider generation changed before paging',
'SESSION_QUERY_STALE_CURSOR',
)))
ctx.provide('sessionQuery', { searchSessions } as never)
const response = await createSessionTestRemote(ctx, defaults).search(
request('first-page-stale'),
new AbortController().signal,
)
expect(response).toMatchObject({
ok: false,
error: { code: 'internal' },
})
expect(response).not.toHaveProperty('value')
expect(searchSessions).toHaveBeenCalledOnce()
})
it('does not adapt an invalid-limit continuation failure', async () => {
const ctx = await baseContext()
ctx.sessions.create(sid('visible'), { meta: header('visible') })
const searchSessions = vi.fn()
.mockResolvedValueOnce({ items: [], nextCursor: 'page-2' })
.mockRejectedValueOnce(new SessionQueryError(
'continuation limit is invalid',
'SESSION_QUERY_INVALID_LIMIT',
))
ctx.provide('sessionQuery', { searchSessions } as never)
const response = await createSessionTestRemote(ctx, defaults).search(
request('continuation-invalid-limit'),
new AbortController().signal,
)
expect(response).toMatchObject({
ok: false,
error: { code: 'internal' },
})
expect(searchSessions).toHaveBeenCalledTimes(2)
expect(searchSessions.mock.calls.map(([providerRequest]) => (
providerRequest as SessionSearchRequest
).limit))
.toEqual([20, 20])
})
it('stops page-limit adaptation at one item', async () => {
const ctx = await baseContext()
ctx.sessions.create(sid('visible'), { meta: header('visible') })
const searchSessions = vi.fn((providerRequest: SessionSearchRequest) => Promise.reject(
new SessionQueryError(
`provider rejects ${providerRequest.limit}`,
'SESSION_QUERY_INVALID_LIMIT',
),
))
ctx.provide('sessionQuery', { searchSessions } as never)
const response = await createSessionTestRemote(ctx, defaults).search(
request('minimum-page-limit'),
new AbortController().signal,
)
expect(response).toMatchObject({
ok: false,
error: { code: 'internal' },
})
expect(searchSessions.mock.calls.map(([providerRequest]) => providerRequest.limit))
.toEqual([20, 10, 5, 2, 1])
})
it('gives abort priority over a coincident invalid first-page limit', async () => {
const ctx = await baseContext()
ctx.sessions.create(sid('visible'), { meta: header('visible') })
const controller = new AbortController()
const searchSessions = vi.fn(() => {
controller.abort()
return Promise.reject(new SessionQueryError(
'provider rejects 20',
'SESSION_QUERY_INVALID_LIMIT',
))
})
ctx.provide('sessionQuery', { searchSessions } as never)
const response = await createSessionTestRemote(ctx, defaults).search(
request('abort-invalid-limit'),
controller.signal,
)
expect(response).toMatchObject({
ok: false,
error: { code: 'cancelled' },
})
expect(searchSessions).toHaveBeenCalledOnce()
})
it('rejects an oversized provider page', async () => {
const ctx = await baseContext()
ctx.sessions.create(sid('visible'), { meta: header('visible') })
const oversized = Array.from({ length: 21 }, (_, index) => hit(`oversized-${index}`))
const searchSessions = vi.fn(() => Promise.resolve({ items: oversized }))
ctx.provide('sessionQuery', { searchSessions } as never)
const response = await createSessionTestRemote(ctx, defaults).search(
request('oversized-page'),
new AbortController().signal,
)
expect(response.ok).toBe(false)
if (response.ok) throw new Error('unreachable')
expect(response.error).toMatchObject({ code: 'internal' })
expect(response.error.message).toContain('returned 21 items; maximum is 20')
})
it('uses the learned provider limit for the overproduction guard', async () => {
const ctx = await baseContext()
ctx.sessions.create(sid('visible'), { meta: header('visible') })
const oversized = Array.from({ length: 11 }, (_, index) => hit(`oversized-${index}`))
const searchSessions = vi.fn((providerRequest: SessionSearchRequest) => {
if (providerRequest.limit === 20) {
return Promise.reject(new SessionQueryError(
'provider accepts at most 10 items',
'SESSION_QUERY_INVALID_LIMIT',
))
}
return Promise.resolve({ items: oversized })
})
ctx.provide('sessionQuery', { searchSessions } as never)
const response = await createSessionTestRemote(ctx, defaults).search(
request('adapted-oversized-page'),
new AbortController().signal,
)
expect(response.ok).toBe(false)
if (response.ok) throw new Error('unreachable')
expect(response.error).toMatchObject({ code: 'internal' })
expect(response.error.message).toContain('returned 11 items; maximum is 10')
expect(searchSessions).toHaveBeenCalledTimes(2)
})
it('bounds provider snippets to 240 Unicode code points without splitting astral text', async () => {
const ctx = await baseContext()
const visible = hit('visible')
ctx.sessions.create(visible.header.id, { meta: visible.header })
const expected = `${'x'.repeat(239)}😀`
const overlong = {
...visible,
bestMatch: {
...visible.bestMatch,
snippet: `${expected}${'y'.repeat(10_000)}`,
},
}
ctx.provide('sessionQuery', {
searchSessions: () => Promise.resolve({ items: [overlong] }),
} as never)
const response = await createSessionTestRemote(ctx, defaults).search(
request('bounded-snippet'),
new AbortController().signal,
)
expect(response).toEqual({
ok: true,
value: {
items: [{ sessionId: 'visible', snippet: expected }],
hasMore: false,
},
})
})
it('fails closed when the provider repeats a continuation cursor', async () => {
const ctx = await baseContext()
ctx.sessions.create(sid('visible'), { meta: header('visible') })
const searchSessions = vi.fn()
.mockResolvedValueOnce({ items: [], nextCursor: 'repeated' })
.mockResolvedValueOnce({ items: [], nextCursor: 'repeated' })
ctx.provide('sessionQuery', { searchSessions } as never)
const response = await createSessionTestRemote(ctx, defaults).search(
request('repeated-cursor'),
new AbortController().signal,
)
expect(response.ok).toBe(false)
if (response.ok) throw new Error('unreachable')
expect(response.error).toMatchObject({ code: 'internal' })
expect(response.error.message).toContain('repeated a continuation cursor')
expect(searchSessions).toHaveBeenCalledTimes(2)
})
it('validates a repeated cursor before accepting the authorized lookahead', async () => {
const ctx = await baseContext()
const items = Array.from({ length: 21 }, (_, index) => hit(`visible-${index}`, index))
for (const item of items) {
ctx.sessions.create(item.header.id, { meta: item.header })
}
const searchSessions = vi.fn()
.mockResolvedValueOnce({ items: items.slice(0, 20), nextCursor: 'repeated' })
.mockResolvedValueOnce({ items: items.slice(20), nextCursor: 'repeated' })
ctx.provide('sessionQuery', { searchSessions } as never)
const response = await createSessionTestRemote(ctx, defaults).search(
request('repeated-lookahead-cursor'),
new AbortController().signal,
)
expect(response).toMatchObject({
ok: false,
error: { code: 'internal' },
})
expect(response).not.toHaveProperty('value')
if (response.ok) throw new Error('unreachable')
expect(response.error.message).toContain('repeated a continuation cursor')
expect(searchSessions).toHaveBeenCalledTimes(2)
})
it('does not count duplicate session ids toward the result or lookahead boundary', async () => {
const ctx = await baseContext()
const items = Array.from({ length: 21 }, (_, index) => hit(`visible-${index}`, index))
for (const item of items) {
ctx.sessions.create(item.header.id, { meta: item.header })
}
const searchSessions = vi.fn()
.mockResolvedValueOnce({ items: items.slice(0, 20), nextCursor: 'page-2' })
.mockResolvedValueOnce({ items: items.slice(0, 20), nextCursor: 'page-3' })
.mockResolvedValueOnce({ items: items.slice(20) })
ctx.provide('sessionQuery', { searchSessions } as never)
const response = await createSessionTestRemote(ctx, defaults).search(
request('duplicate-pages'),
new AbortController().signal,
)
expect(response).toMatchObject({
ok: true,
value: { hasMore: true },
})
if (!response.ok) throw new Error('unreachable')
expect(response.value.items.map(item => item.sessionId)).toEqual(
items.slice(0, 20).map(item => item.header.id),
)
expect(searchSessions).toHaveBeenCalledTimes(3)
})
it('cancels on a continuation page and passes the carrier signal to both calls', async () => {
const ctx = await baseContext()
ctx.sessions.create(sid('visible'), { meta: header('visible') })
const controller = new AbortController()
const searchSessions = vi.fn()
.mockResolvedValueOnce({ items: [], nextCursor: 'page-2' })
.mockImplementationOnce(() => {
controller.abort()
return Promise.resolve({ items: [] })
})
ctx.provide('sessionQuery', { searchSessions } as never)
const response = await createSessionTestRemote(ctx, defaults).search(
request('cancel-continuation'),
controller.signal,
)
expect(response).toMatchObject({
ok: false,
error: { code: 'cancelled' },
})
expect(searchSessions).toHaveBeenCalledTimes(2)
for (const call of searchSessions.mock.calls) {
expect(call[1]).toEqual({ signal: controller.signal })
}
})
it('keeps visibility sets above SQLite variable limits out of provider bindings', async () => {
const ctx = await baseContext()
const cold = Array.from(
{ length: 32_751 },
(_, index) => header(`cold-${index}`, `/cold-${index}`),
)
ctx.provide('sessionPersistence', {
list: () => Promise.resolve(cold),
locate: () => undefined,
} as never)
const searchSessions = vi.fn((_request: SessionSearchRequest) => Promise.resolve({
items: [hit('cold-32750')],
}))
ctx.provide('sessionQuery', { searchSessions } as never)
const response = await createSessionTestRemote(ctx, defaults).search(
request('large corpus'),
new AbortController().signal,
)
expect(response).toEqual({
ok: true,
value: {
items: [{ sessionId: 'cold-32750', snippet: 'match 0' }],
hasMore: false,
},
})
expect(searchSessions).toHaveBeenCalledOnce()
expect(searchSessions.mock.calls[0]?.[0]).not.toHaveProperty('sessionFilters')
})
it('propagates cancellation through visible-session collection and stops cold-summary work', async () => {
const ctx = await baseContext()
const controller = new AbortController()
const cold = Array.from({ length: 32 }, (_, index) => header(`cold-${index}`, `/cold-${index}`))
const list = vi.fn((signal?: AbortSignal) => {
expect(signal).toBe(controller.signal)
return Promise.resolve(cold)
})
let locateCalls = 0
ctx.provide('sessionPersistence', {
list,
locate: () => {
locateCalls++
controller.abort()
return undefined
},
} as never)
const searchSessions = vi.fn()
ctx.provide('sessionQuery', { searchSessions } as never)
const response = await createSessionTestRemote(ctx, defaults).search(
request('cancel-during-visibility'),
controller.signal,
)
expect(response).toMatchObject({
ok: false,
error: { code: 'cancelled' },
})
expect(list).toHaveBeenCalledOnce()
expect(locateCalls).toBe(1)
expect(searchSessions).not.toHaveBeenCalled()
})
it('awaits every started cold-summary stat before returning cancellation', async () => {
const ctx = await baseContext()
const controller = new AbortController()
const cold = Array.from({ length: 16 }, (_, index) => header(`cold-${index}`, `/cold-${index}`))
const statGates = cold.map(() => Promise.withResolvers<{ mtimeMs: number }>())
const statMock = vi.mocked(stat)
statMock.mockClear()
for (const gate of statGates) {
statMock.mockImplementationOnce((() => gate.promise) as never)
}
ctx.provide('sessionPersistence', {
list: () => Promise.resolve(cold),
locate: (meta: SessionHeader) => ({ kind: 'jsonl', path: `/logs/${meta.id}.jsonl` }),
} as never)
const searchSessions = vi.fn()
ctx.provide('sessionQuery', { searchSessions } as never)
let settled = false
const responsePromise = createSessionTestRemote(ctx, defaults).search(
request('cancel-during-cold-stats'),
controller.signal,
).finally(() => {
settled = true
})
await vi.waitFor(() => {
expect(statMock).toHaveBeenCalledTimes(16)
})
controller.abort()
statGates[0]!.resolve({ mtimeMs: 101 })
await new Promise<void>(resolve => setImmediate(resolve))
expect(settled).toBe(false)
for (const gate of statGates.slice(1)) gate.resolve({ mtimeMs: 102 })
const response = await responsePromise
expect(response).toMatchObject({
ok: false,
error: { code: 'cancelled' },
})
expect(searchSessions).not.toHaveBeenCalled()
})
it('maps missing composition, query cancellation, and provider failure', async () => {
const missingCtx = await baseContext()
missingCtx.sessions.create(sid('visible'), { meta: header('visible') })
const missingApi = createSessionTestRemote(missingCtx, defaults)
const preAborted = new AbortController()
preAborted.abort()
const cancelledBeforeLookup = await missingApi.search(
request('cancel-before-lookup'),
preAborted.signal,
)
expect(cancelledBeforeLookup).toMatchObject({
ok: false,
error: { code: 'cancelled' },
})
const missing = await missingApi.search(
request('needle'),
new AbortController().signal,
)
expect(missing.ok).toBe(false)
if (missing.ok) throw new Error('unreachable')
expect(missing.error.code).toBe('internal')
expect(missing.error.message).toContain('does not mount')
const ctx = await baseContext()
ctx.sessions.create(sid('visible'), { meta: header('visible') })
const aborted = new SessionQueryError('provider stopped', 'SESSION_QUERY_ABORTED')
const searchSessions = vi.fn()
.mockRejectedValueOnce(aborted)
.mockRejectedValueOnce(new Error('database unavailable'))
ctx.provide('sessionQuery', { searchSessions } as never)
const remote = createSessionTestRemote(ctx, defaults)
const cancelled = await remote.search(
request('first'),
new AbortController().signal,
)
expect(cancelled).toMatchObject({
ok: false,
error: { code: 'cancelled' },
})
const failed = await remote.search(
request('second'),
new AbortController().signal,
)
expect(failed.ok).toBe(false)
if (failed.ok) throw new Error('unreachable')
expect(failed.error.code).toBe('internal')
expect(failed.error.message).toContain('database unavailable')
})
})
@@ -0,0 +1,729 @@
/** Session object lifecycle, event-window transport, commands, and resync behavior. */
import { afterEach, describe, expect, it, vi } from 'vitest'
import { RemoteStreamError } from '@deepseek-ai/dsh-api-gateway/client'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
import type { SessionToolView } from '@deepseek-ai/dsh-api-session-controller/types'
import { Session, type SessionOptions } from '../src/client/sessions/session.ts'
import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.client.ts'
import { entries, ev, plainTurn } from './event-script.client.ts'
const SID = 'fk-s1' as SessionId
const PARENT = 'fk-parent' as SessionId
afterEach(() => {
vi.unstubAllGlobals()
})
function makeSession(
api = new FakeApiClient(),
options: SessionOptions = {},
): { api: FakeApiClient; session: Session } {
return { api, session: new Session(SID, api, fakeRemote(api), options) }
}
function follow(
api: FakeApiClient,
event: SessionEvent,
view?: SessionToolView,
): Promise<void> {
return api.pushFollow(SID, {
type: 'event',
event: event as never,
...(view === undefined ? {} : { view }),
})
}
function windowEntries(session: Session) {
return session.eventSource.getSnapshot().entries
}
function eventSeqs(session: Session): number[] {
return windowEntries(session).map(entry => entry.event.seq)
}
function histResponse(events: SessionEvent[], hasMore = false) {
// history returns HistoryEntry[] ({event, view?}); these tests are view-less.
return Promise.resolve(ok({ events: entries(events) as never[], hasMore }))
}
describe('Session open', () => {
it('keeps a bare Session blank until an authoritative lifecycle signal arrives', () => {
const { session } = makeSession()
expect(session.getSnapshot()).toMatchObject({ blank: true, promptAttempted: false, running: false })
session.handleRunning(true)
expect(session.getSnapshot()).toMatchObject({ blank: false, running: true })
})
it('installs the tail page: cold → loading → open with window and nodes in place', async () => {
const { api, session } = makeSession()
const page = plainTurn(10, 3, '问', '答')
api.onHistory = () => histResponse(page, true)
expect(session.getSnapshot().openState).toBe('cold')
const opening = session.open()
expect(session.getSnapshot().openState).toBe('loading')
await opening
const snapshot = session.getSnapshot()
expect(snapshot.openState).toBe('open')
expect(snapshot.hasMore).toBe(true)
expect(eventSeqs(session)).toEqual([10, 11, 12, 13, 14, 15])
expect(session.eventSource.getSnapshot().change).toMatchObject({ kind: 'replace' })
})
it('is idempotent: concurrent opens share one history call, reopening when open is a no-op', async () => {
const { api, session } = makeSession()
await Promise.all([session.open(), session.open()])
await session.open()
expect(api.callsOf('session.history')).toHaveLength(1)
})
it('lands an error result in openState=error with the RpcError kept', async () => {
const { api, session } = makeSession()
api.onHistory = () => Promise.resolve(err({ code: 'session-not-found', message: 'gone', details: { sessionId: SID } }))
await session.open()
const snapshot = session.getSnapshot()
expect(snapshot.openState).toBe('error')
expect(snapshot.openError?.code).toBe('session-not-found')
})
it('folds a transport throw into openState=error / internal', async () => {
const { api, session } = makeSession()
api.onHistory = () => Promise.reject(new Error('socket died'))
await session.open()
expect(session.getSnapshot().openState).toBe('error')
expect(session.getSnapshot().openError).toMatchObject({ code: 'internal', message: 'socket died' })
})
it('stitches live frames arriving while history is pending, dropping the page overlap', async () => {
const { api, session } = makeSession()
const gate = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
api.onHistory = () => gate.promise
const opening = session.open()
// Three live frames land mid-open; seq 15 overlaps the page tail (page covers 10..15).
const page = plainTurn(10, 0, '早', '安')
const deliveries = [
follow(api, ev.turnStart(15, 1)),
follow(api, ev.user(16, '插进来的')),
]
gate.resolve(ok({
events: entries(page) as never[],
hasMore: false,
modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
}))
await Promise.all([opening, ...deliveries])
const seqs = eventSeqs(session)
// Overlapping seq-15 frame (== page tail turn/end) was dropped; 16 appended once.
expect(seqs).toEqual([10, 11, 12, 13, 14, 15, 16])
})
})
describe('live event path', () => {
async function opened(events: SessionEvent[] = plainTurn(0, 0, 'a', 'b')) {
const { api, session } = makeSession()
api.onHistory = () => histResponse(events)
await session.open()
return { api, session }
}
it('drops replayed frames at or below the window tail', async () => {
const { api, session } = await opened()
const before = session.eventSource.getSnapshot()
await follow(api, ev.user(3, '重放'))
expect(session.eventSource.getSnapshot()).toBe(before)
})
it('keeps the authoritative host blank bit across unrelated log events', async () => {
const { api, session } = await opened([])
session.handleBlank(true)
await Promise.all([
follow(api, ev.commandRun(0, 'cmd-perm', 'permission', ' danger-full-access')),
follow(api, ev.commandDone(1, 'cmd-perm', 'success', 'preset danger-full-access')),
])
const snapshot = session.getSnapshot()
expect(eventSeqs(session)).toEqual([0, 1])
expect(snapshot.blank).toBe(true)
})
it('repairs a seq gap by repulling the tail page instead of appending a hole', async () => {
const { api, session } = await opened(plainTurn(0, 0, 'a', 'b')) // tail seq = 5
const repaired = [...plainTurn(0, 0, 'a', 'b'), ...plainTurn(6, 1, 'c', 'd')]
api.onHistory = () => histResponse(repaired)
// seq 9 with tail 5 → gap; the event detours to the buffer and one history refetch fires.
await follow(api, ev.assistant(9, 1, 'd'))
await vi.waitFor(() => {
expect(api.callsOf('session.history').length).toBe(2)
})
await vi.waitFor(() => {
expect(eventSeqs(session)).toEqual(
repaired.filter(event => event.seq <= 9).map(event => event.seq),
)
})
})
})
describe('paging', () => {
it('prepends an older page and keeps seq continuity', async () => {
const older = plainTurn(0, 0, '旧问', '旧答')
const newer = plainTurn(6, 1, '新问', '新答')
const { api, session } = makeSession()
api.onHistory = payload => payload.beforeSeq === undefined
? histResponse(newer, true)
: histResponse(older, false)
await session.open()
await session.loadOlder()
const snapshot = session.getSnapshot()
expect(api.callsOf('session.history')).toMatchObject([
{ sessionId: SID, throughSeq: 11 },
{ sessionId: SID, throughSeq: 11, beforeSeq: 6 },
])
expect(snapshot.hasMore).toBe(false)
expect(eventSeqs(session)).toEqual([...older, ...newer].map(event => event.seq))
})
it('installs a page without interpreting business replacement metadata', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse([
ev.compactSummary(80, '窗外范围的摘要', 3, 40),
ev.compactCheckpoint(81, 80, 3, 40),
ev.user(82, '压缩后的新问题'),
], true)
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
try {
await session.open()
const snapshot = session.getSnapshot()
expect(snapshot.openState).toBe('open')
expect(eventSeqs(session)).toEqual([80, 81, 82])
expect(errorSpy).not.toHaveBeenCalled()
} finally {
errorSpy.mockRestore()
}
})
it('drops a discontinuous older page fail-soft (window unchanged, hasMore cleared)', async () => {
const { api, session } = makeSession()
api.onHistory = payload => payload.beforeSeq === undefined
? histResponse(plainTurn(10, 1, '新', '页'), true)
: histResponse(plainTurn(0, 0, '断', '层'), true) // tail seq 5, but baseSeq is 10 → hole
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
try {
await session.open()
const windowBefore = session.eventSource.getSnapshot()
await session.loadOlder()
const snapshot = session.getSnapshot()
expect(session.eventSource.getSnapshot().entries).toEqual(windowBefore.entries)
expect(snapshot.hasMore).toBe(false)
} finally {
errorSpy.mockRestore()
}
})
it('ignores loadOlder while one is in flight (single request)', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(6, 1, 'x', 'y'), true)
await session.open()
const gate = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
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,
modelSelection: { provider: 'deepseek-official', 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('routes an addressed child through non-activating history, continuation prompt, and interrupt only', async () => {
const api = new FakeApiClient()
const session = new Session(SID, api, fakeRemote(api), {
address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' },
parentAvailable: true,
})
await session.open()
const prompted = await session.prompt([{ type: 'text', text: '继续' }], 'queue')
const cancelled = await session.cancel()
expect(prompted).toEqual({ ok: true, value: { accepted: true } })
expect(cancelled).toEqual({ ok: true, value: { accepted: true } })
expect(api.callsOf('subagent.history')).toEqual([
{ parentSessionId: PARENT, childSessionId: SID, mode: 'continuable', throughSeq: -1, maxMessages: 50 },
])
expect(api.callsOf('subagent.prompt')).toEqual([
{
parentSessionId: PARENT, childSessionId: SID, mode: 'continuable',
content: [{ type: 'text', text: '继续' }],
clientTimeZone: new Intl.DateTimeFormat().resolvedOptions().timeZone,
},
])
expect(api.callsOf('subagent.interrupt')).toEqual([
{ parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' },
])
expect(api.callsOf('session.history')).toEqual([])
expect(api.callsOf('session.prompt')).toEqual([])
expect(api.callsOf('session.cancel')).toEqual([])
// A successful interrupt leaves no stop error behind.
expect(session.getSnapshot().promptError).toBeNull()
expect(session.getSnapshot().subagent).toEqual({
address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' },
parentAvailable: true,
})
})
it('lands an interrupt business failure in promptError with op=stop', async () => {
const api = new FakeApiClient()
api.onSubagentInterrupt = () => Promise.resolve(err({
code: 'subagent-unauthorized', message: 'nope', details: { childSessionId: SID },
}) as never)
const session = new Session(SID, api, fakeRemote(api), {
address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' },
parentAvailable: true,
})
await session.open()
const cancelled = await session.cancel()
expect(cancelled).toMatchObject({ ok: false, error: { code: 'subagent-unauthorized' } })
expect(session.getSnapshot().promptError).toMatchObject({
op: 'stop', error: { code: 'subagent-unauthorized' },
})
})
it('keeps one-shot history readable without exposing prompt or cancel transport', async () => {
const api = new FakeApiClient()
const session = new Session(SID, api, fakeRemote(api), {
address: { parentSessionId: PARENT, childSessionId: SID, mode: 'one-shot' },
})
await session.open()
const prompted = await session.prompt([{ type: 'text', text: '继续' }], 'queue')
const cancelled = await session.cancel()
expect(prompted).toMatchObject({ ok: false, error: { code: 'subagent-not-resumable' } })
expect(cancelled).toMatchObject({ ok: false, error: { code: 'subagent-delivery-unavailable' } })
expect(api.callsOf('subagent.history')).toEqual([
{ parentSessionId: PARENT, childSessionId: SID, mode: 'one-shot', throughSeq: -1, maxMessages: 50 },
])
expect(api.callsOf('subagent.prompt')).toEqual([])
expect(api.callsOf('subagent.interrupt')).toEqual([])
expect(api.callsOf('session.cancel')).toEqual([])
})
it('publishes the first-prompt lifecycle synchronously before the Remote settles', async () => {
const { api, session } = makeSession()
session.handleBlank(true)
expect(session.getSnapshot()).toMatchObject({
blank: true, promptAttempted: false, awaitingFirstTurn: false,
})
const inFlight = session.prompt([{ type: 'text', text: '要发的' }], 'queue')
expect(session.getSnapshot()).toMatchObject({
blank: true, promptAttempted: true, awaitingFirstTurn: true,
})
const result = await inFlight
expect(result.ok).toBe(true)
expect(session.getSnapshot()).toMatchObject({
blank: false, promptAttempted: true, awaitingFirstTurn: true,
})
expect(api.callsOf('session.prompt')).toMatchObject([{
sessionId: SID,
mode: 'queue',
content: [{ type: 'text', text: '要发的' }],
clientTimeZone: new Intl.DateTimeFormat().resolvedOptions().timeZone,
}])
session.handleRunning(true)
expect(session.getSnapshot()).toMatchObject({ running: true, awaitingFirstTurn: false })
})
it('keeps the attempted-first-prompt state when the Host rejects the prompt', async () => {
const { api, session } = makeSession()
session.handleBlank(true)
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' } })
expect(session.getSnapshot()).toMatchObject({
blank: true, promptAttempted: true, awaitingFirstTurn: true,
})
})
it('lands cancel failures in promptError with op=stop', async () => {
const { api, session } = makeSession()
api.onCancel = () => Promise.reject(new Error('cancel transport down'))
const result = await session.cancel()
expect(result.ok).toBe(false)
expect(session.getSnapshot().promptError).toMatchObject({ op: 'stop', error: { code: 'internal' } })
})
it('reads session-authorized attachment bytes and keeps the opaque id on the wire', async () => {
const { api, session } = makeSession()
const result = await session.readAttachment('attachment-1' as never)
expect(result).toEqual({
ok: true,
value: {
attachment: { attachmentId: 'a', mediaType: 'image/png', bytes: 1, width: 1, height: 1 },
data: Uint8Array.of(0),
},
})
expect(api.callsOf('session.attachment')).toEqual([{
sessionId: SID, attachmentId: 'attachment-1',
}])
})
})
describe('rename', () => {
it('settles the title projection cell from the unary response (higher-seq-wins vs the push frame)', async () => {
const { api, session } = makeSession()
api.onRename = () => Promise.resolve(ok({ title: '正名', seq: 7 }))
const result = await session.rename(' 正名 ')
expect(result).toMatchObject({ ok: true, value: { title: '正名', seq: 7 } })
expect(api.callsOf('session.rename')).toMatchObject([{ sessionId: SID, title: ' 正名 ' }])
expect(session.projections.faceOf('title').getSnapshot()).toBe('正名')
// A stale lower-seq apply (the push-frame path routes into this same
// store) must not roll the settled value back.
session.projections.apply('title', '旧名', 3)
expect(session.projections.faceOf('title').getSnapshot()).toBe('正名')
})
it('returns the business error untouched and folds a transport throw to internal', async () => {
const { api, session } = makeSession()
api.onRename = () => Promise.resolve(err({
code: 'title-invalid', message: 'empty', details: { sessionId: SID },
} as never))
const rejected = await session.rename(' ')
expect(rejected).toMatchObject({ ok: false, error: { code: 'title-invalid' } })
expect(session.projections.faceOf('title').getSnapshot()).toBeUndefined()
api.onRename = () => Promise.reject(new Error('rename transport down'))
const folded = await session.rename('x')
expect(folded).toMatchObject({ ok: false, error: { code: 'internal' } })
})
})
describe('remaining branches', () => {
it('prompt transport throw folds to internal promptError', async () => {
const { api, session } = makeSession()
api.onPrompt = () => Promise.reject(new Error('prompt wire down'))
const result = await session.prompt([{ type: 'text', text: 'x' }], 'queue')
expect(result.ok).toBe(false)
expect(session.getSnapshot().promptError).toMatchObject({ op: 'send', error: { code: 'internal', message: 'prompt wire down' } })
})
it('cancel business error also lands op=stop promptError', async () => {
const { api, session } = makeSession()
api.onCancel = () => Promise.resolve(err({ code: 'agent-busy', message: 'nope', details: { reason: 'r' } }))
await session.cancel()
expect(session.getSnapshot().promptError).toMatchObject({ op: 'stop', error: { code: 'agent-busy' } })
})
it('loadOlder guards: not-open/no-hasMore no-op, err result kept window, empty page updates hasMore, throw fail-soft', async () => {
const { api, session } = makeSession()
await session.loadOlder() // cold: no-op, zero calls
expect(api.calls).toEqual([])
api.onHistory = () => histResponse(plainTurn(6, 1, 'x', 'y'), true)
await session.open()
// err result: window unchanged
api.onHistory = () => Promise.resolve(err({ code: 'internal', message: 'x', details: {} }))
await session.loadOlder()
expect(eventSeqs(session)).toHaveLength(6)
expect(session.getSnapshot().hasMore).toBe(true)
// empty page: hasMore adopts the response
api.onHistory = () => histResponse([], false)
await session.loadOlder()
expect(session.getSnapshot().hasMore).toBe(false)
// hasMore false now: further loadOlder is a guard no-op
const calls = api.calls.length
await session.loadOlder()
expect(api.calls.length).toBe(calls)
// throw path: fail-soft with console.error
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
try {
await session.resync()
api.onHistory = () => histResponse(plainTurn(6, 1, 'x', 'y'), true)
await session.resync()
api.onHistory = () => Promise.reject(new Error('page wire down'))
await session.loadOlder()
expect(errorSpy).toHaveBeenCalled()
expect(session.getSnapshot().loadingOlder).toBe(false)
} finally {
errorSpy.mockRestore()
}
})
it('subscribe delivers snapshot-change notifications and unsubscribes', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
let notified = 0
const unsubscribe = session.subscribe(() => { notified++ })
await session.open()
await new Promise(resolve => setTimeout(resolve, 0))
expect(notified).toBeGreaterThan(0)
const seen = notified
unsubscribe()
session.handleRunning(true) // any snapshot mutation; the listener must stay silent
await new Promise(resolve => setTimeout(resolve, 0))
expect(notified).toBe(seen)
})
it('rejects an opening page that does not end at the opening cursor', async () => {
const { api, session } = makeSession()
let call = 0
api.onHistory = () => {
call++
return histResponse(plainTurn(0, 0, 'a', 'b'))
}
api.followCursor = 11
await session.open()
expect(call).toBe(1)
const snapshot = session.getSnapshot()
expect(snapshot.openState).toBe('error')
expect(snapshot.openError).toMatchObject({
code: 'internal', message: 'session event stream page did not end at its requested cursor',
})
expect(eventSeqs(session)).toEqual([])
})
it('deduplicates repeated running flips and records removal', () => {
const { session } = makeSession()
const before = session.getSnapshot()
session.handleRunning(false) // already false: dedup branch
expect(session.getSnapshot()).toBe(before)
session.handleRemoved()
expect(session.getSnapshot().removed).toBe(true)
})
it('drops live events while cold/error (no window upkeep)', async () => {
const { api, session } = makeSession()
await follow(api, ev.user(0, '冷态帧'))
expect(eventSeqs(session)).toEqual([])
api.onHistory = () => Promise.resolve(err({ code: 'internal', message: 'x', details: {} }))
await session.open()
await follow(api, ev.user(0, '错态帧'))
expect(eventSeqs(session)).toEqual([])
})
it('preserves a Host-reported failure that terminates the live source', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
await session.open()
const failure = {
code: 'session-not-found',
message: 'session disappeared',
details: { sessionId: SID },
}
api.failStreams(new RemoteStreamError(failure.code, failure.message, failure.details))
await vi.waitFor(() => { expect(session.getSnapshot().openState).toBe('error') })
expect(session.getSnapshot().openError).toEqual(failure)
})
it('coalesces queued gap frames behind one repair and exposes a failed repair', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
await session.open()
const gate = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
let repairs = 0
api.onHistory = () => {
repairs++
return gate.promise
}
const deliveries = Promise.all([
follow(api, ev.user(9, '洞一')),
follow(api, ev.user(10, '洞二')),
])
await vi.waitFor(() => { expect(repairs).toBe(1) })
gate.reject(new Error('repair wire down'))
await deliveries
await vi.waitFor(() => { expect(session.getSnapshot().openState).toBe('error') })
expect(session.getSnapshot().openError).toMatchObject({ code: 'internal', message: 'repair wire down' })
expect(eventSeqs(session)).toHaveLength(6)
})
it('doOpen transport throw of a stale generation is swallowed (generation guard in catch)', async () => {
const { api, session } = makeSession()
const stale = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
api.onHistory = () => stale.promise
const opening = session.open()
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
const resynced = session.resync()
stale.reject(new Error('stale wire'))
await Promise.all([opening, resynced])
expect(session.getSnapshot().openState).toBe('open') // stale catch did not write error
})
it('drops a stale doOpen whose history resolved successfully after resync superseded it', async () => {
const { api, session } = makeSession()
const stale = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
api.onHistory = () => stale.promise
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,
modelSelection: { provider: 'deepseek-official', model: 'stale' },
})) // success, but its generation is gone
await Promise.all([opening, resynced])
expect(eventSeqs(session)).toEqual(plainTurn(6, 1, '新', '代').map(event => event.seq))
})
it('drops a gap repair superseded by a full resync while its pull was in flight', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
await session.open()
const repairPull = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
api.onHistory = () => repairPull.promise
const delivery = follow(api, ev.user(9, '洞'))
await vi.waitFor(() => { expect(api.callsOf('session.history')).toHaveLength(2) })
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,
modelSelection: { provider: 'deepseek-official', model: 'stale' },
})) // repair result: stale, dropped
await Promise.all([delivery, resynced])
expect(eventSeqs(session)).toEqual(plainTurn(6, 1, 'c', 'd').map(event => event.seq))
})
it('successful cancel leaves no promptError', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
await session.open()
const result = await session.cancel()
expect(result.ok).toBe(true)
expect(session.getSnapshot().promptError).toBeNull()
})
it('dispose is a reserved no-op on resident instances', async () => {
const { session } = makeSession()
await expect(session.dispose()).resolves.toBeUndefined()
})
it('carries history-entry and follow-frame views through the event feed', async () => {
const { api, session } = makeSession()
const callView = { for: 'call', view: { card: 'generic', title: '历史卡' } }
api.onHistory = () => Promise.resolve(ok({
events: [
...entries(plainTurn(0, 0, 'a', 'b')),
{ event: ev.toolCall(6, 1, 'h1', 'bash', '{}'), view: callView },
{ event: ev.toolResult(7, 1, 'h1', 'done'), view: { for: 'result', view: { card: 'generic', title: '历史果' } } },
] as never[],
hasMore: false,
modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
}))
await session.open()
expect(windowEntries(session).slice(-2).map(item => item.view)).toEqual([
callView,
{ for: 'result', view: { card: 'generic', title: '历史果' } },
])
await follow(
api,
ev.toolCall(8, 2, 'l1', 'write', '{}'),
{ for: 'call', view: { card: 'generic', title: '直播卡' } },
)
expect(windowEntries(session).at(-1)?.view).toEqual({
for: 'call', view: { card: 'generic', title: '直播卡' },
})
await follow(
api,
ev.toolResult(9, 2, 'l1', 'ok'),
{ for: 'result', view: { card: 'generic', title: '直播果' } },
)
expect(windowEntries(session).at(-1)?.view).toEqual({
for: 'result', view: { card: 'generic', title: '直播果' },
})
})
})
describe('resync', () => {
it('keeps the old feed until one sorted page-and-live replacement is ready', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, '旧', '窗'))
await session.open()
const oldWindow = session.eventSource.getSnapshot()
const replacement = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
api.followCursor = 15
api.onHistory = () => replacement.promise
const publications: ReturnType<Session['eventSource']['getSnapshot']>[] = []
const off = session.eventSource.subscribe(() => {
publications.push(session.eventSource.getSnapshot())
})
const syncing = session.resync()
await vi.waitFor(() => { expect(api.callsOf('session.history')).toHaveLength(2) })
expect(session.eventSource.getSnapshot()).toBe(oldWindow)
expect(publications).toEqual([])
await Promise.all([
follow(api, ev.user(17, '后到高位')),
follow(api, ev.user(16, '后到低位')),
])
expect(session.eventSource.getSnapshot()).toBe(oldWindow)
replacement.resolve(ok({
events: entries(plainTurn(10, 2, '终', '页')) as never[],
hasMore: false,
modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
}))
await syncing
expect(publications).toHaveLength(1)
expect(publications[0]?.entries).not.toHaveLength(0)
expect(publications[0]?.change.kind).toBe('replace')
expect(eventSeqs(session)).toEqual([10, 11, 12, 13, 14, 15, 16, 17])
off()
})
it('rebuilds the window without clearing control state; cold instances no-op', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
await session.open()
session.handleRunning(true)
session.handleAgentError('still visible')
api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), ...plainTurn(6, 1, 'c', 'd')])
await session.resync()
const snapshot = session.getSnapshot()
expect(snapshot.openState).toBe('open')
expect(snapshot.running).toBe(true)
expect(snapshot.lastAgentError).toBe('still visible')
expect(eventSeqs(session)).toHaveLength(12)
const cold = makeSession()
await cold.session.resync()
expect(cold.api.calls).toEqual([]) // never opened: no traffic
})
it('drops a stale in-flight open superseded by resync (generation guard)', async () => {
const { api, session } = makeSession()
const stale = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
api.onHistory = () => stale.promise
const firstOpen = session.open()
api.onHistory = () => histResponse(plainTurn(6, 1, '新', '代'))
const resynced = session.resync()
stale.reject(new Error('dead connection')) // the doomed pre-disconnect request fails late
await firstOpen
await resynced
const snapshot = session.getSnapshot()
expect(snapshot.openState).toBe('open') // stale failure did not settle the fresh generation into error
expect(eventSeqs(session)).toEqual(plainTurn(6, 1, '新', '代').map(event => event.seq))
})
})
describe('snapshot ownership', () => {
it('publishes event-window appends without changing an unrelated Session snapshot', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, '稳', '定'))
await session.open()
const sessionBefore = session.getSnapshot()
const windowBefore = session.eventSource.getSnapshot()
const firstEntry = windowBefore.entries[0]
await follow(api, ev.user(6, '追加'))
const windowAfter = session.eventSource.getSnapshot()
expect(session.getSnapshot()).toBe(sessionBefore)
expect(windowAfter).not.toBe(windowBefore)
expect(windowAfter.entries[0]).toBe(firstEntry)
expect(windowAfter.change).toMatchObject({ kind: 'append' })
})
})
@@ -0,0 +1,859 @@
/**
* ClientSessions: list store projection (manager → {ids, byId, current}
* with derived titles), the current-selection account (open validation and
* persisted mask semantics), scope-tree
* lifecycle (lazy mint / frozen survival / removed teardown with staged
* deferral — the stage follows list.current), binding identity, breadcrumb
* projection, create.
*/
import { Context } from '@deepseek-ai/cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
import { ClientSessions, SessionCreateError } from '../src/client/sessions/service.ts'
import { scopeOf } from '../src/client/scope.ts'
import type { SessionFollowFrame } from '../src/types.ts'
import {
FakeApiClient,
deferred,
err,
fakeRemote,
ok,
type RuntimeRemotes,
} from './fake-api.client.ts'
const sid = (s: string): SessionId => s as SessionId
interface Bench {
ctx: Context
api: FakeApiClient
svc: ClientSessions
}
function bench(configureRemote?: (remote: RuntimeRemotes) => RuntimeRemotes): Bench {
const ctx = new Context()
const api = new FakeApiClient()
const remote = fakeRemote(api)
const svc = new ClientSessions(ctx, api, configureRemote?.(remote) ?? remote)
return { ctx, api, svc }
}
/** Refresh the manager list from programmable rows and flush the microtask batch. */
type FeedRow = {
id: string
cwd?: string
parentId?: string
origin?: 'subagent'
running?: boolean
blank?: boolean
agentPreset?: string
}
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, blank: r.blank ?? false,
...(r.cwd !== undefined ? { cwd: r.cwd } : {}),
...(r.parentId !== undefined ? { parentSessionId: sid(r.parentId) } : {}),
...(r.origin !== undefined ? { origin: r.origin } : {}),
...(r.agentPreset !== undefined ? { agentPreset: r.agentPreset } : {}),
})),
}) as never)
await b.svc.refresh()
await Promise.resolve() // manager notifier flush
}
describe('list store projection', () => {
it('projects durable titles separately from cwd/id display fallbacks and parent links', async () => {
const b = bench()
b.svc.handleControlFrame({
type: 'projection', sessionId: sid('s1'), key: 'title', value: 'Durable title', seq: 2,
})
await feedList(b, [
{ id: 's1', cwd: '/home/u/proj-a/' },
{ id: 's2', parentId: 's1', origin: 'subagent', running: true },
])
const state = b.svc.list.getSnapshot()
expect(state.ids).toEqual(['s1', 's2'])
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', origin: 'subagent', running: true,
})
expect(state.byId[sid('s2')]?.title).toBeUndefined()
})
it('reprojects a blank session whose composition switched and nothing else moved', async () => {
const b = bench()
await feedList(b, [{ id: 's1', blank: true, agentPreset: 'standard' }])
expect(b.svc.list.getSnapshot().byId[sid('s1')]?.agentPreset).toBe('standard')
// A confirmed switch moves the preset alone: the row keeps its updatedAt,
// title, running, and blank bits, so an identity guard blind to the preset
// would serve the old row forever — and every reader (the hero chip's own
// no-op check, the header label) would keep the composition it replaced.
b.svc.noteAgentPreset(sid('s1'), 'minimal')
await Promise.resolve()
expect(b.svc.list.getSnapshot().byId[sid('s1')]?.agentPreset).toBe('minimal')
})
it('reflects live increments (host stream via manager) into the store', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }])
b.svc.handleSessionAdded({
sessionId: sid('s2'), updatedAt: 2, running: false, blank: true,
})
await Promise.resolve()
expect(b.svc.list.getSnapshot().ids).toContain('s2')
})
})
describe('search', () => {
it('delegates transient content search without changing the list snapshot', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }])
const before = b.svc.list.getSnapshot()
b.api.onSearch = () => Promise.resolve(ok({
items: [{ sessionId: sid('s1'), snippet: 'matching excerpt' }],
hasMore: false,
}))
const signal = new AbortController().signal
await expect(b.svc.search('needle', signal)).resolves.toEqual({
ok: true,
value: {
items: [{ sessionId: 's1', snippet: 'matching excerpt' }],
hasMore: false,
},
})
expect(b.api.lastSearchSignal).toBe(signal)
expect(b.svc.list.getSnapshot()).toBe(before)
})
})
describe('scope tree', () => {
it('retains a Host-addressed scope until the first Session baseline owns pruning', async () => {
const b = bench()
const scoped = b.svc.resolveAgentScope(sid('s-early'))
expect(scopeOf(scoped)).toBe('s-early')
b.svc.handleControlFrame({
type: 'baseline',
value: { queues: {}, jobs: {}, projections: {} },
})
await Promise.resolve()
expect(b.svc.resolveAgentScope(sid('s-early'))).toBe(scoped)
await feedList(b, [])
expect(b.svc.scope(sid('s-early'))).toBeUndefined()
})
it('mints lazily on first resolution, tags the ctx, and keeps binding identity stable', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }])
expect(b.svc.scope(sid('unknown'))).toBeUndefined()
const scoped = b.svc.scope(sid('s1'))
expect(scoped).toBeDefined()
expect(scopeOf(scoped as Context)).toBe('s1')
expect(scopeOf(b.ctx)).toBeUndefined()
const binding = b.svc.binding(sid('s1'))
b.svc.open(sid('s1'))
expect(b.svc.sessionOf(scoped as Context)).toBe(binding?.session)
expect(b.svc.binding(sid('s1'))).toBe(binding)
expect(binding?.ctx).toBe(scoped)
})
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.open(sid('s1')) // s1 staged (current)
b.svc.scope(sid('s2')) // s2 scoped but off stage
await feedList(b, [{ id: 's1' }]) // s2 removed, off stage: torn down
expect(b.svc.scope(sid('s2'))).toBeUndefined()
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.open(sid('s3')) // stage moves: deferred teardown sweeps s1
expect(b.svc.scope(sid('s1'))).toBeUndefined()
})
it('keeps the scope when the session merely stops running (frozen ≠ removed)', async () => {
const b = bench()
await feedList(b, [{ id: 's1', running: true }])
const scoped = b.svc.scope(sid('s1'))
await feedList(b, [{ id: 's1', running: false }])
expect(b.svc.scope(sid('s1'))).toBe(scoped)
})
it('cancels a deferred teardown when the id reappears in the list', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }])
const scoped = b.svc.scope(sid('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)
})
it('closes an opened journal when its removed scope drops', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }])
b.svc.open(sid('s1'))
const session = b.svc.binding(sid('s1'))?.session
if (session === undefined) throw new Error('expected the selected Session binding')
await vi.waitFor(() => { expect(b.api.activeFollows(sid('s1'))).toBe(1) })
const notified = vi.fn()
session.subscribe(notified)
await feedList(b, [])
await feedList(b, [{ id: 's2' }])
b.svc.open(sid('s2'))
await vi.waitFor(() => { expect(b.api.activeFollows(sid('s1'))).toBe(0) })
await b.api.pushFollow(sid('s1'), {
type: 'event',
event: { seq: 0, timestamp: 0, type: 'turn/start', data: { turn: 0 } } as never,
})
await Promise.resolve()
expect(b.api.followStarts.filter(id => id === sid('s1'))).toHaveLength(1)
expect(notified).not.toHaveBeenCalled()
})
})
describe('Agent scope disposal lifecycle', () => {
it('root disposal runs Agent scope effects', async () => {
const b = bench()
const readiness = b.ctx.plugin(() => undefined)
await readiness
b.svc.handleSessionAdded({
sessionId: sid('live'), updatedAt: 1, running: false, blank: true,
})
await Promise.resolve()
const scoped = b.svc.scope(sid('live'))
if (scoped === undefined) throw new Error('fixture Agent Context was not minted')
await scoped.fiber.await()
const scopeDisposed = vi.fn()
scoped.effect(() => scopeDisposed, 'fixture Agent scope effect')
await b.ctx.fiber.dispose()
expect(scopeDisposed).toHaveBeenCalledOnce()
expect(b.svc.sessionOf(scoped)).toBeUndefined()
})
it('root disposal waits for an opened Session source to finish closing', async () => {
const closeGate = deferred<undefined>()
const abortObserved = vi.fn()
let followSignal: AbortSignal | undefined
const b = bench(remote => ({
...remote,
session: {
...remote.session,
follow: (_request, signal) => {
if (signal === undefined) throw new Error('fixture requires a signal')
followSignal = signal
let opened = false
return {
[Symbol.asyncIterator]: () => ({
next: () => {
if (!opened) {
opened = true
return Promise.resolve({
done: false,
value: { type: 'opened', cursor: -1 } as const,
})
}
return new Promise((_resolve, reject) => {
signal.addEventListener('abort', () => {
abortObserved()
void closeGate.promise.then(() => {
reject(signal.reason instanceof Error
? signal.reason
: new Error(String(signal.reason)))
})
}, { once: true })
})
},
}),
}
},
},
}))
const readiness = b.ctx.plugin(() => undefined)
await readiness
await feedList(b, [{ id: 's1' }])
b.svc.open(sid('s1'))
await vi.waitFor(() => {
expect(b.svc.binding(sid('s1'))?.session.getSnapshot().openState).toBe('open')
})
const disposal = b.ctx.fiber.dispose()
const settled = vi.fn()
const observed = disposal.then(settled)
await vi.waitFor(() => { expect(abortObserved).toHaveBeenCalledOnce() })
expect(followSignal?.aborted).toBe(true)
expect(settled).not.toHaveBeenCalled()
closeGate.resolve(undefined)
await observed
expect(settled).toHaveBeenCalledOnce()
})
it('root disposal joins every Session drop already started by pruning under load', async () => {
const closeGates = new Map<SessionId, ReturnType<typeof deferred<undefined>>>()
const aborted = new Set<SessionId>()
const b = bench(remote => ({
...remote,
session: {
...remote.session,
follow: (request, signal) => {
if (signal === undefined) throw new Error('fixture requires a signal')
const sessionId = request.address.kind === 'session'
? request.address.sessionId
: request.address.childSessionId
const closeGate = deferred<undefined>()
closeGates.set(sessionId, closeGate)
let opened = false
return {
[Symbol.asyncIterator]: () => ({
next: () => {
if (!opened) {
opened = true
return Promise.resolve({
done: false,
value: { type: 'opened', cursor: -1 } as const,
})
}
return new Promise<IteratorResult<SessionFollowFrame>>((_resolve, reject) => {
signal.addEventListener('abort', () => {
aborted.add(sessionId)
void closeGate.promise.then(() => {
reject(signal.reason instanceof Error
? signal.reason
: new Error(String(signal.reason)))
})
}, { once: true })
})
},
}),
}
},
},
}))
const readiness = b.ctx.plugin(() => undefined)
await readiness
const sessionIds = Array.from({ length: 24 }, (_, index) => sid(`load-${String(index)}`))
const retained = sessionIds.at(-1)
const held = sessionIds[0]
if (retained === undefined || held === undefined) throw new Error('fixture requires sessions')
await feedList(b, sessionIds.map(id => ({ id })))
for (const id of sessionIds) b.svc.open(id)
await vi.waitFor(() => {
for (const id of sessionIds) {
expect(b.svc.binding(id)?.session.getSnapshot().openState).toBe('open')
}
})
const pruned = sessionIds.slice(0, -1)
await feedList(b, [{ id: retained }])
await vi.waitFor(() => { expect(aborted.size).toBe(pruned.length) })
for (const id of pruned) expect(b.svc.scope(id)).toBeUndefined()
const disposal = b.ctx.fiber.dispose()
const settled = vi.fn()
const observed = disposal.then(settled)
await vi.waitFor(() => { expect(aborted.size).toBe(sessionIds.length) })
const otherClosures: Promise<void>[] = []
for (const [id, gate] of closeGates) {
if (id === held) continue
gate.resolve(undefined)
otherClosures.push(gate.promise)
}
await Promise.all(otherClosures)
await new Promise((resolve) => { setTimeout(resolve, 0) })
expect(settled).not.toHaveBeenCalled()
closeGates.get(held)?.resolve(undefined)
await observed
expect(settled).toHaveBeenCalledOnce()
})
})
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('binding and stage lifecycle', () => {
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'))
await vi.waitFor(() => {
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'))
await vi.waitFor(() => {
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
await vi.waitFor(() => {
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('catalog-addressed navigation', () => {
it('uses catalog labels for a listed addressed route', async () => {
const b = bench()
b.api.onSubagentList = (payload) => {
const { parentSessionId } = payload as { parentSessionId: SessionId }
if (parentSessionId === sid('root')) {
return Promise.resolve(ok({
entries: [{
kind: 'child', id: sid('child'), mode: 'continuable', label: 'Child',
activity: 'inactive', hasChildren: true,
}] as never[],
parentAvailable: true,
}))
}
if (parentSessionId === sid('child')) {
return Promise.resolve(ok({
entries: [{
kind: 'child', id: sid('grandchild'), mode: 'continuable', label: 'Grandchild',
activity: 'inactive', hasChildren: false,
}] as never[],
parentAvailable: false,
}))
}
return Promise.resolve(ok({ entries: [], parentAvailable: false }))
}
await feedList(b, [
{ id: 'root' },
{ id: 'child', cwd: '/summary-child', parentId: 'root', origin: 'subagent' },
{ id: 'grandchild', cwd: '/summary-grandchild', parentId: 'child', origin: 'subagent' },
])
await b.svc.refreshSubagents(sid('root'))
await b.svc.refreshSubagents(sid('child'))
b.svc.openSubagent({
parentSessionId: sid('child'), childSessionId: sid('grandchild'), mode: 'continuable',
})
expect(b.svc.list.getSnapshot().byId[sid('child')]?.displayTitle).toBe('Child')
expect(b.svc.list.getSnapshot().byId[sid('grandchild')]?.displayTitle).toBe('Grandchild')
})
it('projects a directly opened descendant route without retaining ancestor scopes or addresses', async () => {
const b = bench()
b.api.onSubagentList = (payload) => {
const { parentSessionId } = payload as { parentSessionId: SessionId }
if (parentSessionId === sid('root')) {
return Promise.resolve(ok({
entries: [{
kind: 'child', id: sid('child'), mode: 'continuable', label: 'Child',
activity: 'inactive', hasChildren: true,
}] as never[],
parentAvailable: true,
}))
}
if (parentSessionId === sid('child')) {
return Promise.resolve(ok({
entries: [{
kind: 'child', id: sid('grandchild'), mode: 'continuable', label: 'Grandchild',
activity: 'inactive', hasChildren: false,
}] as never[],
parentAvailable: false,
}))
}
return Promise.resolve(ok({ entries: [], parentAvailable: false }))
}
await feedList(b, [{ id: 'root' }])
await b.svc.refreshSubagents(sid('root'))
await b.svc.refreshSubagents(sid('child'))
b.svc.openSubagent({
parentSessionId: sid('child'), childSessionId: sid('grandchild'), mode: 'continuable',
})
const list = b.svc.list.getSnapshot()
expect(list.ids).toEqual([sid('root')])
expect(list.byId[sid('child')]).toMatchObject({ parentId: sid('root'), origin: 'subagent' })
expect(list.byId[sid('grandchild')]).toMatchObject({ parentId: sid('child'), origin: 'subagent' })
expect(b.svc.binding(sid('child'))).toBeUndefined()
expect(b.svc.subagentAddress(sid('child'))).toBeUndefined()
b.svc.open(sid('child'))
expect(b.svc.list.getSnapshot().current).toBe(sid('child'))
expect(b.svc.subagentAddress(sid('child'))).toEqual({
parentSessionId: sid('root'), childSessionId: sid('child'), mode: 'continuable',
})
})
})
describe('create', () => {
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', 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)
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('fork', () => {
it.each([
['Roadmap', 'Roadmap (1)'],
['Roadmap (1)', 'Roadmap (2)'],
['计划(1', '计划(2'],
['计划 9', '计划 10'],
])('increments the durable title %j after the child is published', async (sourceTitle, childTitle) => {
const b = bench()
b.svc.handleControlFrame({
type: 'projection', sessionId: sid('source'), key: 'title', value: sourceTitle, seq: 2,
})
await feedList(b, [{ id: 'source', cwd: '/work' }])
b.api.onFork = () => Promise.resolve(ok({ sessionId: sid('child') }))
b.api.onRename = (payload) => {
const { title } = payload as { title: string }
return Promise.resolve(ok({ title, seq: 3 }))
}
await expect(b.svc.fork({
sessionId: sid('source'), atSeq: 7, increaseTitle: true,
})).resolves.toBe('child')
expect(b.api.callsOf('session.fork')).toEqual([{ sessionId: 'source', atSeq: 7 }])
expect(b.api.callsOf('session.rename')).toEqual([{ sessionId: 'child', title: childTitle }])
await Promise.resolve()
expect(b.svc.list.getSnapshot().byId[sid('child')]).toMatchObject({
title: childTitle,
displayTitle: childTitle,
parentId: 'source',
})
})
it('floors a fractional anchor to the real event seq the wire accepts', async () => {
const b = bench()
await feedList(b, [{ id: 'source', cwd: '/work' }])
b.api.onFork = () => Promise.resolve(ok({ sessionId: sid('child') }))
// The frozen node of an interrupted turn carries turnEnd.seq - 0.9.
await expect(b.svc.fork({ sessionId: sid('source'), atSeq: 41.1 })).resolves.toBe('child')
expect(b.api.callsOf('session.fork')).toEqual([{ sessionId: 'source', atSeq: 41 }])
})
it('does not rename without the title policy or a durable source title', async () => {
const b = bench()
await feedList(b, [{ id: 'source', cwd: '/work' }])
b.api.onFork = () => Promise.resolve(ok({ sessionId: sid('child') }))
await expect(b.svc.fork({ sessionId: sid('source'), increaseTitle: true })).resolves.toBe('child')
expect(b.api.callsOf('session.rename')).toEqual([])
b.api.onFork = () => Promise.resolve(ok({ sessionId: sid('child-2') }))
await expect(b.svc.fork({ sessionId: sid('source') })).resolves.toBe('child-2')
expect(b.api.callsOf('session.rename')).toEqual([])
})
it('rejects when child rename fails while keeping the published child addressable', async () => {
const b = bench()
b.svc.handleControlFrame({
type: 'projection', sessionId: sid('source'), key: 'title', value: 'Roadmap', seq: 2,
})
await feedList(b, [{ id: 'source' }])
b.api.onFork = () => Promise.resolve(ok({ sessionId: sid('child') }))
b.api.onRename = () => Promise.resolve(err({
code: 'title-invalid', message: 'rejected', details: { sessionId: sid('child') },
} as never))
await expect(b.svc.fork({ sessionId: sid('source'), increaseTitle: true }))
.rejects.toThrow('fork child rename failed: title-invalid: rejected')
expect(b.svc.binding(sid('child'))).toBeDefined()
})
})
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.handleSessionAdded({
sessionId: sid('s-new'), updatedAt: 2, running: false, blank: true, cwd: '/w/a',
})
await Promise.resolve()
const scoped = b.svc.scope(sid('s-new'))
expect(scoped).toBeDefined()
expect(scopeOf(scoped as Context)).toBe('s-new')
b.svc.handleSessionRemoved(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.handleSessionStatus(sid('s1'), 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.handleSessionAdded({
sessionId: sid('s-new'), updatedAt: 2, running: false, blank: true, cwd: '/w/a',
})
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('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')]?.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 and leaves the staged scope intact', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }])
b.svc.open(sid('s1'))
expect(b.svc.binding(sid('ghost'))).toBeUndefined()
// Stage unchanged: removing s1 defers (still staged), proving the ghost lookup touched nothing.
await feedList(b, [])
expect(b.svc.scope(sid('s1'))).toBeDefined()
})
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.open(sid('s1'))
const historyCalls = () => b.api.calls.filter(c => c.method === 'session.history')
await vi.waitFor(() => { 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: staged-id skip and an already-vacated scope record', async () => {
const b = bench()
await feedList(b, [{ id: 'a' }, { id: 'b' }])
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.open(sid('c'))
expect(b.svc.scope(sid('b'))).toBeUndefined()
// 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.open(sid('d')) // sweep tears c
expect(b.svc.scope(sid('c'))).toBeUndefined()
})
})
@@ -0,0 +1,171 @@
/** Test-only direct Remote face over the Session Controller's internal controllers. */
import type { Context } from '@deepseek-ai/cordis'
import type { ModelSelection as AgentModelSelection } from '@deepseek-ai/dsh-agent'
import { vi } from 'vitest'
import {
TypertRemoteFailure,
type RemoteResult,
} from '@deepseek-ai/dsh-typert-protocol'
import SessionController from '../src/index.ts'
import type {
SessionAttachmentRequest,
SessionAttachmentValue,
SessionCancelRequest,
SessionCancelValue,
SessionControlFrame,
SessionCreateRequest,
SessionCreateValue,
SessionForkRequest,
SessionForkValue,
SessionListRequest,
SessionListValue,
SessionModels,
SessionModelsRequest,
SessionPage,
SessionPageRequest,
SessionPromptRequest,
SessionPromptValue,
SessionRenameRequest,
SessionRenameValue,
SessionSearchRequest,
SessionSearchValue,
SessionSelectModelRequest,
SessionSelectModelValue,
SessionUpdateQueueRequest,
SessionUpdateQueueValue,
} from '../src/types.ts'
/** Direct test face matching the generated `ctx.remote.session` unary methods. */
export interface TestSessionRemote {
list(request: SessionListRequest, signal?: AbortSignal): Promise<RemoteResult<SessionListValue>>
search(request: SessionSearchRequest, signal?: AbortSignal): Promise<RemoteResult<SessionSearchValue>>
create(request: SessionCreateRequest): Promise<RemoteResult<SessionCreateValue>>
models(request: SessionModelsRequest): Promise<RemoteResult<SessionModels>>
selectModel(request: SessionSelectModelRequest): Promise<RemoteResult<SessionSelectModelValue>>
rename(request: SessionRenameRequest): Promise<RemoteResult<SessionRenameValue>>
fork(request: SessionForkRequest): Promise<RemoteResult<SessionForkValue>>
prompt(request: SessionPromptRequest, signal?: AbortSignal): Promise<RemoteResult<SessionPromptValue>>
attachment(request: SessionAttachmentRequest): Promise<RemoteResult<SessionAttachmentValue>>
updateQueue(request: SessionUpdateQueueRequest): Promise<RemoteResult<SessionUpdateQueueValue>>
cancel(request: SessionCancelRequest): Promise<RemoteResult<SessionCancelValue>>
page(request: SessionPageRequest, signal?: AbortSignal): Promise<RemoteResult<SessionPage>>
control(signal?: AbortSignal): AsyncIterable<SessionControlFrame>
}
/** Dependencies and policy supplied by a Session Controller unit harness. */
export interface TestSessionRemoteDefaults {
readonly defaultModelSelection: () => AgentModelSelection
readonly cwd: string
readonly coldBlankProbeMaxBytes?: number
readonly saveDefaultModelSelection?: (selection: AgentModelSelection) => void | Promise<void>
}
const installed = new WeakMap<Context, SessionController>()
function installControllers(
ctx: Context,
defaults: TestSessionRemoteDefaults,
): SessionController {
const found = installed.get(ctx)
if (found !== undefined) return found
if (ctx.get('typert') === undefined) {
const dispose = (): void => {}
ctx.provide('typert', {
lookups: { configure: () => dispose },
contexts: { configureHost: () => dispose },
} as never)
}
if (ctx.get('agentDefaultModel') === undefined) {
ctx.provide('agentDefaultModel', {
currentSelection: defaults.defaultModelSelection,
saveSelection: async (selection: AgentModelSelection) => {
await defaults.saveDefaultModelSelection?.(selection)
},
} as never)
}
if (ctx.get('llm') === undefined) {
ctx.provide('llm', {
listProviders: () => {
const selection = defaults.defaultModelSelection()
return [{ id: selection.provider, name: selection.provider }]
},
} as never)
}
const cwd = vi.spyOn(process, 'cwd').mockReturnValue(defaults.cwd)
let controller: SessionController
try {
controller = new SessionController(ctx, defaults.coldBlankProbeMaxBytes === undefined
? {}
: { coldBlankProbeMaxBytes: defaults.coldBlankProbeMaxBytes })
} finally {
cwd.mockRestore()
}
installed.set(ctx, controller)
return controller
}
/** Build or return the production Session Controller for a direct unit harness. */
export function createSessionTestController(
ctx: Context,
defaults: TestSessionRemoteDefaults,
): SessionController {
return installControllers(ctx, defaults)
}
function remoteResult<T>(
operation: () => T | Promise<T>,
signal?: AbortSignal,
): Promise<RemoteResult<T>> {
return Promise.resolve()
.then(operation)
.then(value => ({ ok: true as const, value }))
.catch((error: unknown) => ({
ok: false as const,
error: signal?.aborted === true
? { code: 'cancelled', message: 'request was aborted', details: {} }
: error instanceof TypertRemoteFailure
? error.failure
: {
code: 'internal',
message: error instanceof Error ? error.message : String(error),
details: {},
},
}))
}
/** Build the generated Session Remote's unary result semantics without a carrier. */
export function createSessionTestRemote(
ctx: Context,
defaults: TestSessionRemoteDefaults,
): TestSessionRemote {
const direct = createSessionTestController(ctx, defaults)
return {
list: (request, signal = new AbortController().signal) => remoteResult(
() => direct.list(request, signal),
signal,
),
search: (request, signal = new AbortController().signal) => remoteResult(
() => direct.search(request, signal),
signal,
),
create: request => remoteResult(() => direct.create(request)),
models: request => remoteResult(() => direct.models(request)),
selectModel: request => remoteResult(() => direct.selectModel(request)),
rename: request => remoteResult(() => direct.rename(request)),
fork: request => remoteResult(() => direct.fork(request)),
prompt: (request, signal = new AbortController().signal) => remoteResult(
() => direct.prompt(request, signal),
signal,
),
attachment: request => remoteResult(() => direct.attachment(request)),
updateQueue: request => remoteResult(() => direct.updateQueue(request)),
cancel: request => remoteResult(() => direct.cancel(request)),
page: (request, signal = new AbortController().signal) => remoteResult(
() => direct.page(request, signal),
signal,
),
control: (signal = new AbortController().signal) => direct.control(signal),
}
}
@@ -0,0 +1,24 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { resolvedClientTimeZone } from '../src/client/time-zone.ts'
afterEach(() => {
vi.restoreAllMocks()
})
describe('Session Controller browser time zone', () => {
it('returns the runtime-resolved zone', () => {
expect(resolvedClientTimeZone()).toBe(
new Intl.DateTimeFormat().resolvedOptions().timeZone,
)
})
it.each([undefined, ''])('fails loud when the runtime exposes no zone %#', (timeZone) => {
const options = new Intl.DateTimeFormat().resolvedOptions()
vi.spyOn(Intl.DateTimeFormat.prototype, 'resolvedOptions').mockReturnValue({
...options,
timeZone: timeZone as string,
})
expect(() => resolvedClientTimeZone()).toThrow('browser time zone is unavailable')
})
})
@@ -0,0 +1,295 @@
import { describe, expect, it, vi } from 'vitest'
import {
RemoteStream,
RemoteStreamCarrierError,
RemoteStreamError,
type RemoteStreamOptions,
} from '@deepseek-ai/dsh-api-gateway/client'
import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
import {
createSessionControlStream,
SessionEventStream,
sessionStreamFailure,
type SessionJournalChange,
type SessionRemote,
} from '../src/client/index.ts'
import type {
SessionAddress,
SessionControlFrame,
SessionEventEntry,
SessionFollowFrame,
SessionFollowRequest,
SessionPage,
SessionPageRequest,
} from '../src/types.ts'
type SessionTransportRemote = Pick<SessionRemote, 'control' | 'follow' | 'page'>
const ADDRESS: SessionAddress = { kind: 'session', sessionId: 'session-1' as never }
const AVAILABLE_CONNECTION = {
hostDescription: {
getSnapshot: () => ({
version: 'fixture', cwd: '/fixture', attachedSessions: 0, home: '/home/fixture', canOpenPath: true,
}),
subscribe: () => () => {},
},
}
function entry(seq: number): SessionEventEntry {
return { event: { type: 'turn/start', seq, time: seq, data: { turn: seq } } }
}
function page(events: readonly SessionEventEntry[], hasMore = false): SessionPage {
return { events, hasMore }
}
function sessionClient(remote: SessionTransportRemote) {
return {
session: remote as SessionRemote,
$stream: <Item>(options: RemoteStreamOptions<Item>) => (
new RemoteStream(AVAILABLE_CONNECTION, options)
),
}
}
interface FollowGeneration {
readonly frames: readonly SessionFollowFrame[]
readonly terminal?: Error
readonly hold?: boolean
readonly waitAfterFrames?: Promise<void>
}
class ScriptedSessionRemote implements SessionTransportRemote {
readonly followRequests: SessionFollowRequest[] = []
readonly pageRequests: SessionPageRequest[] = []
readonly signals: AbortSignal[] = []
constructor(
private readonly generations: FollowGeneration[],
private readonly pages: RemoteResult<SessionPage>[],
private readonly controlFrames: readonly SessionControlFrame[] = [],
private readonly holdControl = true,
) {}
async *follow(request: SessionFollowRequest, signal = new AbortController().signal): AsyncIterable<SessionFollowFrame> {
const generation = this.generations.shift()
if (generation === undefined) throw new Error('no scripted Session generation')
this.followRequests.push(request)
this.signals.push(signal)
for (const frame of generation.frames) yield frame
await generation.waitAfterFrames
if (generation.terminal !== undefined) throw generation.terminal
if (generation.hold === true && !signal.aborted) {
await new Promise<void>((resolve) => {
signal.addEventListener('abort', () => { resolve() }, { once: true })
})
}
}
page(request: SessionPageRequest): Promise<RemoteResult<SessionPage>> {
this.pageRequests.push(request)
const result = this.pages.shift()
if (result === undefined) throw new Error('no scripted Session page')
return Promise.resolve(result)
}
async *control(signal = new AbortController().signal): AsyncIterable<SessionControlFrame> {
for (const frame of this.controlFrames) yield frame
if (this.holdControl && !signal.aborted) {
await new Promise<void>((resolve) => {
signal.addEventListener('abort', () => { resolve() }, { once: true })
})
}
}
}
describe('Session Client stream adapters', () => {
it('binds an event journal to one address and publishes replace, append, and prepend changes', async () => {
const remote = new ScriptedSessionRemote(
[{
frames: [
{ type: 'opened', cursor: 3 },
{ type: 'event', ...entry(3) },
{ type: 'event', ...entry(4) },
],
hold: true,
}],
[
{ ok: true, value: page([entry(2), entry(3)], true) },
{ ok: true, value: page([entry(0), entry(1)], false) },
],
)
const changes: SessionJournalChange[] = []
const stream = new SessionEventStream(sessionClient(remote), ADDRESS, {
publish: (change) => { changes.push(change) },
failed: vi.fn(),
})
await stream.open({ maxMessages: 50 })
await vi.waitFor(() => { expect(changes).toHaveLength(2) })
await stream.prepend({ beforeSeq: 2, maxMessages: 50 })
expect(remote.followRequests).toEqual([{ address: ADDRESS }])
expect(remote.pageRequests).toEqual([
{ address: ADDRESS, throughSeq: 3, maxMessages: 50 },
{ address: ADDRESS, throughSeq: 4, beforeSeq: 2, maxMessages: 50 },
])
expect(changes).toMatchObject([
{ type: 'replace', entries: [entry(2), entry(3)], hasMore: true },
{ type: 'append', entry: entry(4) },
{ type: 'prepend', entries: [entry(0), entry(1)], hasMore: false },
])
await stream.dispose()
expect(remote.signals[0]?.aborted).toBe(true)
})
it('resumes after the applied cursor and repairs through the addressed tail page', async () => {
const lost = new RemoteStreamCarrierError('lost')
const remote = new ScriptedSessionRemote(
[
{
frames: [{ type: 'opened', cursor: 1 }, { type: 'event', ...entry(2) }],
terminal: lost,
},
{ frames: [{ type: 'opened', cursor: 4 }], hold: true },
],
[
{ ok: true, value: page([entry(0), entry(1)]) },
{ ok: true, value: page([entry(0), entry(1), entry(2), entry(3), entry(4)]) },
],
)
const changes: SessionJournalChange[] = []
const carrierFailed = vi.fn()
const stream = new SessionEventStream(sessionClient(remote), ADDRESS, {
publish: (change) => { changes.push(change) },
carrierFailed,
failed: vi.fn(),
})
await stream.open({ maxMessages: 50 })
await vi.waitFor(() => { expect(remote.followRequests).toHaveLength(2) })
expect(remote.followRequests).toEqual([
{ address: ADDRESS },
{ address: ADDRESS, afterSeq: 2 },
])
expect(remote.pageRequests).toEqual([
{ address: ADDRESS, throughSeq: 1, maxMessages: 50 },
{ address: ADDRESS, throughSeq: 4, maxMessages: 50 },
])
expect(changes.map(change => change.type)).toEqual(['replace', 'append', 'replace'])
expect(carrierFailed).toHaveBeenCalledWith(lost)
await stream.dispose()
})
it('repairs a resumed event stream without an optional message limit', async () => {
const finish = Promise.withResolvers<undefined>()
const remote = new ScriptedSessionRemote(
[
{
frames: [{ type: 'opened', cursor: 0 }],
waitAfterFrames: finish.promise,
terminal: new RemoteStreamCarrierError('lost'),
},
{ frames: [{ type: 'opened', cursor: 1 }], hold: true },
],
[
{ ok: true, value: page([entry(0)]) },
{ ok: true, value: page([entry(0), entry(1)]) },
],
)
const stream = new SessionEventStream(sessionClient(remote), ADDRESS, {
publish: vi.fn(),
failed: vi.fn(),
})
await stream.open({})
finish.resolve(undefined)
await vi.waitFor(() => { expect(remote.pageRequests).toHaveLength(2) })
expect(remote.pageRequests).toEqual([
{ address: ADDRESS, throughSeq: 0 },
{ address: ADDRESS, throughSeq: 1 },
])
await stream.dispose()
})
it('turns a page failure into a typed stream failure and closes follow', async () => {
const failure = { code: 'session-not-found', message: 'missing', details: { sessionId: 'session-1' } } as const
const remote = new ScriptedSessionRemote(
[{ frames: [{ type: 'opened', cursor: -1 }], hold: true }],
[{ ok: false, error: failure }],
)
const stream = new SessionEventStream(sessionClient(remote), ADDRESS, {
publish: vi.fn(),
failed: vi.fn(),
})
await expect(stream.open({})).rejects.toBeInstanceOf(RemoteStreamError)
await expect(stream.open({})).rejects.toThrow('already opened')
expect(sessionStreamFailure(new RemoteStreamError(failure.code, failure.message, failure.details)))
.toEqual(failure)
expect(sessionStreamFailure(new Error('local'))).toBeUndefined()
expect(remote.signals[0]?.aborted).toBe(true)
expect(remote.pageRequests).toEqual([{ address: ADDRESS, throughSeq: -1 }])
})
it('maps the Host-wide control baseline and deltas into one snapshot stream', async () => {
const baseline: SessionControlFrame = {
type: 'baseline',
value: { queues: {}, jobs: {}, projections: {} },
}
const update: SessionControlFrame = {
type: 'queue', sessionId: 'session-1' as never, items: [],
}
const remote = new ScriptedSessionRemote([], [], [baseline, update])
const accept = vi.fn<(frame: SessionControlFrame) => void>()
const stream = createSessionControlStream(sessionClient(remote), {
accept,
failed: vi.fn(),
})
stream.start()
stream.start()
await vi.waitFor(() => { expect(accept).toHaveBeenCalledTimes(2) })
expect(accept.mock.calls.map(([frame]) => frame)).toEqual([baseline, update])
await stream.dispose()
await stream.dispose()
})
it('classifies control streams that end before and after their opening baseline', async () => {
const beforeFailed = vi.fn()
const before = createSessionControlStream(
sessionClient(new ScriptedSessionRemote([], [], [], false)),
{ accept: vi.fn(), failed: beforeFailed },
)
before.start()
await vi.waitFor(() => { expect(beforeFailed).toHaveBeenCalledOnce() })
expect(beforeFailed.mock.calls[0]?.[0]).toMatchObject({
message: 'session control stream ended before its opening snapshot',
})
await before.dispose()
const baseline: SessionControlFrame = {
type: 'baseline',
value: { queues: {}, jobs: {}, projections: {} },
}
const carrierFailed = vi.fn()
const failed = vi.fn()
const afterRemote = new ScriptedSessionRemote([], [], [baseline], false)
const after = createSessionControlStream(sessionClient(afterRemote), {
accept: vi.fn(),
carrierFailed: (error) => {
carrierFailed(error)
void after.dispose()
},
failed,
})
after.start()
await vi.waitFor(() => { expect(carrierFailed).toHaveBeenCalledOnce() })
expect(carrierFailed.mock.calls[0]?.[0]).toMatchObject({
message: 'session control stream ended without a terminal result',
})
expect(failed).not.toHaveBeenCalled()
await after.dispose()
})
})
@@ -0,0 +1,652 @@
import { Context } from '@deepseek-ai/cordis'
import { createScope } from '@deepseek-ai/dsh-scope'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
import { describe, expect, it, vi } from 'vitest'
import { SessionHistoryController } from '../src/history.ts'
const signal = (): AbortSignal => new AbortController().signal
function append(
session: Session,
type: string,
data: unknown,
options?: { readonly surfaceOp?: unknown; readonly sourceEventSeqs?: readonly number[] },
): SessionEvent {
return (session.append as unknown as (
eventType: string,
eventData: unknown,
eventOptions?: unknown,
) => SessionEvent)(type, data, options)
}
function event(type: string, seq: number, data: unknown = {}): SessionEvent {
return { type, seq, time: seq + 1, data } as SessionEvent
}
function cold(
ctx: Context,
header: SessionHeader,
events: readonly SessionEvent[],
): void {
ctx.provide('sessionPersistence', {
list: () => Promise.resolve([header]),
inspect: () => Promise.resolve({ meta: header, events }),
} as never)
}
interface Deferred<T> {
readonly promise: Promise<T>
resolve(value: T): void
}
function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void
const promise = new Promise<T>((settle) => { resolve = settle })
return { promise, resolve }
}
async function setup(): Promise<{ ctx: Context; transport: SessionHistoryController }> {
const ctx = new Context()
await ctx.plugin(SessionStore)
const transport = new SessionHistoryController(ctx)
return { ctx, transport }
}
describe('SessionHistoryController', () => {
it('opens at the current cursor and follows later events from an ordinary Session', async () => {
const { ctx, transport } = await setup()
const session = ctx.sessions.create(SessionId('ordinary'), { meta: { cwd: '/workspace' } })
session.append('turn/start', { turn: 1 })
const abort = new AbortController()
const iterator = transport.follow(
{ address: { kind: 'session', sessionId: session.id } },
abort.signal,
)[Symbol.asyncIterator]()
expect(await iterator.next()).toMatchObject({ done: false, value: { type: 'opened', cursor: 0 } })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
expect(await iterator.next()).toMatchObject({
done: false,
value: { type: 'event', event: { type: 'turn/end', seq: 1 } },
})
const page = await transport.page(
{ address: { kind: 'session', sessionId: session.id }, throughSeq: 1 },
new AbortController().signal,
)
expect(page.events.map(entry => entry.event.seq)).toEqual([0, 1])
abort.abort()
expect(await iterator.next()).toMatchObject({ done: true })
})
it('ends active followers when the owning Controller unloads', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
let transport!: SessionHistoryController
const owner = ctx.plugin(Object.assign(
(inner: Context) => { transport = new SessionHistoryController(inner) },
{ inject: ['sessions'] },
))
await owner.await()
const session = ctx.sessions.create(SessionId('controller-unload'), { meta: { cwd: '/workspace' } })
const iterator = transport.follow(
{ address: { kind: 'session', sessionId: session.id } },
new AbortController().signal,
)[Symbol.asyncIterator]()
await expect(iterator.next()).resolves.toEqual({
done: false,
value: { type: 'opened', cursor: -1 },
})
const pending = iterator.next()
await owner.dispose()
await expect(pending).resolves.toEqual({ done: true, value: undefined })
await ctx.fiber.dispose()
})
it('resumes from the last applied seq before delivering later live events', async () => {
const { ctx, transport } = await setup()
const session = ctx.sessions.create(SessionId('resume'), { meta: { cwd: '/workspace' } })
session.append('turn/start', { turn: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
session.append('turn/start', { turn: 2 })
const abort = new AbortController()
const iterator = transport.follow({
address: { kind: 'session', sessionId: session.id },
afterSeq: 0,
}, abort.signal)[Symbol.asyncIterator]()
expect(await iterator.next()).toEqual({ done: false, value: { type: 'opened', cursor: 2 } })
expect(await iterator.next()).toMatchObject({ done: false, value: { type: 'event', event: { seq: 1 } } })
expect(await iterator.next()).toMatchObject({ done: false, value: { type: 'event', event: { seq: 2 } } })
session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
expect(await iterator.next()).toMatchObject({ done: false, value: { type: 'event', event: { seq: 3 } } })
abort.abort()
expect(await iterator.next()).toMatchObject({ done: true })
})
it('subscribes before a cold read and ignores unrelated and replayed buffered events', async () => {
const { ctx, transport } = await setup()
const sessionId = SessionId('cold-race')
const header = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' }
const listed = deferred<readonly SessionHeader[]>()
ctx.provide('sessionPersistence', {
list: () => listed.promise,
inspect: () => Promise.resolve({ meta: header, events: [event('fixture/start', 0)] }),
} as never)
const abort = new AbortController()
const iterator = transport.follow({ address: { kind: 'session', sessionId } }, abort.signal)
[Symbol.asyncIterator]()
const opening = iterator.next()
ctx.emit('session/event', { id: SessionId('unrelated') } as Session, event('fixture/other', 0))
ctx.emit('session/event', { id: sessionId } as Session, event('fixture/start', 0))
listed.resolve([header])
await expect(opening).resolves.toEqual({ done: false, value: { type: 'opened', cursor: 0 } })
const waiting = iterator.next()
abort.abort()
await expect(waiting).resolves.toMatchObject({ done: true })
})
it('bridges the unpublished end-seed boundary when a cold source attaches', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
let transport!: SessionHistoryController
let agentCtx!: Context
await ctx.plugin(Object.assign(
(inner: Context) => { transport = new SessionHistoryController(inner) },
{ inject: ['sessions'] },
))
await ctx.plugin(Object.assign(
(inner: Context) => { agentCtx = createScope(inner, { name: 'agent' }).ctx },
{ inject: ['sessions'] },
))
const sessionId = SessionId('cold-attach')
const header = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' }
const seed = [event('fixture/start', 0)]
cold(ctx, header, seed)
agentCtx.on('session/created', (session) => {
if (session.id !== sessionId) return
append(session, 'fixture/setup-one', {})
append(session, 'fixture/setup-two', {})
})
const abort = new AbortController()
const iterator = transport.follow({ address: { kind: 'session', sessionId } }, abort.signal)
[Symbol.asyncIterator]()
await expect(iterator.next()).resolves.toEqual({ done: false, value: { type: 'opened', cursor: 0 } })
agentCtx.sessions.create(SessionId('unrelated-created'), { meta: { cwd: '/workspace' } })
const attached = agentCtx.sessions.prepare(sessionId, { meta: header, seed })
agentCtx.sessions.enter(attached)
agentCtx.sessions.announce(attached)
await expect(iterator.next()).resolves.toMatchObject({
done: false,
value: { type: 'event', event: { type: 'session/end-seed', seq: 1 } },
})
await expect(iterator.next()).resolves.toMatchObject({
done: false,
value: { type: 'event', event: { type: 'fixture/setup-one', seq: 2 } },
})
await expect(iterator.next()).resolves.toMatchObject({
done: false,
value: { type: 'event', event: { type: 'fixture/setup-two', seq: 3 } },
})
append(attached, 'fixture/live', {})
await expect(iterator.next()).resolves.toMatchObject({
done: false,
value: { type: 'event', event: { type: 'fixture/live', seq: 4 } },
})
abort.abort()
await expect(iterator.next()).resolves.toMatchObject({ done: true })
})
it('rejects gaps in replayed and live event sequences', async () => {
const replay = await setup()
const replayId = SessionId('replay-gap')
const replayHeader = { version: 0, id: replayId, createdAt: 1, cwd: '/workspace' }
cold(replay.ctx, replayHeader, [event('fixture/start', 0), event('fixture/gap', 2)])
const replayed = replay.transport.follow({
address: { kind: 'session', sessionId: replayId }, afterSeq: -1,
}, signal())[Symbol.asyncIterator]()
await expect(replayed.next()).resolves.toEqual({ done: false, value: { type: 'opened', cursor: 2 } })
await expect(replayed.next()).resolves.toMatchObject({ done: false, value: { event: { seq: 0 } } })
await expect(replayed.next()).rejects.toMatchObject({ failure: { code: 'internal' } })
const live = await setup()
const session = live.ctx.sessions.create(SessionId('live-gap'), { meta: { cwd: '/workspace' } })
append(session, 'fixture/start', {})
live.ctx.provide('agents', { get: () => ({ id: session.id }) } as never)
const followed = live.transport.follow({
address: { kind: 'session', sessionId: session.id },
}, signal())[Symbol.asyncIterator]()
await expect(followed.next()).resolves.toEqual({ done: false, value: { type: 'opened', cursor: 0 } })
live.ctx.emit('session/event', session, event('fixture/gap', 2))
await expect(followed.next()).rejects.toMatchObject({ failure: { code: 'internal' } })
})
it('opens an empty source at cursor -1', async () => {
const { ctx, transport } = await setup()
const session = ctx.sessions.create(SessionId('empty-follow'), { meta: { cwd: '/workspace' } })
const abort = new AbortController()
const iterator = transport.follow({
address: { kind: 'session', sessionId: session.id },
}, abort.signal)[Symbol.asyncIterator]()
await expect(iterator.next()).resolves.toEqual({ done: false, value: { type: 'opened', cursor: -1 } })
await expect(transport.page({
address: { kind: 'session', sessionId: session.id }, throughSeq: -1,
}, signal())).resolves.toMatchObject({ events: [], hasMore: false })
abort.abort()
await expect(iterator.next()).resolves.toMatchObject({ done: true })
})
it('requires the durable parent and mode for a direct subagent address', async () => {
const { ctx, transport } = await setup()
const parentSessionId = SessionId('parent')
const childSessionId = SessionId('child')
ctx.sessions.create(parentSessionId, { meta: { cwd: '/workspace' } })
const child = ctx.sessions.create(childSessionId, {
meta: { cwd: '/workspace', origin: 'subagent', parentSession: parentSessionId },
})
child.append('subagent/descriptor', snapshotSubagentDescriptor({
mode: 'continuable',
provider: 'test',
label: 'child',
}))
const signal = new AbortController().signal
await expect(transport.page({
address: { kind: 'subagent', parentSessionId, childSessionId, mode: 'continuable' },
throughSeq: 0,
}, signal)).resolves.toMatchObject({ events: [{ event: { type: 'subagent/descriptor' } }] })
await expect(transport.page({
address: {
kind: 'subagent',
parentSessionId: SessionId('other-parent'),
childSessionId,
mode: 'continuable',
},
throughSeq: 0,
}, signal)).rejects.toMatchObject({ failure: { code: 'subagent-unauthorized' } })
await expect(transport.page({
address: { kind: 'subagent', parentSessionId, childSessionId, mode: 'one-shot' },
throughSeq: 0,
}, signal)).rejects.toMatchObject({ failure: { code: 'subagent-unauthorized' } })
await expect(transport.page({
address: { kind: 'session', sessionId: childSessionId },
throughSeq: 0,
}, signal)).rejects.toMatchObject({ failure: { code: 'agent-busy' } })
})
it('preserves a cold inspection failure for the Gateway error branch', async () => {
const { ctx, transport } = await setup()
const sessionId = SessionId('corrupt-cold')
const failure = new Error('cold log is corrupt')
const header = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' }
ctx.provide('sessionPersistence', {
list: () => Promise.resolve([header]),
inspect: () => Promise.reject(failure),
} as never)
await expect(transport.page({
address: { kind: 'session', sessionId },
throughSeq: -1,
}, new AbortController().signal)).rejects.toBe(failure)
})
it('rejects malformed page and follow cursors at the service boundary', async () => {
const { ctx, transport } = await setup()
const session = ctx.sessions.create(SessionId('validation'), { meta: { cwd: '/workspace' } })
const address = { kind: 'session' as const, sessionId: session.id }
for (const request of [
{ address, throughSeq: -2 },
{ address, throughSeq: 0.5 },
{ address, throughSeq: -1, beforeSeq: -1 },
{ address, throughSeq: -1, beforeSeq: 1.5 },
{ address, throughSeq: -1, maxMessages: 0 },
{ address, throughSeq: -1, maxMessages: 1.5 },
]) {
await expect(transport.page(request, signal())).rejects.toMatchObject({ failure: { code: 'bad-request' } })
}
await expect(transport.page({ address, throughSeq: 0 }, signal()))
.rejects.toMatchObject({ failure: { code: 'bad-request' } })
const corrupt = await setup()
const corruptId = SessionId('missing-through-seq')
cold(
corrupt.ctx,
{ version: 0, id: corruptId, createdAt: 1, cwd: '/workspace' },
[event('fixture/start', 0), event('fixture/gap', 2)],
)
await expect(corrupt.transport.page({
address: { kind: 'session', sessionId: corruptId }, throughSeq: 1,
}, signal())).rejects.toMatchObject({ failure: { code: 'internal' } })
for (const afterSeq of [-2, 0.5]) {
const iterator = transport.follow({ address, afterSeq }, signal())[Symbol.asyncIterator]()
await expect(iterator.next()).rejects.toMatchObject({ failure: { code: 'bad-request' } })
}
const past = transport.follow({ address, afterSeq: 0 }, signal())[Symbol.asyncIterator]()
await expect(past.next()).rejects.toMatchObject({ failure: { code: 'bad-request' } })
})
it('reports missing ordinary and subagent sources without fabricating inspection failures', async () => {
const { ctx, transport } = await setup()
const ordinary = { kind: 'session' as const, sessionId: SessionId('missing') }
await expect(transport.page({ address: ordinary, throughSeq: -1 }, signal()))
.rejects.toMatchObject({ failure: { code: 'internal' } })
ctx.provide('sessionPersistence', {
list: () => Promise.resolve([]),
inspect: () => Promise.reject(new Error('must not inspect')),
} as never)
await expect(transport.page({ address: ordinary, throughSeq: -1 }, signal()))
.rejects.toMatchObject({ failure: { code: 'session-not-found' } })
await expect(transport.page({
address: {
kind: 'subagent',
parentSessionId: SessionId('parent'),
childSessionId: SessionId('missing-child'),
mode: 'continuable',
},
throughSeq: -1,
}, signal())).rejects.toMatchObject({ failure: { code: 'subagent-not-found' } })
})
it('rejects incomplete cold metadata before serving a source', async () => {
const first = await setup()
const sessionId = SessionId('incomplete')
const address = { kind: 'session' as const, sessionId }
first.ctx.provide('sessionPersistence', {
list: () => Promise.resolve([{ version: 0, id: sessionId, createdAt: 1 }]),
inspect: () => Promise.reject(new Error('must not inspect')),
} as never)
await expect(first.transport.page({ address, throughSeq: -1 }, signal()))
.rejects.toMatchObject({ failure: { code: 'session-not-found' } })
const second = await setup()
const listed = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' }
second.ctx.provide('sessionPersistence', {
list: () => Promise.resolve([listed]),
inspect: () => Promise.resolve({ meta: { ...listed, cwd: undefined }, events: [] }),
} as never)
await expect(second.transport.page({ address, throughSeq: -1 }, signal()))
.rejects.toMatchObject({ failure: { code: 'session-not-found' } })
})
it('serves cold ordinary history and validates every durable subagent descriptor state', async () => {
const ordinaryBench = await setup()
const ordinaryId = SessionId('cold-ordinary')
const ordinaryHeader = { version: 0, id: ordinaryId, createdAt: 1, cwd: '/workspace' }
cold(ordinaryBench.ctx, ordinaryHeader, [event('turn/start', 0, { turn: 1 })])
await expect(ordinaryBench.transport.page({
address: { kind: 'session', sessionId: ordinaryId },
throughSeq: 0,
}, signal())).resolves.toMatchObject({ events: [{ event: { seq: 0 } }] })
const parentSessionId = SessionId('cold-parent')
const childSessionId = SessionId('cold-child')
const childHeader = {
version: 0,
id: childSessionId,
createdAt: 1,
cwd: '/workspace',
origin: 'subagent' as const,
parentSession: parentSessionId,
}
const childAddress = {
kind: 'subagent' as const,
parentSessionId,
childSessionId,
mode: 'continuable' as const,
}
const missing = await setup()
cold(missing.ctx, childHeader, [])
await expect(missing.transport.page({ address: childAddress, throughSeq: -1 }, signal()))
.rejects.toMatchObject({ failure: { code: 'subagent-catalog-diagnostic', details: { reason: 'unsupported' } } })
const corrupt = await setup()
cold(corrupt.ctx, childHeader, [event('subagent/descriptor', 0, { version: 'bad' })])
await expect(corrupt.transport.page({ address: childAddress, throughSeq: 0 }, signal()))
.rejects.toMatchObject({ failure: { code: 'subagent-catalog-diagnostic', details: { reason: 'corrupt' } } })
const ordinaryChild = await setup()
const { origin: _origin, ...ordinaryChildHeader } = childHeader
cold(ordinaryChild.ctx, ordinaryChildHeader, [])
await expect(ordinaryChild.transport.page({ address: childAddress, throughSeq: -1 }, signal()))
.rejects.toMatchObject({ failure: { code: 'subagent-unauthorized' } })
})
it('uses attached and detached projection cuts and isolates a child projection failure', async () => {
const attached = await setup()
const session = attached.ctx.sessions.create(SessionId('projected'), { meta: { cwd: '/workspace' } })
session.append('turn/start', { turn: 1 })
const snapshot = vi.fn(() => ({ asOfSeq: 0, values: { title: 'attached' } }))
attached.ctx.provide('sessionProjections', { snapshot, restore: vi.fn() } as never)
await expect(attached.transport.page({
address: { kind: 'session', sessionId: session.id },
throughSeq: 0,
}, signal())).resolves.toMatchObject({ projections: { asOfSeq: 0, values: { title: 'attached' } } })
expect(snapshot).toHaveBeenCalledWith(session)
const older = await attached.transport.page({
address: { kind: 'session', sessionId: session.id }, throughSeq: 0, beforeSeq: 1,
}, signal())
expect('projections' in older).toBe(false)
const detached = await setup()
const coldId = SessionId('projected-cold')
const header = { version: 0, id: coldId, createdAt: 1, cwd: '/workspace' }
cold(detached.ctx, header, [event('turn/start', 0, { turn: 1 })])
const restore = vi.fn(() => ({ snapshot: { asOfSeq: 0, values: { title: 'cold' } } }))
detached.ctx.provide('sessionProjections', { snapshot: vi.fn(), restore } as never)
await expect(detached.transport.page({
address: { kind: 'session', sessionId: coldId },
throughSeq: 0,
}, signal())).resolves.toMatchObject({ projections: { values: { title: 'cold' } } })
expect(restore).toHaveBeenCalledWith({}, expect.any(Array), 0)
const failed = await setup()
cold(failed.ctx, header, [event('turn/start', 0, { turn: 1 })])
failed.ctx.provide('sessionProjections', {
snapshot: vi.fn(),
restore: () => { throw new Error('projection failed') },
} as never)
await expect(failed.transport.page({
address: { kind: 'session', sessionId: coldId },
throughSeq: 0,
}, signal())).rejects.toThrow('projection failed')
const child = await setup()
const parentSessionId = SessionId('projection-parent')
const childSessionId = SessionId('projection-child')
const childSession = child.ctx.sessions.create(childSessionId, {
meta: { cwd: '/workspace', origin: 'subagent', parentSession: parentSessionId },
})
childSession.append('subagent/descriptor', snapshotSubagentDescriptor({
mode: 'continuable', provider: 'test', label: 'child',
}))
const warn = vi.spyOn(child.ctx.logger, 'warn').mockImplementation(() => undefined)
child.ctx.provide('sessionProjections', {
snapshot: () => { throw new Error('child projection failed') },
restore: vi.fn(),
} as never)
const page = await child.transport.page({
address: { kind: 'subagent', parentSessionId, childSessionId, mode: 'continuable' },
throughSeq: 0,
}, signal())
expect('projections' in page).toBe(false)
expect(warn).toHaveBeenCalledWith(expect.stringContaining('child projection failed'))
})
it('resolves presenter scope from a live Agent or the durable preset and tolerates lookup failure', async () => {
const live = await setup()
const liveSession = live.ctx.sessions.create(SessionId('live-scope'), { meta: { cwd: '/workspace' } })
const liveAgent = { id: liveSession.id }
const preset = vi.fn(() => Promise.resolve('preset-scope'))
live.ctx.provide('agents', { get: () => liveAgent } as never)
live.ctx.provide('agentPresets', { standingKeyFor: preset } as never)
await live.transport.page({
address: { kind: 'session', sessionId: liveSession.id }, throughSeq: -1,
}, signal())
expect(preset).not.toHaveBeenCalled()
const attached = await setup()
const attachedSession = attached.ctx.sessions.create(SessionId('preset-scope'), {
meta: { cwd: '/workspace', agentPreset: 'minimal' },
})
const standingKeyFor = vi.fn(() => Promise.resolve('standing-scope'))
attached.ctx.provide('agentPresets', { standingKeyFor } as never)
await attached.transport.page({
address: { kind: 'session', sessionId: attachedSession.id },
throughSeq: -1,
}, signal())
expect(standingKeyFor).toHaveBeenCalledWith('minimal')
const detached = await setup()
const detachedId = SessionId('detached-scope')
const header = {
version: 0, id: detachedId, createdAt: 1, cwd: '/workspace', agentPreset: 'standard',
}
cold(detached.ctx, header, [])
const rejected = vi.fn(() => Promise.reject(new Error('preset unavailable')))
detached.ctx.provide('agentPresets', { standingKeyFor: rejected } as never)
await expect(detached.transport.page({
address: { kind: 'session', sessionId: detachedId },
throughSeq: -1,
}, signal())).resolves.toMatchObject({ events: [] })
expect(rejected).toHaveBeenCalledWith('standard')
const switched = await setup()
const switchedId = SessionId('switched-scope')
const switchedHeader = {
version: 0, id: switchedId, createdAt: 1, cwd: '/workspace', agentPreset: 'standard',
}
cold(switched.ctx, switchedHeader, [
event('agent-preset/selected', 0, { agentPreset: 'minimal' }),
])
const switchedKey = vi.fn(() => Promise.resolve('switched-scope'))
switched.ctx.provide('agentPresets', { standingKeyFor: switchedKey } as never)
await switched.transport.page({
address: { kind: 'session', sessionId: switchedId }, throughSeq: 0,
}, signal())
expect(switchedKey).toHaveBeenCalledWith('minimal')
})
it('keeps message-aligned pagination contiguous across replacement provenance', async () => {
const { ctx, transport } = await setup()
const session = ctx.sessions.create(SessionId('pagination'), { meta: { cwd: '/workspace' } })
session.append('turn/start', { turn: 1 })
append(session, 'user/message', { content: [], source: { kind: 'user' } }, { surfaceOp: 'append' })
const firstReply = append(session, 'assistant/message', { turn: 1, step: 1, message: {} }, { surfaceOp: 'append' })
append(session, 'user/message', { content: [], source: { kind: 'user' } }, { surfaceOp: 'append' })
append(session, 'assistant/message', { turn: 1, step: 2, message: {} }, { surfaceOp: 'append' })
const summary = append(session, 'fixture/summary', {})
const replacement = append(session, 'user/message', { content: [], source: { kind: 'plugin' } }, {
surfaceOp: { op: 'replace', start: 1, end: 4 },
sourceEventSeqs: [1, firstReply.seq, 3, 4, summary.seq],
})
const page = await transport.page({
address: { kind: 'session', sessionId: session.id }, throughSeq: replacement.seq, maxMessages: 2,
}, signal())
expect(page.events.map(entry => entry.event.seq)).toEqual([3, 4, 5, replacement.seq])
expect(page.hasMore).toBe(true)
const before = await transport.page({
address: { kind: 'session', sessionId: session.id }, throughSeq: replacement.seq, beforeSeq: 3, maxMessages: 1,
}, signal())
expect(before.events.map(entry => entry.event.seq)).toEqual([2])
})
it('keeps cited source events in the page that owns their appended message', async () => {
const { ctx, transport } = await setup()
const session = ctx.sessions.create(SessionId('pagination-sources'), { meta: { cwd: '/workspace' } })
const source = append(session, 'fixture/source', {})
append(session, 'user/message', { content: [], source: { kind: 'plugin' } }, {
surfaceOp: 'append', sourceEventSeqs: [source.seq],
})
const page = await transport.page({
address: { kind: 'session', sessionId: session.id }, throughSeq: 1, maxMessages: 1,
}, signal())
expect(page.events.map(entry => entry.event.seq)).toEqual([0, 1])
expect(page.hasMore).toBe(false)
})
it('projects tool call and result views and contains malformed presenters', async () => {
const { ctx, transport } = await setup()
const sessionId = SessionId('presenters')
const header = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' }
const events = [
event('fixture/start', 0),
event('tool/call', 1, { callId: 'c1', name: 'present', arguments: '{"path":"a.ts"}' }),
event('tool/result', 2, {
message: {
source: { callId: 'c1' },
content: [{ content: [{ type: 'text', text: 'ok' }], isError: true }],
},
meta: { persisted: true },
}),
event('tool/result', 3, {
message: {
source: { callId: 'missing' },
content: [{ content: [{ type: 'text', text: 'missing' }] }],
},
}),
event('tool/call', 4, { callId: 'c2', name: 'present', arguments: '{' }),
event('tool/result', 5, {
message: {
source: { callId: 'c2' },
content: [{ content: [{ type: 'text', text: 'bad args' }] }],
},
}),
event('tool/call', 6, { callId: 'c3', name: 'empty', arguments: '{}' }),
event('tool/result', 7, {
message: {
source: { callId: 'c3' },
content: [{ content: [{ type: 'text', text: 'no presenter' }], isError: false }],
},
}),
event('tool/call', 8, { callId: 'c4', name: 'throw-call', arguments: '{}' }),
event('tool/call', 9, { callId: 'c5', name: 'throw-result', arguments: '{}' }),
event('tool/result', 10, {
message: {
source: { callId: 'c5' },
content: [{ content: [{ type: 'text', text: 'throw' }], isError: false }],
},
}),
]
cold(ctx, header, events)
ctx.provide('tools', {
get: (name: string) => {
if (name === 'present') {
return {
presentCall: (args: unknown) => ({ card: 'generic', title: 'Call', rawInput: args }),
presentResult: (_args: unknown, result: unknown) => ({ card: 'generic', title: 'Result', result }),
}
}
if (name === 'empty') return {}
if (name === 'throw-call') return { presentCall: () => { throw new Error('call presenter failed') } }
if (name === 'throw-result') return { presentResult: () => { throw new Error('result presenter failed') } }
return undefined
},
} as never)
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
const page = await transport.page({
address: { kind: 'session', sessionId }, throughSeq: 10,
}, signal())
expect(page.events[1]?.view).toEqual({
for: 'call', view: { card: 'generic', title: 'Call', rawInput: { path: 'a.ts' } },
})
expect(page.events[2]?.view).toMatchObject({ for: 'result', view: { card: 'generic', title: 'Result' } })
for (const index of [0, 3, 4, 5, 6, 7, 8, 9, 10]) {
expect(page.events[index]).not.toHaveProperty('view')
}
expect(warn).toHaveBeenCalledWith(expect.stringContaining('call presenter failed'))
expect(warn).toHaveBeenCalledWith(expect.stringContaining('result presenter failed'))
})
})