mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-14 04:01:35 +00:00
Merge remote-tracking branch 'origin/master' into dshw/pr-deepseek-harness-deepseek-harness-2672
This commit is contained in:
@@ -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'))
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user