Merge remote-tracking branch 'github/master' into xtr/session-format-migration

# Conflicts:
#	docs/config-catalog.i18n.yaml
#	docs/config-catalog.md
#	docs/config-catalog.zh.md
#	packages/session/session-persistence-jsonl/src/index.ts
#	packages/session/session-persistence/src/coordinator.ts
#	packages/session/session-persistence/src/index.ts
This commit is contained in:
_Kerman
2026-08-25 12:41:23 +08:00
2357 changed files with 46214 additions and 18025 deletions
@@ -4,8 +4,10 @@ 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 { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-presets'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
import type { SessionObservation } from '@deepseek-ai/dsh-session-query'
import { TypertLookupFailure } from '@deepseek-ai/dsh-typert-protocol'
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -16,6 +18,8 @@ import {
ApiSessionSubagentOwnership,
inspectApiSession,
} from '../src/agent.ts'
import { installModelSelectionProjection } from '../src/model-selection-projection.ts'
import { installSessionReadTestServices, testSessionPersistence } from './test-remote.ts'
const roots: Context[] = []
@@ -29,6 +33,9 @@ async function harness(): Promise<{ ctx: Context; agents: ApiSessionAgentControl
await ctx.plugin(TypertRegistry)
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
installSessionReadTestServices(ctx)
ctx.sessionProjections.register(agentPresetProjectionDefinition)
installModelSelectionProjection(ctx)
ctx.provide('agentDefaultModel', {
currentSelection: () => ({ provider: 'fixture', model: 'fixture-model' }),
saveSelection: () => Promise.resolve(),
@@ -45,6 +52,10 @@ function header(id: string, cwd: string | null = '/workspace'): SessionHeader {
}
}
function providePersistence(ctx: Context, persistence: Record<string, unknown>): () => void {
return ctx.provide('sessionPersistence', testSessionPersistence(ctx, persistence) as never)
}
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
@@ -67,48 +78,94 @@ describe('ApiSession identity failures', () => {
.toContain('belongs to "/existing"')
})
it('rejects absent persistence, catalog misses, and cwd-less inspected artifacts', async () => {
it('maps absent and cwd-less point observations to not found', async () => {
const ctx = new Context()
roots.push(ctx)
await ctx.plugin(SessionStore)
installSessionReadTestServices(ctx)
await expect(inspectApiSession(ctx, SessionId('missing')))
.rejects.toThrow('session persistence is not configured')
.rejects.toBeInstanceOf(ApiSessionNotFound)
const inspect = vi.fn(() => Promise.resolve({ meta: header('missing'), events: [] as SessionEvent[] }))
const disposeMissing = ctx.provide('sessionPersistence', {
const inspect = vi.fn(() => Promise.resolve(undefined))
const disposeMissing = providePersistence(ctx, {
list: () => Promise.resolve([]),
inspect,
} as never)
})
await expect(inspectApiSession(ctx, SessionId('missing'))).rejects.toBeInstanceOf(ApiSessionNotFound)
expect(inspect).not.toHaveBeenCalled()
expect(inspect).toHaveBeenCalledOnce()
disposeMissing()
const listed = header('cwd-less-catalog', null)
const disposeListed = ctx.provide('sessionPersistence', {
const disposeListed = providePersistence(ctx, {
list: () => Promise.resolve([listed]),
inspect,
} as never)
inspect: () => Promise.resolve({ meta: listed, events: [] }),
})
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', {
providePersistence(ctx, {
list: () => Promise.resolve([catalog]),
inspect: () => Promise.resolve({ meta: inspected, events: [] }),
} as never)
})
await expect(inspectApiSession(ctx, catalog.id)).rejects.toBeInstanceOf(ApiSessionNotFound)
})
it('forwards an explicit inspection signal', async () => {
const ctx = new Context()
roots.push(ctx)
await ctx.plugin(SessionStore)
installSessionReadTestServices(ctx)
const meta = header('signalled-inspection')
const inspect = vi.fn(() => Promise.resolve({ meta, events: [] }))
providePersistence(ctx, { inspect })
const signal = new AbortController().signal
await expect(inspectApiSession(ctx, meta.id, signal)).resolves.toEqual({ meta, events: [] })
expect(inspect).toHaveBeenCalledWith(meta.id, signal)
})
})
describe('ApiSession Agent lookup and recovery', () => {
it('resumes directly from a retained observation and rejects an invalid observed header', async () => {
const { ctx, agents } = await harness()
const meta = header('observed-resume')
const resumed = unpublishedAgent(ctx, meta)
const resume = vi.spyOn(ctx.agents, 'resume').mockResolvedValue({
agent: resumed,
dispose: () => Promise.resolve(),
})
const observed = {
source: 'prepared',
header: meta,
events: [],
cursor: -1,
projections: { asOfSeq: -1, values: {} },
retain: vi.fn(),
[Symbol.dispose]: vi.fn(),
} as unknown as SessionObservation
await expect(agents.resolveObservedAgent(observed)).resolves.toEqual({ agent: resumed })
expect(resume).toHaveBeenCalledWith(expect.objectContaining({ resumeSessionId: meta.id }))
const invalid = {
...observed,
header: header('observed-without-cwd', null),
} as SessionObservation
await expect(agents.resolveObservedAgent(invalid)).resolves.toMatchObject({
error: { code: 'session-not-found' },
})
})
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', {
providePersistence(ctx, {
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')
@@ -119,10 +176,10 @@ describe('ApiSession Agent lookup and recovery', () => {
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', {
providePersistence(ordinary.ctx, {
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)
@@ -132,10 +189,10 @@ describe('ApiSession Agent lookup and recovery', () => {
const child = await harness()
const childMeta = header('child-race')
child.ctx.provide('sessionPersistence', {
providePersistence(child.ctx, {
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' },
@@ -149,25 +206,84 @@ describe('ApiSession Agent lookup and recovery', () => {
it('reports not-found and ordinary resume failures without fabricating an Agent', async () => {
const missing = await harness()
missing.ctx.provide('sessionPersistence', {
providePersistence(missing.ctx, {
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', {
providePersistence(failed.ctx, {
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 },
})
})
it('requires projected observations before activation', async () => {
const { agents } = await harness()
const meta = header('unprojected-observation')
const observed = {
source: 'prepared',
header: meta,
events: [],
cursor: -1,
retain: vi.fn(),
[Symbol.dispose]: vi.fn(),
} as unknown as SessionObservation
expect(() => agents.presetForObservation(observed)).toThrow(
'Agent activation requires a projected Session observation',
)
})
})
describe('ApiSession model selection', () => {
it('requires the model-selection projection', async () => {
const { ctx, agents } = await harness()
const live = agent(ctx, header('missing-model-projection'))
vi.spyOn(ctx.sessionProjections, 'stateOf').mockReturnValue(undefined)
expect(() => agents.selectionFor(live)).toThrow('required modelSelection projection')
})
it('reads a reasoning-free request and consumes only the exact pending selection', async () => {
const { ctx, agents } = await harness()
const logged = agent(ctx, header('logged-model'))
logged.session.append('request/header', {
header: { config: { provider: 'logged-provider', model: 'logged-model' } },
reason: 'initial',
})
expect(agents.selectionFor(logged).current).toEqual({
provider: 'logged-provider',
model: 'logged-model',
})
const pending = agent(ctx, header('pending-model'))
const selection = agents.selectionFor(pending)
agents.selectForNextRequest(pending, {
provider: 'selected-provider',
model: 'selected-model',
reasoningEffort: 'high' as never,
})
expect(selection.current).toMatchObject({
provider: 'selected-provider', model: 'selected-model', reasoningEffort: 'high',
})
expect(agents.consumeSelection(pending, 'other-provider', 'selected-model', 'high')).toBe(false)
expect(agents.consumeSelection(pending, 'selected-provider', 'other-model', 'high')).toBe(false)
expect(agents.consumeSelection(pending, 'selected-provider', 'selected-model', 'low')).toBe(false)
expect(agents.consumeSelection(pending, 'selected-provider', 'selected-model', 'high')).toBe(true)
expect(selection.current).toEqual({ provider: 'fixture', model: 'fixture-model' })
const untouched = agent(ctx, header('uninstalled-model'))
expect(agents.consumeSelection(untouched, 'fixture', 'fixture-model', undefined)).toBe(false)
})
})
describe('ApiSession create or adoption', () => {
@@ -252,10 +368,10 @@ describe('ApiSession create or adoption', () => {
time: 1,
data: { agentPreset: 'minimal' },
}] as SessionEvent[]
ctx.provide('sessionPersistence', {
providePersistence(ctx, {
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(),
@@ -278,10 +394,10 @@ describe('ApiSession create or adoption', () => {
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', {
providePersistence(child.ctx, {
list: () => Promise.resolve([childMeta]),
inspect: () => Promise.resolve({ meta: childMeta, events: [] }),
} as never)
})
child.ctx.provide('agentPresets', {
resolve: () => {
child.ctx.sessions.create(childMeta.id, {
@@ -297,10 +413,10 @@ describe('ApiSession create or adoption', () => {
const conflict = await harness()
const stored = header('stored-cwd-conflict', '/stored')
conflict.ctx.provide('sessionPersistence', {
providePersistence(conflict.ctx, {
list: () => Promise.resolve([stored]),
inspect: () => Promise.resolve({ meta: stored, events: [] }),
} as never)
})
await expect(conflict.agents.ensureSession(stored.id, '/requested', true))
.rejects.toBeInstanceOf(ApiSessionCwdConflict)
})
@@ -11,6 +11,7 @@ import {
ApiSessionCwdConflict,
} from '../src/agent.ts'
import { SessionCommandController } from '../src/commands.ts'
import { installSessionReadTestServices, testSessionPersistence } from './test-remote.ts'
async function expectFailure(operation: Promise<unknown>, code: string): Promise<void> {
await expect(operation).rejects.toMatchObject({ failure: { code } })
@@ -20,6 +21,8 @@ function controllerAgents(overrides: object = {}): ApiSessionAgentController {
return {
ensureSession: () => Promise.resolve(),
composeAgent: () => Promise.resolve({ setup: () => {} }),
presetForSession: () => undefined,
presetForObservation: () => undefined,
...overrides,
} as unknown as ApiSessionAgentController
}
@@ -28,6 +31,7 @@ async function baseContext(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
installSessionReadTestServices(ctx)
ctx.provide('agentDefaultModel', {
currentSelection: () => ({ provider: 'fixture', model: 'fixture-model' }),
saveSelection: () => Promise.resolve(),
@@ -167,23 +171,23 @@ function resolvedHandle(ctx: Context, sessionId: SessionId): AgentHandle {
}
describe('Session fork failures', () => {
it('distinguishes missing cold sources from unavailable persistence', async () => {
const unavailable = await baseContext()
unavailable.provide('workspaceRegistry', { list: () => [] } as never)
it('maps missing cold sources with and without persistence', async () => {
const withoutPersistence = await baseContext()
withoutPersistence.provide('workspaceRegistry', { list: () => [] } as never)
const unavailableController = new SessionCommandController(
unavailable, controllerAgents(), '/default',
withoutPersistence, controllerAgents(), '/default',
)
await expectFailure(unavailableController.fork({
sessionId: SessionId('missing'),
}), 'internal')
await unavailable.fiber.dispose()
}), 'session-not-found')
await withoutPersistence.fiber.dispose()
const missing = await baseContext()
missing.provide('workspaceRegistry', { list: () => [] } as never)
missing.provide('sessionPersistence', {
missing.provide('sessionPersistence', testSessionPersistence(missing, {
list: () => Promise.resolve([]),
inspect: vi.fn(),
} as never)
}) as never)
const missingController = new SessionCommandController(missing, controllerAgents(), '/default')
await expectFailure(missingController.fork({
sessionId: SessionId('missing'),
@@ -191,6 +195,16 @@ describe('Session fork failures', () => {
await missing.fiber.dispose()
})
it('maps an observation failure to an internal fork error', async () => {
const ctx = await baseContext()
ctx.provide('workspaceRegistry', { list: () => [] } as never)
vi.spyOn(ctx.sessionQuery, 'observeSession').mockRejectedValue(new Error('storage offline'))
const controller = new SessionCommandController(ctx, controllerAgents(), '/default')
await expectFailure(controller.fork({ sessionId: SessionId('unreadable') }), 'internal')
await ctx.fiber.dispose()
})
it('rejects a Session with no completed turn', async () => {
const ctx = await baseContext()
ctx.provide('workspaceRegistry', { list: () => [] } as never)
@@ -204,9 +218,8 @@ describe('Session fork failures', () => {
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)
vi.spyOn(lineage.sessionQuery, 'traceSession')
.mockRejectedValue(new Error('lineage unavailable'))
const child = completedSession(lineage, 'subagent-source', '/workspace', {
parentSession: SessionId('parent'),
origin: 'subagent',
@@ -3,12 +3,13 @@ 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 { createAssistantMessage, 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'
import { installSessionReadTestServices, testSessionPersistence } from './test-remote.ts'
async function commandHarness(): Promise<{
ctx: Context
@@ -142,10 +143,11 @@ async function persistedController(
await ctx.plugin(SessionStore)
const sessionId = SessionId('cold-attachment')
const meta: SessionHeader = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' }
ctx.provide('sessionPersistence', {
ctx.provide('sessionPersistence', testSessionPersistence(ctx, {
list: () => Promise.resolve([meta]),
inspect: () => Promise.resolve({ meta, events }),
} as never)
}) as never)
installSessionReadTestServices(ctx)
ctx.provide('attachments', { readImage } as never)
const agents = { resolveAgent: vi.fn() } as unknown as ApiSessionAgentController
return { ctx, controller: new SessionCommandController(ctx, agents, '/workspace'), sessionId }
@@ -158,15 +160,31 @@ describe('Session attachment authorization', () => {
const inserted = imageRef('inserted')
const streamed = imageRef('streamed')
const events = [
event('fixture/direct', 0, {
{ ...event('fixture/direct', 0, {
content: [null, [], { type: 'tool-result', content: [{ type: 'text', text: 'none' }] }, {
type: 'tool-result', content: [{ type: 'image', attachment: nested }],
}],
}), ignorable: true as const },
{ ...event('assistant/message', 1, {
turn: 1,
step: 1,
message: createAssistantMessage({
content: [{ type: 'image', attachment: message }],
source: { provider: 'fixture', model: 'fixture' },
}),
}), surfaceOp: 'append' as const },
event('agent/inbox/spliced', 2, {
target: 'next-turn',
start: 0,
inserted: [createUserMessage({
content: [{ type: 'image', attachment: inserted }],
source: { kind: 'user' },
})],
}),
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 } },
turn: 1,
step: 1,
chunk: { type: 'block-end', index: 0, block: { type: 'image', attachment: streamed } },
}),
]
const readImage = vi.fn((ref: ImageAttachmentRef) => Promise.resolve({ ref, data: Uint8Array.of(1) }))
@@ -183,6 +201,7 @@ describe('Session attachment authorization', () => {
it('maps missing persistence identities and attachment backend failures', async () => {
const noPersistence = new Context()
await noPersistence.plugin(SessionStore)
installSessionReadTestServices(noPersistence)
const noPersistenceController = new SessionCommandController(
noPersistence,
{ resolveAgent: vi.fn() } as unknown as ApiSessionAgentController,
@@ -190,14 +209,15 @@ describe('Session attachment authorization', () => {
)
await expectFailure(noPersistenceController.attachment({
sessionId: SessionId('missing'), attachmentId: AttachmentId('att'),
}), 'internal')
}), 'session-not-found')
const missing = new Context()
await missing.plugin(SessionStore)
missing.provide('sessionPersistence', {
missing.provide('sessionPersistence', testSessionPersistence(missing, {
list: () => Promise.resolve([]),
inspect: vi.fn(),
} as never)
}) as never)
installSessionReadTestServices(missing)
const missingController = new SessionCommandController(
missing,
{ resolveAgent: vi.fn() } as unknown as ApiSessionAgentController,
@@ -223,4 +243,21 @@ describe('Session attachment authorization', () => {
await fixture.ctx.fiber.dispose()
}
})
it('maps a cold observation failure to an internal authorization error', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
installSessionReadTestServices(ctx)
vi.spyOn(ctx.sessionQuery, 'observeSession').mockRejectedValue(new Error('storage offline'))
const controller = new SessionCommandController(
ctx,
{ resolveAgent: vi.fn() } as unknown as ApiSessionAgentController,
'/workspace',
)
await expectFailure(controller.attachment({
sessionId: SessionId('unreadable'), attachmentId: AttachmentId('att'),
}), 'internal')
await ctx.fiber.dispose()
})
})
@@ -104,6 +104,29 @@ describe('Session control jobs baseline', () => {
})
describe('Session control jobs updates', () => {
it('publishes existing unowned jobs when a Session attaches after the stream opens', async () => {
const { ctx, control } = await harness(true)
const abort = new AbortController()
const iterator = control.control(abort.signal)[Symbol.asyncIterator]()
await expect(iterator.next()).resolves.toMatchObject({ value: { type: 'baseline' } })
const task = producer('already running')
const id = ctx.jobs.start(task.spec)
await expect(iterator.next()).resolves.toMatchObject({ value: { type: 'jobs' } })
const created = ctx.sessions.create(SessionId('late-session'))
await expect(iterator.next()).resolves.toMatchObject({
value: {
type: 'jobs',
sessionId: created.id,
jobs: [expect.objectContaining({ id, label: 'already running' })],
},
})
task.settle({ status: 'completed' })
abort.abort()
await iterator.return?.()
})
it('pushes the owner whole set on registration, stopping, and settlement', async () => {
const { ctx, session, agent, control } = await harness(true)
const abort = new AbortController()
@@ -200,16 +223,4 @@ describe('Session control jobs updates', () => {
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')
})
})
@@ -5,7 +5,9 @@ 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'
import SessionController from '../src/index.ts'
import type { ApiSessionAgentController } from '../src/agent.ts'
import { createSessionTestController, testSessionPersistence } from './test-remote.ts'
const defaults = {
defaultModelSelection: () => ({ provider: 'fixture', model: 'fixture-model' }),
@@ -13,6 +15,10 @@ const defaults = {
}
describe('SessionController facade', () => {
it('does not require the Tools service', () => {
expect(SessionController.inject).not.toContain('tools')
})
it('owns Host service methods and publishes Agent lifecycle projections', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
@@ -26,10 +32,10 @@ describe('SessionController facade', () => {
}
const events: SessionEvent[] = []
const inspect = vi.fn(() => Promise.resolve({ meta: header, events }))
ctx.provide('sessionPersistence', {
ctx.provide('sessionPersistence', testSessionPersistence(ctx, {
list: () => Promise.resolve([header]),
inspect,
} as never)
}) as never)
const controller = createSessionTestController(ctx, defaults)
const status = vi.fn()
const failure = vi.fn()
@@ -49,6 +55,10 @@ describe('SessionController facade', () => {
ctx,
} as Agent
ctx.agents.register(agent)
const consumeSelection = vi.spyOn(
(controller as unknown as { agents: ApiSessionAgentController }).agents,
'consumeSelection',
)
await expect(controller.resolveAgent(sessionId)).resolves.toEqual({ agent })
await expect(controller.inspect(sessionId)).resolves.toEqual({ meta: header, events })
@@ -62,6 +72,21 @@ describe('SessionController facade', () => {
expect(status).toHaveBeenCalledWith(sessionId, true)
expect(failure).toHaveBeenCalledWith(sessionId, expect.stringContaining('fixture failure'))
expect(activity).toHaveBeenCalledWith(sessionId, expect.any(Number))
session.append('request/header', {
header: { config: { provider: 'fixture', model: 'fixture-model' } },
reason: 'initial',
})
expect(consumeSelection).toHaveBeenCalledWith(
agent, 'fixture', 'fixture-model', undefined,
)
const unowned = ctx.sessions.create(SessionId('controller-unowned'), {
meta: { cwd: '/workspace' },
})
unowned.append('request/header', {
header: { config: { provider: 'fixture', model: 'other-model' } },
reason: 'initial',
})
expect(consumeSelection).toHaveBeenCalledTimes(1)
const abort = new AbortController()
const iterator = controller.follow({
@@ -69,9 +94,103 @@ describe('SessionController facade', () => {
}, abort.signal)[Symbol.asyncIterator]()
await expect(iterator.next()).resolves.toMatchObject({
done: false,
value: { type: 'opened', cursor: 0 },
value: { type: 'snapshot', cursor: 1 },
})
abort.abort()
await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined })
})
it.each(['success', 'domain-error', 'throw'] as const)(
'promotes a prepared follow observation in the background: %s',
async (outcome) => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
const sessionId = SessionId(`background-${outcome}`)
const header: SessionHeader = {
version: 0, id: sessionId, createdAt: 1, cwd: '/workspace',
}
ctx.provide('sessionPersistence', testSessionPersistence(ctx, {
list: () => Promise.resolve([header]),
inspect: () => Promise.resolve({ meta: header, events: [] }),
}) as never)
const controller = createSessionTestController(ctx, defaults)
const agents = (controller as unknown as { agents: ApiSessionAgentController }).agents
const apiError = vi.fn()
ctx.on('api-session/error', apiError)
const logError = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {})
const live = { id: sessionId, session: { id: sessionId }, ctx, status: 'idle' } as unknown as Agent
const resolve = vi.spyOn(agents, 'resolveObservedAgent')
if (outcome === 'success') resolve.mockResolvedValue({ agent: live })
else if (outcome === 'domain-error') {
resolve.mockResolvedValue({
error: { code: 'internal', message: 'activation unavailable', details: {} },
})
} else {
resolve.mockRejectedValue(new Error('activation crashed'))
}
const abort = new AbortController()
const iterator = controller.follow({
address: { kind: 'session', sessionId },
}, abort.signal)[Symbol.asyncIterator]()
await expect(iterator.next()).resolves.toMatchObject({ value: { type: 'snapshot' } })
const waiting = iterator.next()
await vi.waitFor(() => { expect(resolve).toHaveBeenCalledOnce() })
if (outcome === 'domain-error') {
await vi.waitFor(() => {
expect(apiError).toHaveBeenCalledWith(sessionId, 'activation unavailable')
})
} else if (outcome === 'throw') {
await vi.waitFor(() => {
expect(logError).toHaveBeenCalledWith(expect.stringContaining('activation crashed'))
})
} else {
expect(apiError).not.toHaveBeenCalled()
}
abort.abort()
await expect(waiting).resolves.toMatchObject({ done: true })
await ctx.fiber.dispose()
},
)
it('waits for an admitted background promotion during teardown', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
const sessionId = SessionId('background-disposal')
const header: SessionHeader = {
version: 0, id: sessionId, createdAt: 1, cwd: '/workspace',
}
ctx.provide('sessionPersistence', testSessionPersistence(ctx, {
list: () => Promise.resolve([header]),
inspect: () => Promise.resolve({ meta: header, events: [] }),
}) as never)
const controller = createSessionTestController(ctx, defaults)
const agents = (controller as unknown as { agents: ApiSessionAgentController }).agents
const started = Promise.withResolvers<undefined>()
const release = Promise.withResolvers<undefined>()
vi.spyOn(agents, 'resolveObservedAgent').mockImplementation(async () => {
started.resolve(undefined)
await release.promise
return {
agent: { id: sessionId, session: { id: sessionId }, ctx, status: 'idle' } as unknown as Agent,
}
})
const iterator = controller.follow({
address: { kind: 'session', sessionId },
}, new AbortController().signal)[Symbol.asyncIterator]()
await expect(iterator.next()).resolves.toMatchObject({ value: { type: 'snapshot' } })
const waiting = iterator.next()
await started.promise
let disposed = false
const disposal = ctx.fiber.dispose().then(() => { disposed = true })
await Promise.resolve()
expect(disposed).toBe(false)
release.resolve(undefined)
await disposal
await expect(waiting).resolves.toMatchObject({ done: true })
})
})
@@ -142,7 +142,7 @@ export function plainTurn(startSeq: number, turn: number, ask: string, answer: s
]
}
/** Wrap raw events as view-less history entries (the wire shape history returns). */
/** Wrap raw events in the journal envelope returned by history. */
export function entries(events: readonly SessionEvent[]): { event: SessionEvent }[] {
return events.map(event => ({ event }))
}
@@ -12,9 +12,9 @@ import type {
SessionControlFrame,
SessionFollowFrame,
SessionFollowRequest,
SessionModels,
SessionPage,
SessionPageRequest,
SessionProjectionBaseline,
SessionSelectModelRequest,
SessionSelectModelValue,
} from '@deepseek-ai/dsh-api-session-controller/types'
@@ -23,6 +23,7 @@ import type { WorkspaceFollowFrame } from '@deepseek-ai/dsh-api-workspace-contro
import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
import {
RemoteStream,
RemoteStreamError,
type RemoteStreamOptions,
} from '@deepseek-ai/dsh-api-gateway/client'
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
@@ -54,12 +55,6 @@ 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
@@ -128,12 +123,6 @@ export class FakeApiClient implements IApiClient {
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: {
@@ -147,7 +136,7 @@ export class FakeApiClient implements IApiClient {
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<RpcResponse<SessionPage & { readonly projections?: SessionProjectionBaseline }>> =
() => Promise.resolve(ok({ events: [], hasMore: false }))
onPrompt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
@@ -186,7 +175,6 @@ export class FakeApiClient implements IApiClient {
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 = {
@@ -292,7 +280,12 @@ export class FakeApiClient implements IApiClient {
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: [] }))),
models: payload => this.record('llm.models', payload, Promise.resolve(ok({
default: { provider: 'fixture', model: 'fixture' },
routableProviders: [],
groups: [],
failures: [],
}))),
discoverModels: payload => this.record('llm.discoverModels', payload, Promise.resolve(ok({ models: [] }))),
}
@@ -312,7 +305,6 @@ export class FakeApiClient implements IApiClient {
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,
@@ -412,14 +404,6 @@ export class FakeApiClient implements IApiClient {
}
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)
}
@@ -466,25 +450,42 @@ export class FakeApiClient implements IApiClient {
): 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)
this.calls.push({ method: 'session.follow', payload: request })
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 }
const response = await this.onHistory({
sessionId,
maxMessages: request.maxMessages ?? 50,
})
if (!response.result.ok) {
throw new RemoteStreamError(
response.result.error.code,
response.result.error.message,
response.result.error.details,
)
}
const page = response.result.value
const cursor = this.followCursor ?? page.events.at(-1)?.event.seq ?? -1
yield {
type: 'snapshot',
header: {
version: 0,
id: sessionId,
createdAt: 0,
...(request.address.kind === 'subagent'
? { origin: 'subagent' as const, parentSession: request.address.parentSessionId }
: {}),
},
cursor,
events: page.events.filter(entry => entry.event.seq <= cursor),
hasMore: page.hasMore,
projections: page.projections ?? { asOfSeq: cursor, values: {} },
}
yield* stream.values
} finally {
stream.dispose()
if (initialPage !== undefined && this.openingPages.get(key) === initialPage) {
this.openingPages.delete(key)
}
}
}
@@ -310,9 +310,15 @@ describe('subagent catalogs', () => {
})
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('session.follow')).toEqual([
{
address: {
kind: 'subagent', parentSessionId: S1, childSessionId: S2, mode: 'continuable',
},
maxMessages: 50,
},
])
expect(api.callsOf('subagent.history')).toEqual([])
expect(api.callsOf('subagent.prompt')).toEqual([
{
parentSessionId: S1, childSessionId: S2, mode: 'continuable',
@@ -507,6 +513,7 @@ describe('subagent catalogs', () => {
api.onSubagentList = () => first.promise
const manager = new SessionManager(api, fakeRemote(api), root)
const refresh = manager.refreshSubagents(root)
manager.setSubagentCatalogOpen(root, true)
// A membership frame arrives while the pull is in flight; the debounced
// refresh it schedules fires 50ms later and is coalesced into the pull —
@@ -768,17 +775,37 @@ describe('connected generation', () => {
expect(api.callsOf('session.history')).toHaveLength(historyCallsBefore)
})
it('reloads the durable parent address for a restored child selection', async () => {
it('retains the durable parent address and refreshes its catalogs across reconnect', async () => {
const api = new FakeApiClient()
const address = {
parentSessionId: S1, childSessionId: S2, mode: 'continuable' as const,
}
const parent = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
const child = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
api.onSubagentList = payload => (
(payload as { parentSessionId: SessionId }).parentSessionId === S1
? parent.promise
: child.promise
)
const manager = new SessionManager(api, fakeRemote(api), S2, address)
manager.handleConnected()
expect(manager.get(S2).getSnapshot().subagent).toEqual({ address })
parent.resolve(ok({ entries: [], parentAvailable: true }))
child.resolve(ok({ entries: [], parentAvailable: true }))
await vi.waitFor(() => {
expect(api.callsOf('subagent.list')).toContainEqual({ parentSessionId: S1 })
expect(api.callsOf('session.list')).toHaveLength(1)
})
await vi.waitFor(() => {
expect(api.callsOf('subagent.list')).toEqual([
{ parentSessionId: S1 },
{ parentSessionId: S2 },
])
})
expect(manager.get(S2).getSnapshot().subagent).toEqual({
address,
parentAvailable: true,
})
expect(manager.getListSnapshot().currentAddress).toEqual(address)
})
@@ -4,19 +4,21 @@
* isolation, and prompt failure mapping.
*/
import { describe, expect, it, vi } from 'vitest'
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 { subagentIdentityProjectionDefinition } from '@deepseek-ai/dsh-subagent/src/projection.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 { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionPromptRequest, SessionRequestId } from '../src/types.ts'
import {
PersistenceCoordinator,
@@ -24,7 +26,12 @@ import {
type PersistenceBackend,
type StoredSessionSource,
} from '@deepseek-ai/dsh-session-persistence'
import { createSessionTestRemote } from './test-remote.ts'
import { ApiSessionList } from '../src/list.ts'
import {
createSessionTestRemote,
installSessionReadTestServices,
testSessionPersistence,
} from './test-remote.ts'
const sid = (id: string): SessionId => id as SessionId
@@ -46,8 +53,12 @@ function header(id: string, createdAt: number, extra: Partial<SessionHeader> = {
return { version: 0, id: sid(id), createdAt, cwd: '/proj', ...extra }
}
function providePersistence(ctx: Context, persistence: Record<string, unknown>): () => void {
return ctx.provide('sessionPersistence', testSessionPersistence(ctx, persistence) as never)
}
describe('sessions.list cold merge', () => {
it('verifies only small possibly-blank artifacts and treats every unavailable probe as visible', async () => {
it('fully observes only small possibly-blank artifacts and treats unavailable probes as visible', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const root = mkdtempSync(join(tmpdir(), 'dsh-cold-'))
@@ -63,8 +74,9 @@ describe('sessions.list cold merge', () => {
header('locationless', 500, { parentSession: sid('session-parent'), origin: 'subagent' }),
header('vanished', 600),
header('read-failure', 700),
{ version: 0, id: sid('missing-cwd'), createdAt: 800 },
]
const readFrom = vi.fn(async (id: SessionId) => {
const inspect = vi.fn(async (id: SessionId) => {
if (id === sid('small-blank')) {
return {
meta: metas[0]!,
@@ -87,7 +99,7 @@ describe('sessions.list cold merge', () => {
if (id === sid('read-failure')) throw new Error('simulated read failure')
throw new Error(`unexpected cold read: ${id}`)
})
ctx.provide('sessionPersistence', {
providePersistence(ctx, {
list: () => Promise.resolve(metas),
locate: (meta: SessionHeader) => {
if (meta.id === sid('large-unknown')) return { kind: 'jsonl', path: largePath }
@@ -95,8 +107,8 @@ describe('sessions.list cold merge', () => {
if (meta.id === sid('vanished')) return { kind: 'jsonl', path: join(root, 'vanished.log') }
return { kind: 'jsonl', path: smallPath }
},
readFrom,
} as never)
inspect,
})
ctx.provide('sessionProjectionCache', {
cachedSnapshot: (meta: SessionHeader) => {
if (meta.id === sid('small-blank')) {
@@ -110,6 +122,8 @@ describe('sessions.list cold merge', () => {
}
return undefined
},
hydratePrepared: (session: Session, _meta: SessionHeader, events: readonly SessionEvent[]) =>
ctx.sessionProjections.hydrate(session, {}, events, 0),
} as never)
const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
@@ -118,10 +132,8 @@ describe('sessions.list cold merge', () => {
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,
@@ -131,24 +143,25 @@ describe('sessions.list cold merge', () => {
})
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([
expect(byId['missing-cwd']).toBeUndefined()
expect(inspect).toHaveBeenCalledTimes(3)
expect(inspect.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 () => {
it('can disable bounded cold observations 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', {
const inspect = vi.fn()
providePersistence(ctx, {
list: () => Promise.resolve([meta]),
locate: () => ({ kind: 'jsonl', path: '/not-read' }),
readFrom,
} as never)
inspect,
})
const remote = createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
cwd: '/tmp',
@@ -160,31 +173,23 @@ describe('sessions.list cold merge', () => {
expect(response.value.items).toEqual([
expect.objectContaining({ sessionId: meta.id, blank: false, updatedAt: meta.createdAt }),
])
expect(readFrom).not.toHaveBeenCalled()
expect(inspect).not.toHaveBeenCalled()
})
it('replaces a probed cold row with the live Session that attached during the read', async () => {
it('prefers a live row attached during the query without folding its seed', 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 meta = header('attached-during-list', 100)
const started = Promise.withResolvers<undefined>()
const release = Promise.withResolvers<undefined>()
ctx.provide('sessionPersistence', {
list: () => Promise.resolve([meta]),
locate: () => ({ kind: 'jsonl', path }),
readFrom: async () => {
providePersistence(ctx, {
list: async () => {
started.resolve(undefined)
await release.promise
return {
meta,
events: [{ type: 'session/end-seed', seq: 0, time: 110, data: {} }] as SessionEvent[],
}
return [meta]
},
} as never)
})
const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const listing = remote.list(request({}))
@@ -213,10 +218,89 @@ describe('sessions.list cold merge', () => {
sessionId: meta.id,
blank: false,
running: true,
updatedAt: 300,
updatedAt: 100,
}),
])
})
it('prefers a Session that attaches during its bounded cold observation', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
const root = mkdtempSync(join(tmpdir(), 'dsh-cold-race-'))
const path = join(root, 'small.log')
writeFileSync(path, 'small')
const meta = header('attached-during-probe', 100)
providePersistence(ctx, {
list: () => Promise.resolve([meta]),
locate: () => ({ kind: 'jsonl', path }),
inspect: () => {
const session = ctx.sessions.create(meta.id, {
meta,
seed: [{ type: 'turn/start', seq: 0, time: 200, data: { turn: 1 } }],
})
ctx.agents.register({ id: session.id, session, status: 'running', ctx } as Agent)
return Promise.resolve({ meta, events: [] })
},
})
const remote = createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp',
})
const response = await remote.list(request({}))
if (!response.ok) throw new Error('list failed')
expect(response.value.items).toEqual([
expect.objectContaining({ sessionId: meta.id, running: true, blank: false }),
])
await ctx.fiber.dispose()
})
it('propagates a cold location failure instead of returning a partial list', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const meta = header('broken-cache', 100)
providePersistence(ctx, {
list: () => Promise.resolve([meta]),
locate: () => { throw new Error('location failed') },
})
const remote = createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp',
})
await expect(remote.list(request({}))).resolves.toMatchObject({
ok: false,
error: { message: expect.stringContaining('location failed') as string },
})
await ctx.fiber.dispose()
})
it('supports an unsignalled probe whose observation has no projection registry', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
installSessionReadTestServices(ctx)
const root = mkdtempSync(join(tmpdir(), 'dsh-cold-unprojected-'))
const path = join(root, 'small.log')
writeFileSync(path, 'small')
const meta = header('unprojected-small', 100)
ctx.provide('sessionPersistence', {
list: () => Promise.resolve([meta]),
locate: () => ({ kind: 'jsonl', path }),
} as never)
vi.spyOn(ctx.sessionQuery, 'listSessions').mockResolvedValue([{
header: meta, live: false, persisted: true,
}])
vi.spyOn(ctx.sessionQuery, 'observeSession').mockResolvedValue({
source: 'prepared', header: meta, events: [], cursor: -1,
retain: vi.fn(), [Symbol.dispose]: vi.fn(),
})
const list = new ApiSessionList(ctx, 1024)
await expect(list.list()).resolves.toEqual([
expect.objectContaining({ sessionId: meta.id, blank: false }),
])
await ctx.fiber.dispose()
})
})
describe('attached updatedAt tracks human prompts', () => {
@@ -225,6 +309,7 @@ describe('attached updatedAt tracks human prompts', () => {
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
await new Promise(resolve => setTimeout(resolve, 0))
// Old work, resumed just now: the log tail would report the pickup.
const worked = 1_000_000
@@ -248,7 +333,7 @@ describe('attached updatedAt tracks human prompts', () => {
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)
expect(summary?.updatedAt).toBe(500)
// A lifecycle boundary is not a human update.
resumed.append('turn/start', { turn: 2 })
@@ -300,11 +385,12 @@ describe('cold history recovery view', () => {
list: () => Promise.resolve([structuredClone(meta)]),
}
const coordinator = new PersistenceCoordinator(ctx, backend)
ctx.provide('sessionPersistence', {
providePersistence(ctx, {
list: (signal?: AbortSignal) => backend.list(signal),
inspect: (id: SessionId, signal?: AbortSignal) => coordinator.inspect(id, signal),
borrowSession: (id: SessionId, signal?: AbortSignal) => coordinator.borrowSession(id, signal),
locate: () => undefined,
} as never)
})
const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const history = await remote.page({
@@ -351,11 +437,11 @@ describe('Remote Agent and Session lookup policy', () => {
const sessionId = sid('session-remote-cold')
const meta = header(sessionId, 1000)
const inspect = vi.fn(() => Promise.resolve({ meta, events: [] as SessionEvent[] }))
ctx.provide('sessionPersistence', {
providePersistence(ctx, {
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>()
@@ -395,11 +481,11 @@ describe('Remote Agent and Session lookup policy', () => {
origin: 'subagent',
})
const inspect = vi.fn(() => Promise.resolve({ meta: coldMeta, events: [] as SessionEvent[] }))
ctx.provide('sessionPersistence', {
providePersistence(ctx, {
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' },
})
@@ -451,27 +537,35 @@ describe('subagent ownership fence', () => {
type: 'user/message',
seq: 1,
time: 2,
data: { content: [{ type: 'text', text: 'work' }], source: { kind: 'user' } },
data: createUserMessage({ 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' },
data: snapshotSubagentDescriptor({
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', {
providePersistence(ctx, {
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' })
ctx.sessionProjections.register(subagentIdentityProjectionDefinition)
const history = await new SessionHistoryController(ctx).page({
const history = await new SessionHistoryController(
ctx,
(observation) => { observation[Symbol.dispose]() },
).page({
address: {
kind: 'subagent',
parentSessionId: meta.parentSession as SessionId,
@@ -521,11 +615,11 @@ describe('subagent ownership fence', () => {
data: { version: 2, mode: 'continuable', provider: 'spawn', label: 'child' },
},
] as SessionEvent[]
ctx.provide('sessionPersistence', {
providePersistence(ctx, {
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
@@ -588,9 +682,13 @@ describe('subagent ownership fence', () => {
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 selection = await remote.selectModel(request({
sessionId: startingChild.id,
provider: 'p',
model: 'm',
}))
expect(selection.ok).toBe(false)
if (!selection.ok) expect(selection.error.code).toBe('agent-busy')
const create = await remote.create(request({ sessionId: originChild.id, cwd: '/proj' }))
expect(create.ok).toBe(false)
@@ -695,7 +793,7 @@ describe('subagent ownership fence', () => {
})
describe('degenerate composition (no persistence, no factory)', () => {
it('list skips the cold merge and history reports missing persistence as internal', async () => {
it('lists no cold rows and reports an absent point source as not found', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
@@ -712,20 +810,19 @@ describe('degenerate composition (no persistence, no factory)', () => {
})
expect(response.ok).toBe(false)
if (!response.ok) {
expect(response.error.code).toBe('internal')
expect(response.error.message).toMatch(/session persistence is not configured/)
expect(response.error.code).toBe('session-not-found')
}
})
it('maps a persistence catalog miss to session-not-found without inspection', async () => {
it('maps a missing direct persistence read to session-not-found', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
const inspect = vi.fn()
ctx.provide('sessionPersistence', {
providePersistence(ctx, {
list: () => Promise.resolve([]),
inspect,
} as never)
})
const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const response = await remote.page({
@@ -734,7 +831,7 @@ describe('degenerate composition (no persistence, no factory)', () => {
})
expect(response.ok).toBe(false)
if (!response.ok) expect(response.error.code).toBe('session-not-found')
expect(inspect).not.toHaveBeenCalled()
expect(inspect).toHaveBeenCalledOnce()
})
})
@@ -777,11 +874,11 @@ describe('sessions.prompt synchronous rejection', () => {
await ctx.plugin(AgentRegistry)
const sessionId = sid('race-resume')
const meta: SessionHeader = header('race-resume', 1000)
ctx.provide('sessionPersistence', {
providePersistence(ctx, {
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' } })
@@ -799,10 +896,10 @@ describe('sessions.prompt synchronous rejection', () => {
})
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({
const selection = await remote.selectModel(request({ sessionId, provider: 'p', model: 'm' }))
expect(selection.ok).toBe(false)
if (!selection.ok) {
expect(selection.error).toMatchObject({
code: 'agent-busy',
details: { reason: 'use subagent delivery for this child session' },
})
@@ -10,7 +10,9 @@ 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'
import {
createSessionTestRemote, installSessionReadTestServices, testSessionPersistence,
} from './test-remote.ts'
const sid = (id: string): SessionId => id as SessionId
@@ -23,6 +25,7 @@ async function composed(workspaces: readonly Workspace[] = []): Promise<Context>
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt, { persona: '' })
await ctx.plugin(AgentRegistry)
installSessionReadTestServices(ctx)
ctx.provide('workspaceRegistry', { list: () => workspaces } as never)
ctx.agents.setFactory({
createAgent: async (ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle> => {
@@ -116,18 +119,16 @@ describe('sessions.fork', () => {
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)
vi.spyOn(ctx.sessionQuery, 'traceSession').mockResolvedValue({
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 },
})
const response = await remote(ctx).fork(request({ sessionId: grandchild.id }))
@@ -165,19 +166,10 @@ describe('sessions.fork', () => {
},
{ type: 'turn/end', seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' } } },
] as SessionEvent[]
ctx.provide('sessionPersistence', {
ctx.provide('sessionPersistence', testSessionPersistence(ctx, {
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)
}) as never)
const resume = vi.spyOn(ctx.agents, 'resume')
const response = await remote(ctx).fork(request({ sessionId: sourceId }))
@@ -0,0 +1,304 @@
/** Raw Session journal transport and message-aligned pagination coverage. */
import { describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import SessionStore from '@deepseek-ai/dsh-session'
import { CallId, createMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
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, installSessionReadTestServices } from './test-remote.ts'
/** 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(AgentRegistry)
installSessionReadTestServices(ctx)
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: 'snapshot' },
})
return { [Symbol.asyncIterator]: () => iterator }
}
describe('Session history raw journal', () => {
it('follows raw tool events and preserves result metadata without a Tools service', async () => {
const { ctx } = await harness()
const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } })
const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() })
const abort = new AbortController()
const stream = await openFollow(history, session.id, abort.signal)
const collected = collect(stream, 2, abort)
const call = session.append('tool/call', {
turn: 1, step: 1, callId: CallId('raw-call'), name: 'custom', arguments: '{malformed',
})
const result = session.append('tool/result', {
turn: 1, step: 1,
message: createToolResultMessage({
callId: CallId('raw-call'),
content: [{ type: 'text', text: 'raw output' }],
isError: false,
}),
meta: { nested: { count: 2 }, paths: ['a.ts', 'b.ts'] },
}, { surfaceOp: 'append' })
const frames = await collected
expect(frames).toEqual([
{ type: 'event', event: call },
{ type: 'event', event: result },
])
expect((frames[1] as Extract<SessionFollowFrame, { type: 'event' }>).event.data)
.toMatchObject({ meta: { nested: { count: 2 }, paths: ['a.ts', 'b.ts'] } })
})
it('follows live results without rescanning Session history', async () => {
const { ctx } = await harness()
const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } })
const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() })
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', event: { type: 'tool/call', data: { callId: 'live-fast' } } },
})
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', event: { type: 'tool/result', data: { message: { source: { callId: 'live-fast' } } } } },
})
} finally {
events.mockRestore()
abort.abort()
await iterator.next()
await ctx.fiber.dispose()
}
})
it('serves raw call and result entries without parsing tool arguments', async () => {
const { ctx } = await harness()
const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } })
const start = session.append('turn/start', { turn: 1 })
const call = session.append('tool/call', {
turn: 1, step: 1, callId: CallId('history-call'), name: 'custom', arguments: '{broken',
})
const result = session.append('tool/result', {
turn: 1, step: 1,
message: createToolResultMessage({
callId: CallId('history-call'),
content: [{ type: 'text', text: 'failed raw output' }],
isError: true,
}),
meta: { persisted: true, count: 3 },
}, { 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')
expect(response.value.events).toEqual([
{ event: start },
{ event: call },
{ event: result },
])
})
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(undefined, { meta: { cwd: '/workspace' } })
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(undefined, { meta: { cwd: '/workspace' } })
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('follows a result after turn/end without reading the addressed Session log', async () => {
const { ctx } = await harness()
const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } })
const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() })
const abort = new AbortController()
const stream = await openFollow(history, session.id, abort.signal)
const iterator = stream[Symbol.asyncIterator]()
session.append('turn/start', { turn: 1 })
await expect(iterator.next()).resolves.toMatchObject({ value: { event: { type: 'turn/start' } } })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-late'), name: 'term', arguments: '{"cmd":"tail"}' })
await expect(iterator.next()).resolves.toMatchObject({ value: { event: { type: 'tool/call' } } })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
await expect(iterator.next()).resolves.toMatchObject({ value: { event: { type: 'turn/end' } } })
const events = vi.spyOn(session, 'events', 'get').mockImplementation(() => {
throw new Error('live result rescanned Session history')
})
try {
const result = session.append('tool/result', {
turn: 1, step: 1,
message: createToolResultMessage({
callId: CallId('c-late'),
content: [{ type: 'text', text: 'ok' }],
isError: false,
}),
}, { surfaceOp: 'append' })
await expect(iterator.next()).resolves.toEqual({
done: false,
value: { type: 'event', event: result },
})
} finally {
events.mockRestore()
abort.abort()
await iterator.next()
await ctx.fiber.dispose()
}
})
})
@@ -1,411 +0,0 @@
/**
* 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' } })
})
})
@@ -19,6 +19,7 @@ import type {
import SessionStore from '@deepseek-ai/dsh-session'
import type { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionPromptRequest, SessionRequestId } from '../src/types.ts'
import { buildModelCatalog } from '../src/catalog.ts'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol'
import { createSessionTestRemote } from './test-remote.ts'
@@ -146,6 +147,14 @@ function registerTextOnly(ctx: Context): void {
}('Text Only', []))
}
/** Resolve the Client-visible next selection from durable state and the Host default. */
function currentSelection(ctx: Context, sessionId: SessionId) {
const session = ctx.sessions.get(sessionId)
if (session === undefined) throw new Error('expected a live test Session')
return ctx.sessionProjections.snapshot(session).values.modelSelection?.next
?? ctx.agentDefaultModel.currentSelection()
}
describe('Web session model selection', () => {
it('validates an ordered image batch before persisting any member', async () => {
const { ctx, agent, sessionId } = await harness()
@@ -227,7 +236,7 @@ describe('Web session model selection', () => {
type: 'image' as const,
attachment: { attachmentId: 'att-history', mediaType: 'image/png' as const, bytes: 1, width: 1, height: 1 },
}
agent.session.append('user/message', {
const imageEvent = 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({
@@ -238,8 +247,8 @@ describe('Web session model selection', () => {
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),
surfaceOp: { op: 'replace', start: imageEvent.seq, end: imageEvent.seq },
sourceEventSeqs: [imageEvent.seq],
})
;(agent.inbox.nextTurn as UserMessage[]).push({
id: 'pending-image', role: 'user', source: { kind: 'user' }, content: [image],
@@ -290,10 +299,10 @@ describe('Web session model selection', () => {
model: 'private-preview',
reasoningEffort: ReasoningEffortId('max'),
})
const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp' })
createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp' })
const catalog = expectValue(await remote.models(request({ sessionId })))
expect(catalog.current).toEqual({
const catalog = await buildModelCatalog(ctx)
expect(currentSelection(ctx, sessionId)).toEqual({
provider: 'deepseek-official',
model: 'private-preview',
reasoningEffort: 'max',
@@ -324,7 +333,7 @@ describe('Web session model selection', () => {
})
it('preserves optional catalog metadata and string provider failures', async () => {
const { ctx, sessionId } = await harness()
const { ctx } = await harness()
ctx.llm.registerAdapter(['plain'], new CatalogAdapter('Plain', [
{ provider: 'plain', id: 'plain-model', name: 'Plain Model' },
]))
@@ -339,12 +348,12 @@ describe('Web session model selection', () => {
return Promise.reject('string catalog failure')
}
}('String Failure', []))
const remote = createSessionTestRemote(ctx, {
createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
cwd: '/tmp',
})
const catalog = expectValue(await remote.models(request({ sessionId })))
const catalog = await buildModelCatalog(ctx)
expect(catalog.groups).toEqual(expect.arrayContaining([
{ id: 'plain', name: 'Plain', models: [{ id: 'plain-model', name: 'Plain Model' }] },
{
@@ -371,10 +380,8 @@ describe('Web session model selection', () => {
const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 }
const signal = new AbortController().signal
expect(expectValue(await remote.models(request({ sessionId }))).current)
expect(currentSelection(ctx, sessionId))
.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,
@@ -389,7 +396,7 @@ describe('Web session model selection', () => {
})
await expect(agentEvents(ctx, agent).waterfall(
'agent/request', { turn: 1, step: 0, signal }, () => Promise.resolve(seed),
)).resolves.toMatchObject({ provider: 'deepseek-official', model: 'deepseek-chat' })
)).resolves.toEqual(seed)
expect((await ctx.systemPrompt.assemble()).variables)
.toMatchObject({ provider: 'deepseek-official', model: 'private-preview' })
@@ -440,7 +447,7 @@ describe('Web session model selection', () => {
details: { provider: 'remote-rejected' },
},
})
expect(expectValue(await remote.models(request({ sessionId }))).current)
expect(currentSelection(ctx, sessionId))
.toEqual({ provider: 'deepseek-official', model: 'private-preview', reasoningEffort: 'max' })
await ctx.fiber.dispose()
})
@@ -448,18 +455,18 @@ describe('Web session model selection', () => {
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, {
createSessionTestRemote(ctx, {
defaultModelSelection: () => stored,
cwd: '/tmp',
})
expect(expectValue(await remote.models(request({ sessionId }))).current)
expect(currentSelection(ctx, sessionId))
.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)
expect(currentSelection(ctx, sessionId))
.toEqual({ provider: 'deepseek-official', model: 'deepseek-reasoner' })
await ctx.fiber.dispose()
})
@@ -470,13 +477,13 @@ describe('Web session model selection', () => {
model: 'deepseek-chat',
})
let stored = { provider: 'deepseek-official', model: 'deepseek-chat' }
const remote = createSessionTestRemote(ctx, {
createSessionTestRemote(ctx, {
defaultModelSelection: () => stored,
cwd: '/tmp',
})
stored = { provider: 'duplicate', model: 'same' }
expect(expectValue(await remote.models(request({ sessionId }))).current)
expect(currentSelection(ctx, sessionId))
.toEqual({ provider: 'deepseek-official', model: 'deepseek-chat' })
await ctx.fiber.dispose()
})
@@ -512,7 +519,7 @@ describe('Web session model selection', () => {
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)
expect(currentSelection(ctx, sessionId))
.toEqual({ provider: 'deepseek-official', model: 'deepseek-chat', reasoningEffort: 'high' })
await ctx.fiber.dispose()
})
@@ -533,15 +540,16 @@ describe('Web session model selection', () => {
ok: false,
error: { code: 'model-unavailable', details: { provider: 'deleted-gateway', model: 'deleted-model' } },
})
expect(expectValue(await remote.models(request({ sessionId }))).routable).toBe(false)
const unavailableCatalog = await buildModelCatalog(ctx)
expect(unavailableCatalog.routableProviders.includes(currentSelection(ctx, sessionId).provider)).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)
const catalog = await buildModelCatalog(ctx)
expect(catalog.routableProviders.includes(currentSelection(ctx, sessionId).provider)).toBe(true)
expect(catalog.groups.flatMap(group => group.models.map(model => model.id)))
.not.toContain('unlisted-but-served')
await ctx.fiber.dispose()
@@ -549,18 +557,18 @@ describe('Web session model selection', () => {
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, {
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 })))
const catalog = await buildModelCatalog(ctx)
// 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(currentSelection(ctx, sessionId)).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()
@@ -6,7 +6,7 @@ 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 { agentPresetProjectionDefinition, 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'
@@ -38,8 +38,9 @@ async function harness(presets?: readonly string[]) {
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)
if (presets !== undefined) {
ctx.provide('agentPresets', roster(presets) as never)
}
const factory: AgentFactory = {
async createAgent(_ownerCtx, options) {
@@ -63,6 +64,7 @@ async function harness(presets?: readonly string[]) {
defaultModelSelection: () => ({ provider: 'test', model: 'test-model' }),
cwd,
})
if (presets !== undefined) ctx.sessionProjections.register(agentPresetProjectionDefinition)
return { ctx, remote }
}
@@ -12,6 +12,7 @@ import { Context } from '@deepseek-ai/cordis'
import { z } from 'zod'
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
import { AttachmentStore } from '@deepseek-ai/dsh-attachment'
import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-presets'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
@@ -19,7 +20,7 @@ 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 type { SessionControlFrame, SessionFollowFrame } from '@deepseek-ai/dsh-api-session-controller/types'
import { createSessionTestRemote, type TestSessionRemote } from './test-remote.ts'
declare module '@deepseek-ai/dsh-session-projection/types' {
@@ -48,6 +49,24 @@ function page(
})
}
/** Read and close one snapshot-first follow generation. */
async function opening(
remote: TestSessionRemote,
sessionId: SessionId,
maxMessages?: number,
): Promise<Extract<SessionFollowFrame, { type: 'snapshot' }>> {
const abort = new AbortController()
const iterator = remote.follow({
address: { kind: 'session', sessionId },
...(maxMessages === undefined ? {} : { maxMessages }),
}, abort.signal)[Symbol.asyncIterator]()
const first = await iterator.next()
abort.abort()
await iterator.return?.()
if (first.done || first.value.type !== 'snapshot') throw new Error('follow did not open with a snapshot')
return first.value
}
/** Whole-value unit folding the latest user/message text; null before the first. */
type LastUserState = { text: string } | null
const lastUserUnit = () => ({
@@ -77,7 +96,7 @@ async function harness(withRegistry: boolean): Promise<{ ctx: Context; session:
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
if (withRegistry) await ctx.plugin(SessionProjectionRegistry)
const session = ctx.sessions.create()
const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } })
// The gateway reads both the session and durable inbox baseline.
ctx.agents.register({ id: session.id, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), status: 'idle', ctx } as Agent)
return { ctx, session }
@@ -96,33 +115,57 @@ function seedMessages(session: Session, count: number): void {
const remote = (ctx: Context) => createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
describe('session.history projections block', () => {
it('tracks pending and used model selections across repeated request headers', async () => {
const { ctx, session } = await harness(true)
remote(ctx)
await new Promise(resolve => setTimeout(resolve, 0))
const selected = { provider: 'p', model: 'next' }
session.append('model/selection', selected)
session.append('model/selection', selected)
session.append('request/header', {
header: { config: { provider: 'p', model: 'used' } }, reason: 'initial',
})
session.append('request/header', {
header: { config: { provider: 'p', model: 'used' } }, reason: 'initial',
})
expect(ctx.sessionProjections.snapshot(session).values.modelSelection).toEqual({
lastUsed: { provider: 'p', model: 'used' },
next: selected,
})
session.append('request/header', {
header: { config: selected }, reason: 'initial',
})
expect(ctx.sessionProjections.snapshot(session).values.modelSelection).toEqual({
lastUsed: selected,
next: selected,
})
})
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' })
const snapshot = await opening(remote(ctx), session.id)
const { events, projections } = snapshot
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)
expect(events.at(-1)?.event.seq).toBe(projections.asOfSeq)
})
it('cuts attached projections and events at the requested follow cursor', async () => {
it('returns a complete current replacement cut on each follow generation', 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')
const snapshot = await opening(remote(ctx), session.id)
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' } }),
expect(snapshot.events.map(entry => entry.event.seq)).toEqual([0, 1])
expect(snapshot.projections.asOfSeq).toBe(1)
expect(snapshot.projections.values).toEqual(
expect.objectContaining({ 'test/last-user': { text: 'm1' } }),
)
})
@@ -130,12 +173,11 @@ describe('session.history projections block', () => {
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')
const snapshot = await opening(remote(ctx), session.id)
expect(response.value.events).toEqual([])
expect(response.value.projections?.asOfSeq).toBe(-1)
expect(response.value.projections?.values).toEqual(
expect(snapshot.events).toEqual([])
expect(snapshot.projections.asOfSeq).toBe(-1)
expect(snapshot.projections.values).toEqual(
expect.objectContaining({ 'test/last-user': null }),
)
})
@@ -157,10 +199,10 @@ describe('session.history projections block', () => {
readImage(): Promise<never> { return Promise.reject(new Error('unused')) }
})
const gateway = remote(ctx)
await new Promise(resolve => setTimeout(resolve, 0))
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)
const snapshot = await opening(gateway, session.id)
expect(snapshot.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()
@@ -186,10 +228,8 @@ describe('session.history projections block', () => {
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)
const snapshot = await opening(remote(ctx), session.id)
expect('imageLimits' in snapshot.projections.values).toBe(false)
})
it('never carries the block on loadOlder pages (beforeSeq present)', async () => {
@@ -236,9 +276,8 @@ describe('session.history projections block', () => {
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 history = await opening(proxy, session.id)
expect('test/internal-count' in history.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)
@@ -250,18 +289,16 @@ describe('session.history projections block', () => {
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' })
const before = await opening(proxy, session.id)
expect(before.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')
const after = await opening(proxy, session.id)
// 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({
expect(after.projections.asOfSeq).toBe(session.seq - 1)
expect('test/last-user' in after.projections.values).toBe(false)
expect(after.projections.values.sessionListMetadata).toEqual({
blank: true,
lastPromptAt: session.events.at(-1)?.time,
})
@@ -284,7 +321,7 @@ describe('session.history projections block', () => {
})
describe('session.list projections column', () => {
it('serves attached rows from the live registry cut, watermarked for client seeding', async () => {
it('serves every already-materialized wire value from the live registry without folding', async () => {
const { ctx, session } = await harness(true)
ctx.sessionProjections.register(lastUserUnit())
const gateway = remote(ctx)
@@ -302,6 +339,37 @@ describe('session.list projections column', () => {
expect(row?.projections?.asOfSeq).toBe(session.seq - 1)
})
it('lists the latest preset selected by a blank Session instead of its creation preset', async () => {
const { ctx } = await harness(true)
const session = ctx.sessions.create(SessionId('preset-list'), {
meta: { cwd: '/workspace', agentPreset: 'standard' },
})
ctx.sessionProjections.register(agentPresetProjectionDefinition)
const gateway = remote(ctx)
await new Promise(resolve => setTimeout(resolve, 0))
session.append('agent-preset/selected', { agentPreset: 'minimal' })
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.agentPreset).toBe('minimal')
})
it('omits an unmaterialized live projection instead of folding history for listing', async () => {
const { ctx, session } = await harness(true)
seedMessages(session, 1)
const unit = lastUserUnit()
const apply = vi.fn(unit.apply)
ctx.sessionProjections.register({ ...unit, apply })
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('test/last-user' in (row?.projections?.values ?? {})).toBe(false)
expect(apply).not.toHaveBeenCalled()
})
it('omits the column entirely when no registry is mounted', async () => {
const { ctx, session } = await harness(false)
seedMessages(session, 1)
@@ -312,7 +380,7 @@ describe('session.list projections column', () => {
expect(row !== undefined && 'projections' in row).toBe(false)
})
it('serves cold rows from the persisted projection cache with zero log loads', async () => {
it('serves every available cold projection hint from the 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') }
@@ -327,14 +395,28 @@ describe('session.list projections column', () => {
// 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' } } }
? {
asOfSeq: 7,
values: {
'test/last-user': { text: 'cached' },
sessionListMetadata: { blank: false, lastPromptAt: 6 },
title: 'Cached title',
},
}
: 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' } } })
expect(row?.projections).toEqual({
asOfSeq: 7,
values: {
'test/last-user': { text: 'cached' },
sessionListMetadata: { blank: false, lastPromptAt: 6 },
title: 'Cached title',
},
})
})
it('cold rows without a cache plugin (or without a stored row) just lack the column', async () => {
@@ -421,9 +503,8 @@ describe('Session control projection frames', () => {
{ 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)
const tail = await opening(proxy, session.id)
expect(tail.projections.asOfSeq).toBe(pushes.at(-1)?.seq)
})
it('emits no projection frames when the composition has no registry', async () => {
@@ -6,22 +6,18 @@
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 {
SessionQueryEngine,
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) }
})
import { ApiSessionList } from '../src/list.ts'
const sid = (value: string): SessionId => value as SessionId
const defaults = { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }
@@ -63,7 +59,48 @@ async function baseContext(): Promise<Context> {
return ctx
}
/** Real query core with a programmable full-text provider for Host search tests. */
class SearchSessionQuery extends SessionQueryEngine {
constructor(
ctx: Context,
private readonly search: (
...args: Parameters<SessionQueryEngine['searchSessions']>
) => Promise<unknown>,
) {
super(ctx)
}
override searchSessions(
...args: Parameters<SessionQueryEngine['searchSessions']>
): ReturnType<SessionQueryEngine['searchSessions']> {
return this.search(...args) as ReturnType<SessionQueryEngine['searchSessions']>
}
override searchEvents(): Promise<never> {
return Promise.reject(new Error('event search is not configured in this test'))
}
}
function installSearchQuery(
ctx: Context,
searchSessions: (
...args: Parameters<SessionQueryEngine['searchSessions']>
) => Promise<unknown>,
): void {
new SearchSessionQuery(ctx, searchSessions)
}
describe('session.search', () => {
it('rejects search when the query service is absent', async () => {
const ctx = await baseContext()
const list = new ApiSessionList(ctx, 0)
await expect(list.search('query', new AbortController().signal)).rejects.toMatchObject({
failure: { code: 'internal' },
})
await ctx.fiber.dispose()
})
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') })
@@ -111,7 +148,7 @@ describe('session.search', () => {
},
],
}))
ctx.provide('sessionQuery', { searchSessions } as never)
installSearchQuery(ctx, searchSessions)
const remote = createSessionTestRemote(ctx, defaults)
const signal = new AbortController().signal
@@ -147,7 +184,7 @@ describe('session.search', () => {
const ctx = await baseContext()
ctx.sessions.create(sid('visible'), { meta: header('visible') })
const searchSessions = vi.fn()
ctx.provide('sessionQuery', { searchSessions } as never)
installSearchQuery(ctx, searchSessions)
const remote = createSessionTestRemote(ctx, defaults)
for (const query of ['', ' ', 'contains\0nul', 'x'.repeat(501)]) {
@@ -161,7 +198,7 @@ describe('session.search', () => {
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)
installSearchQuery(ctx, searchSessions)
const remote = createSessionTestRemote(ctx, defaults)
const response = await remote.search(
@@ -187,16 +224,14 @@ describe('session.search', () => {
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)
installSearchQuery(ctx, () => 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' }),
],
}))
const response = await createSessionTestRemote(ctx, defaults).search(
request('match'),
@@ -224,9 +259,7 @@ describe('session.search', () => {
nextCursor: 'page-2',
})
.mockResolvedValueOnce({ items: items.slice(19) })
ctx.provide('sessionQuery', {
searchSessions,
} as never)
installSearchQuery(ctx, searchSessions)
const response = await createSessionTestRemote(ctx, defaults).search(
request('match'),
new AbortController().signal,
@@ -266,7 +299,7 @@ describe('session.search', () => {
...end < items.length ? { nextCursor: `offset-${end}` } : {},
})
})
ctx.provide('sessionQuery', { searchSessions } as never)
installSearchQuery(ctx, searchSessions)
const response = await createSessionTestRemote(ctx, defaults).search(
request('adaptive-page-limit'),
@@ -309,7 +342,7 @@ describe('session.search', () => {
nextCursor: `page-${searchSessions.mock.calls.length}`,
})
})
ctx.provide('sessionQuery', { searchSessions } as never)
installSearchQuery(ctx, searchSessions)
const response = await createSessionTestRemote(ctx, defaults).search(
request('endless-pages'),
@@ -374,7 +407,7 @@ describe('session.search', () => {
return Promise.reject(new Error('unexpected provider call'))
}
})
ctx.provide('sessionQuery', { searchSessions } as never)
installSearchQuery(ctx, searchSessions)
const response = await createSessionTestRemote(ctx, defaults).search(
request('stale-restart'),
@@ -413,7 +446,7 @@ describe('session.search', () => {
nextCursor: `cursor-${searchSessions.mock.calls.length}`,
})
})
ctx.provide('sessionQuery', { searchSessions } as never)
installSearchQuery(ctx, searchSessions)
const response = await createSessionTestRemote(ctx, defaults).search(
request('stale-churn'),
@@ -442,7 +475,7 @@ describe('session.search', () => {
controller.abort()
return Promise.reject(stale)
})
ctx.provide('sessionQuery', { searchSessions } as never)
installSearchQuery(ctx, searchSessions)
const response = await createSessionTestRemote(ctx, defaults).search(
request('abort-stale'),
@@ -463,7 +496,7 @@ describe('session.search', () => {
'provider generation changed before paging',
'SESSION_QUERY_STALE_CURSOR',
)))
ctx.provide('sessionQuery', { searchSessions } as never)
installSearchQuery(ctx, searchSessions)
const response = await createSessionTestRemote(ctx, defaults).search(
request('first-page-stale'),
@@ -487,7 +520,7 @@ describe('session.search', () => {
'continuation limit is invalid',
'SESSION_QUERY_INVALID_LIMIT',
))
ctx.provide('sessionQuery', { searchSessions } as never)
installSearchQuery(ctx, searchSessions)
const response = await createSessionTestRemote(ctx, defaults).search(
request('continuation-invalid-limit'),
@@ -514,7 +547,7 @@ describe('session.search', () => {
'SESSION_QUERY_INVALID_LIMIT',
),
))
ctx.provide('sessionQuery', { searchSessions } as never)
installSearchQuery(ctx, searchSessions)
const response = await createSessionTestRemote(ctx, defaults).search(
request('minimum-page-limit'),
@@ -540,7 +573,7 @@ describe('session.search', () => {
'SESSION_QUERY_INVALID_LIMIT',
))
})
ctx.provide('sessionQuery', { searchSessions } as never)
installSearchQuery(ctx, searchSessions)
const response = await createSessionTestRemote(ctx, defaults).search(
request('abort-invalid-limit'),
@@ -559,7 +592,7 @@ describe('session.search', () => {
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)
installSearchQuery(ctx, searchSessions)
const response = await createSessionTestRemote(ctx, defaults).search(
request('oversized-page'),
@@ -585,7 +618,7 @@ describe('session.search', () => {
}
return Promise.resolve({ items: oversized })
})
ctx.provide('sessionQuery', { searchSessions } as never)
installSearchQuery(ctx, searchSessions)
const response = await createSessionTestRemote(ctx, defaults).search(
request('adapted-oversized-page'),
@@ -611,9 +644,7 @@ describe('session.search', () => {
snippet: `${expected}${'y'.repeat(10_000)}`,
},
}
ctx.provide('sessionQuery', {
searchSessions: () => Promise.resolve({ items: [overlong] }),
} as never)
installSearchQuery(ctx, () => Promise.resolve({ items: [overlong] }))
const response = await createSessionTestRemote(ctx, defaults).search(
request('bounded-snippet'),
@@ -635,7 +666,7 @@ describe('session.search', () => {
const searchSessions = vi.fn()
.mockResolvedValueOnce({ items: [], nextCursor: 'repeated' })
.mockResolvedValueOnce({ items: [], nextCursor: 'repeated' })
ctx.provide('sessionQuery', { searchSessions } as never)
installSearchQuery(ctx, searchSessions)
const response = await createSessionTestRemote(ctx, defaults).search(
request('repeated-cursor'),
@@ -658,7 +689,7 @@ describe('session.search', () => {
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)
installSearchQuery(ctx, searchSessions)
const response = await createSessionTestRemote(ctx, defaults).search(
request('repeated-lookahead-cursor'),
@@ -685,7 +716,7 @@ describe('session.search', () => {
.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)
installSearchQuery(ctx, searchSessions)
const response = await createSessionTestRemote(ctx, defaults).search(
request('duplicate-pages'),
@@ -713,7 +744,7 @@ describe('session.search', () => {
controller.abort()
return Promise.resolve({ items: [] })
})
ctx.provide('sessionQuery', { searchSessions } as never)
installSearchQuery(ctx, searchSessions)
const response = await createSessionTestRemote(ctx, defaults).search(
request('cancel-continuation'),
@@ -743,7 +774,7 @@ describe('session.search', () => {
const searchSessions = vi.fn((_request: SessionSearchRequest) => Promise.resolve({
items: [hit('cold-32750')],
}))
ctx.provide('sessionQuery', { searchSessions } as never)
installSearchQuery(ctx, searchSessions)
const response = await createSessionTestRemote(ctx, defaults).search(
request('large corpus'),
@@ -761,12 +792,13 @@ describe('session.search', () => {
expect(searchSessions.mock.calls[0]?.[0]).not.toHaveProperty('sessionFilters')
})
it('propagates cancellation through visible-session collection and stops cold-summary work', async () => {
it('propagates cancellation through the lightweight visibility listing', 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)
controller.abort()
return Promise.resolve(cold)
})
let locateCalls = 0
@@ -774,12 +806,11 @@ describe('session.search', () => {
list,
locate: () => {
locateCalls++
controller.abort()
return undefined
},
} as never)
const searchSessions = vi.fn()
ctx.provide('sessionQuery', { searchSessions } as never)
installSearchQuery(ctx, searchSessions)
const response = await createSessionTestRemote(ctx, defaults).search(
request('cancel-during-visibility'),
@@ -791,53 +822,34 @@ describe('session.search', () => {
error: { code: 'cancelled' },
})
expect(list).toHaveBeenCalledOnce()
expect(locateCalls).toBe(1)
expect(locateCalls).toBe(0)
expect(searchSessions).not.toHaveBeenCalled()
})
it('awaits every started cold-summary stat before returning cancellation', async () => {
it('does not stat or locate cold artifacts while collecting search visibility', 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)
}
const locate = vi.fn((meta: SessionHeader) => ({ kind: 'jsonl', path: `/logs/${meta.id}.jsonl` }))
ctx.provide('sessionPersistence', {
list: () => Promise.resolve(cold),
locate: (meta: SessionHeader) => ({ kind: 'jsonl', path: `/logs/${meta.id}.jsonl` }),
locate,
} as never)
const searchSessions = vi.fn()
ctx.provide('sessionQuery', { searchSessions } as never)
const searchSessions = vi.fn(() => Promise.resolve({ items: [] }))
installSearchQuery(ctx, searchSessions)
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
const response = await createSessionTestRemote(ctx, defaults).search(
request('header-only-visibility'),
new AbortController().signal,
)
expect(response).toMatchObject({
ok: false,
error: { code: 'cancelled' },
ok: true,
value: { items: [], hasMore: false },
})
expect(searchSessions).not.toHaveBeenCalled()
expect(locate).not.toHaveBeenCalled()
expect(searchSessions).toHaveBeenCalledOnce()
})
it('maps missing composition, query cancellation, and provider failure', async () => {
it('maps preflight cancellation, query cancellation, and provider failure', async () => {
const missingCtx = await baseContext()
missingCtx.sessions.create(sid('visible'), { meta: header('visible') })
const missingApi = createSessionTestRemote(missingCtx, defaults)
@@ -852,22 +864,13 @@ describe('session.search', () => {
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)
installSearchQuery(ctx, searchSessions)
const remote = createSessionTestRemote(ctx, defaults)
const cancelled = await remote.search(
@@ -4,7 +4,6 @@ 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'
@@ -26,12 +25,10 @@ function makeSession(
function follow(
api: FakeApiClient,
event: SessionEvent,
view?: SessionToolView,
): Promise<void> {
return api.pushFollow(SID, {
type: 'event',
event: event as never,
...(view === undefined ? {} : { view }),
})
}
@@ -44,7 +41,7 @@ function eventSeqs(session: Session): number[] {
}
function histResponse(events: SessionEvent[], hasMore = false) {
// history returns HistoryEntry[] ({event, view?}); these tests are view-less.
// History returns raw journal envelopes around each event.
return Promise.resolve(ok({ events: entries(events) as never[], hasMore }))
}
@@ -72,11 +69,12 @@ describe('Session open', () => {
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 () => {
it('is idempotent: concurrent opens share one follow, 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)
expect(api.callsOf('session.follow')).toHaveLength(1)
expect(api.callsOf('session.history')).toEqual([])
})
it('lands an error result in openState=error with the RpcError kept', async () => {
@@ -101,7 +99,7 @@ describe('Session open', () => {
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).
// Three live frames land while the opening snapshot is pending; seq 15 overlaps its tail.
const page = plainTurn(10, 0, '早', '安')
const deliveries = [
follow(api, ev.turnStart(15, 1)),
@@ -154,7 +152,7 @@ describe('live event path', () => {
// 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)
expect(api.callsOf('session.history')).toHaveLength(1)
})
await vi.waitFor(() => {
expect(eventSeqs(session)).toEqual(
@@ -175,8 +173,8 @@ describe('paging', () => {
await session.open()
await session.loadOlder()
const snapshot = session.getSnapshot()
expect(api.callsOf('session.follow')).toHaveLength(1)
expect(api.callsOf('session.history')).toMatchObject([
{ sessionId: SID, throughSeq: 11 },
{ sessionId: SID, throughSeq: 11, beforeSeq: 6 },
])
expect(snapshot.hasMore).toBe(false)
@@ -234,7 +232,8 @@ describe('paging', () => {
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
expect(api.callsOf('session.follow')).toHaveLength(1)
expect(api.callsOf('session.history')).toHaveLength(1)
})
})
@@ -251,9 +250,15 @@ describe('prompt and cancel errors', () => {
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('session.follow')).toEqual([
{
address: {
kind: 'subagent', parentSessionId: PARENT, childSessionId: SID, mode: 'continuable',
},
maxMessages: 50,
},
])
expect(api.callsOf('subagent.history')).toEqual([])
expect(api.callsOf('subagent.prompt')).toEqual([
{
parentSessionId: PARENT, childSessionId: SID, mode: 'continuable',
@@ -303,9 +308,15 @@ describe('prompt and cancel errors', () => {
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('session.follow')).toEqual([
{
address: {
kind: 'subagent', parentSessionId: PARENT, childSessionId: SID, mode: 'one-shot',
},
maxMessages: 50,
},
])
expect(api.callsOf('subagent.history')).toEqual([])
expect(api.callsOf('subagent.prompt')).toEqual([])
expect(api.callsOf('subagent.interrupt')).toEqual([])
expect(api.callsOf('session.cancel')).toEqual([])
@@ -575,7 +586,7 @@ describe('remaining branches', () => {
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) })
await vi.waitFor(() => { expect(api.callsOf('session.history')).toHaveLength(1) })
api.onHistory = () => histResponse(plainTurn(6, 1, 'c', 'd'))
const resynced = session.resync() // bumps the generation
repairPull.resolve(ok({
@@ -601,44 +612,35 @@ describe('remaining branches', () => {
await expect(session.dispose()).resolves.toBeUndefined()
})
it('carries history-entry and follow-frame views through the event feed', async () => {
it('carries raw history and follow events through the event feed', async () => {
const { api, session } = makeSession()
const callView = { for: 'call', view: { card: 'generic', title: '历史卡' } }
const historyCall = ev.toolCall(6, 1, 'h1', 'bash', '{"cmd":"pwd"}')
const historyResult = ev.toolResult(7, 1, 'h1', 'done')
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: '历史果' } } },
{ event: historyCall },
{ event: historyResult },
] 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: '历史果' } },
expect(windowEntries(session).slice(-2)).toEqual([
{ event: historyCall },
{ event: historyResult },
])
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: '直播果' },
})
const liveCall = ev.toolCall(8, 2, 'l1', 'write', '{"file_path":"a.ts"}')
await follow(api, liveCall)
expect(windowEntries(session).at(-1)).toEqual({ event: liveCall })
const liveResult = ev.toolResult(9, 2, 'l1', 'ok')
await follow(api, liveResult)
expect(windowEntries(session).at(-1)).toEqual({ event: liveResult })
})
})
describe('resync', () => {
it('keeps the old feed until one sorted page-and-live replacement is ready', async () => {
it('keeps the old feed until the reconnect snapshot, then repairs queued live gaps', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, '旧', '窗'))
await session.open()
@@ -652,11 +654,16 @@ describe('resync', () => {
})
const syncing = session.resync()
await vi.waitFor(() => { expect(api.callsOf('session.history')).toHaveLength(2) })
await vi.waitFor(() => { expect(api.callsOf('session.follow')).toHaveLength(2) })
expect(session.eventSource.getSnapshot()).toBe(oldWindow)
expect(publications).toEqual([])
await Promise.all([
api.onHistory = () => histResponse([
...plainTurn(10, 2, '终', '页'),
ev.user(16, '后到低位'),
ev.user(17, '后到高位'),
])
const liveDeliveries = Promise.all([
follow(api, ev.user(17, '后到高位')),
follow(api, ev.user(16, '后到低位')),
])
@@ -666,12 +673,15 @@ describe('resync', () => {
hasMore: false,
modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
}))
await syncing
await Promise.all([syncing, liveDeliveries])
await vi.waitFor(() => {
expect(eventSeqs(session)).toEqual([10, 11, 12, 13, 14, 15, 16, 17])
})
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])
expect(publications).toHaveLength(2)
expect(publications.map(snapshot => snapshot.change.kind)).toEqual(['replace', 'replace'])
expect(publications[0]?.entries.map(entry => entry.event.seq)).toEqual([10, 11, 12, 13, 14, 15])
expect(publications[1]?.entries.map(entry => entry.event.seq)).toEqual([10, 11, 12, 13, 14, 15, 16, 17])
off()
})
@@ -45,7 +45,7 @@ type FeedRow = {
origin?: 'subagent'
running?: boolean
blank?: boolean
agentPreset?: string
projections?: Record<string, unknown>
}
async function feedList(b: Bench, rows: FeedRow[]): Promise<void> {
@@ -55,7 +55,9 @@ async function feedList(b: Bench, rows: FeedRow[]): Promise<void> {
...(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 } : {}),
...(r.projections === undefined
? {}
: { projections: { asOfSeq: 0, values: r.projections } }),
})),
}) as never)
await b.svc.refresh()
@@ -81,19 +83,17 @@ describe('list store projection', () => {
expect(state.byId[sid('s2')]?.title).toBeUndefined()
})
it('reprojects a blank session whose composition switched and nothing else moved', async () => {
it('reprojects a blank session from the generic agent-preset projection', async () => {
const b = bench()
await feedList(b, [{ id: 's1', blank: true, agentPreset: 'standard' }])
expect(b.svc.list.getSnapshot().byId[sid('s1')]?.agentPreset).toBe('standard')
await feedList(b, [{ id: 's1', blank: true, projections: { agentPreset: 'standard' } }])
expect(b.svc.list.getSnapshot().byId[sid('s1')]?.projectionValues?.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')
b.svc.handleControlFrame({
type: 'projection', sessionId: sid('s1'), key: 'agentPreset', value: 'minimal', seq: 1,
})
await Promise.resolve()
expect(b.svc.list.getSnapshot().byId[sid('s1')]?.agentPreset).toBe('minimal')
expect(b.svc.list.getSnapshot().byId[sid('s1')]?.projectionValues?.agentPreset).toBe('minimal')
})
it('reflects live increments (host stream via manager) into the store', async () => {
@@ -214,6 +214,7 @@ describe('scope tree', () => {
b.svc.open(sid('s2'))
await vi.waitFor(() => { expect(b.api.activeFollows(sid('s1'))).toBe(0) })
notified.mockClear()
await b.api.pushFollow(sid('s1'), {
type: 'event',
event: { seq: 0, timestamp: 0, type: 'turn/start', data: { turn: 0 } } as never,
@@ -252,7 +253,7 @@ describe('Agent scope disposal lifecycle', () => {
...remote,
session: {
...remote.session,
follow: (_request, signal) => {
follow: (request, signal) => {
if (signal === undefined) throw new Error('fixture requires a signal')
followSignal = signal
let opened = false
@@ -263,7 +264,20 @@ describe('Agent scope disposal lifecycle', () => {
opened = true
return Promise.resolve({
done: false,
value: { type: 'opened', cursor: -1 } as const,
value: {
type: 'snapshot',
header: {
version: 0,
id: request.address.kind === 'session'
? request.address.sessionId
: request.address.childSessionId,
createdAt: 0,
},
cursor: -1,
events: [],
hasMore: false,
projections: { asOfSeq: -1, values: {} },
} as const,
})
}
return new Promise((_resolve, reject) => {
@@ -325,7 +339,14 @@ describe('Agent scope disposal lifecycle', () => {
opened = true
return Promise.resolve({
done: false,
value: { type: 'opened', cursor: -1 } as const,
value: {
type: 'snapshot',
header: { version: 0, id: sessionId, createdAt: 0 },
cursor: -1,
events: [],
hasMore: false,
projections: { asOfSeq: -1, values: {} },
} as const,
})
}
return new Promise<IteratorResult<SessionFollowFrame>>((_resolve, reject) => {
@@ -457,22 +478,22 @@ describe('binding and stage lifecycle', () => {
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')
const followStarts = () => b.api.followStarts.map(String)
// Resolution is addressing, not staging: no window pull.
b.svc.scope(sid('s1'))
b.svc.binding(sid('s1'))
expect(historyCalls()).toHaveLength(0)
expect(followStarts()).toEqual([])
b.svc.open(sid('s1'))
await vi.waitFor(() => {
expect(historyCalls().map(c => (c.payload as { sessionId: string }).sessionId)).toEqual(['s1'])
expect(followStarts()).toEqual(['s1'])
})
// Same current again: no second pull.
b.svc.open(sid('s1'))
expect(historyCalls()).toHaveLength(1)
expect(followStarts()).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'])
expect(followStarts()).toEqual(['s1', 's2'])
})
})
@@ -486,11 +507,10 @@ describe('binding and stage lifecycle', () => {
})
try {
const b = bench()
expect(b.api.calls.filter(c => c.method === 'session.history')).toHaveLength(0)
expect(b.api.followStarts).toEqual([])
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'])
expect(b.api.followStarts.map(String)).toEqual(['s1'])
})
} finally {
vi.unstubAllGlobals()
@@ -827,13 +847,12 @@ describe('coverage tails (branch duals)', () => {
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 vi.waitFor(() => { expect(b.api.followStarts).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.api.followStarts).toHaveLength(1)
expect(b.svc.list.getSnapshot().current).toBe('s1')
})
@@ -2,6 +2,16 @@
import type { Context } from '@deepseek-ai/cordis'
import type { ModelSelection as AgentModelSelection } from '@deepseek-ai/dsh-agent'
import type { SessionId } from '@deepseek-ai/dsh-session'
import {
SessionPersistenceCorruptionError,
SessionPersistenceNotFoundError,
SessionPersistenceRevision,
type BorrowedSessionSource,
type SessionInspection,
} from '@deepseek-ai/dsh-session-persistence'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import SessionQueryEngine from '@deepseek-ai/dsh-session-query'
import { vi } from 'vitest'
import {
TypertRemoteFailure,
@@ -18,10 +28,10 @@ import type {
SessionCreateValue,
SessionForkRequest,
SessionForkValue,
SessionFollowFrame,
SessionFollowRequest,
SessionListRequest,
SessionListValue,
SessionModels,
SessionModelsRequest,
SessionPage,
SessionPageRequest,
SessionPromptRequest,
@@ -41,7 +51,6 @@ 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>>
@@ -50,6 +59,7 @@ export interface TestSessionRemote {
updateQueue(request: SessionUpdateQueueRequest): Promise<RemoteResult<SessionUpdateQueueValue>>
cancel(request: SessionCancelRequest): Promise<RemoteResult<SessionCancelValue>>
page(request: SessionPageRequest, signal?: AbortSignal): Promise<RemoteResult<SessionPage>>
follow(request: SessionFollowRequest, signal?: AbortSignal): AsyncIterable<SessionFollowFrame>
control(signal?: AbortSignal): AsyncIterable<SessionControlFrame>
}
@@ -63,6 +73,73 @@ export interface TestSessionRemoteDefaults {
const installed = new WeakMap<Context, SessionController>()
type LegacyTestPersistence = Record<string, unknown> & {
readonly inspect?: (
sessionId: SessionId,
signal?: AbortSignal,
) => Promise<SessionInspection | undefined>
readonly borrowSession?: (
sessionId: SessionId,
signal?: AbortSignal,
) => Promise<BorrowedSessionSource>
}
/** Add the preparation-backed point-read contract to compact persistence doubles. */
export function testSessionPersistence(
ctx: Context,
persistence: LegacyTestPersistence,
): LegacyTestPersistence {
if (persistence.borrowSession !== undefined) return persistence
return {
...persistence,
borrowSession: async (sessionId, signal) => {
signal?.throwIfAborted()
const inspection = await persistence.inspect?.(sessionId, signal)
signal?.throwIfAborted()
if (inspection === undefined) throw new SessionPersistenceNotFoundError(sessionId)
try {
const preparedSession = ctx.sessions.prepare(inspection.meta.id, {
seed: [...inspection.events],
meta: inspection.meta,
seedSource: 'persistence',
})
return {
source: 'prepared',
inspection: {
meta: preparedSession.header,
events: Object.freeze([...inspection.events]),
},
revision: SessionPersistenceRevision(`test:${sessionId}:${String(preparedSession.seq)}`),
preparedSession,
[Symbol.dispose]: () => {},
}
} catch (error: unknown) {
throw new SessionPersistenceCorruptionError(
`test session "${sessionId}" failed validation: ${String(error)}`,
{ cause: error },
)
}
},
}
}
/** Concrete point-read query used by Session Controller tests that do not exercise search. */
class TestSessionQuery extends SessionQueryEngine {
override searchSessions(): Promise<never> {
return Promise.reject(new Error('session search is not configured in this test'))
}
override searchEvents(): Promise<never> {
return Promise.reject(new Error('event search is not configured in this test'))
}
}
/** Install the required projection and point-query services for direct controller tests. */
export function installSessionReadTestServices(ctx: Context): void {
if (ctx.get('sessionProjections') === undefined) new SessionProjectionRegistry(ctx)
if (ctx.get('sessionQuery') === undefined) new TestSessionQuery(ctx)
}
function installControllers(
ctx: Context,
defaults: TestSessionRemoteDefaults,
@@ -93,6 +170,7 @@ function installControllers(
},
} as never)
}
installSessionReadTestServices(ctx)
const cwd = vi.spyOn(process, 'cwd').mockReturnValue(defaults.cwd)
let controller: SessionController
try {
@@ -151,7 +229,6 @@ export function createSessionTestRemote(
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)),
@@ -166,6 +243,7 @@ export function createSessionTestRemote(
() => direct.page(request, signal),
signal,
),
follow: (request, signal = new AbortController().signal) => direct.follow(request, signal),
control: (signal = new AbortController().signal) => direct.control(signal),
}
}
@@ -43,6 +43,25 @@ function page(events: readonly SessionEventEntry[], hasMore = false): SessionPag
return { events, hasMore }
}
function snapshot(
cursor: number,
events: readonly SessionEventEntry[],
hasMore = false,
): SessionFollowFrame {
return {
type: 'snapshot',
header: {
version: 0,
id: ADDRESS.kind === 'session' ? ADDRESS.sessionId : ADDRESS.childSessionId,
createdAt: 0,
},
cursor,
events,
hasMore,
projections: { asOfSeq: cursor, values: {} },
}
}
function sessionClient(remote: SessionTransportRemote) {
return {
session: remote as SessionRemote,
@@ -108,14 +127,13 @@ describe('Session Client stream adapters', () => {
const remote = new ScriptedSessionRemote(
[{
frames: [
{ type: 'opened', cursor: 3 },
snapshot(3, [entry(2), entry(3)], true),
{ 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) },
],
)
@@ -129,9 +147,8 @@ describe('Session Client stream adapters', () => {
await vi.waitFor(() => { expect(changes).toHaveLength(2) })
await stream.prepend({ beforeSeq: 2, maxMessages: 50 })
expect(remote.followRequests).toEqual([{ address: ADDRESS }])
expect(remote.followRequests).toEqual([{ address: ADDRESS, maxMessages: 50 }])
expect(remote.pageRequests).toEqual([
{ address: ADDRESS, throughSeq: 3, maxMessages: 50 },
{ address: ADDRESS, throughSeq: 4, beforeSeq: 2, maxMessages: 50 },
])
expect(changes).toMatchObject([
@@ -143,20 +160,17 @@ describe('Session Client stream adapters', () => {
expect(remote.signals[0]?.aborted).toBe(true)
})
it('resumes after the applied cursor and repairs through the addressed tail page', async () => {
it('replaces the retained window from each reconnect snapshot', async () => {
const lost = new RemoteStreamCarrierError('lost')
const remote = new ScriptedSessionRemote(
[
{
frames: [{ type: 'opened', cursor: 1 }, { type: 'event', ...entry(2) }],
frames: [snapshot(1, [entry(0), entry(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)]) },
{ frames: [snapshot(4, [entry(0), entry(1), entry(2), entry(3), entry(4)])], hold: true },
],
[],
)
const changes: SessionJournalChange[] = []
const carrierFailed = vi.fn()
@@ -170,13 +184,10 @@ describe('Session Client stream adapters', () => {
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 },
{ address: ADDRESS, maxMessages: 50 },
{ address: ADDRESS, maxMessages: 50 },
])
expect(remote.pageRequests).toEqual([])
expect(changes.map(change => change.type)).toEqual(['replace', 'append', 'replace'])
expect(carrierFailed).toHaveBeenCalledWith(lost)
await stream.dispose()
@@ -187,16 +198,13 @@ describe('Session Client stream adapters', () => {
const remote = new ScriptedSessionRemote(
[
{
frames: [{ type: 'opened', cursor: 0 }],
frames: [snapshot(0, [entry(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)]) },
{ frames: [snapshot(1, [entry(0), entry(1)])], hold: true },
],
[],
)
const stream = new SessionEventStream(sessionClient(remote), ADDRESS, {
publish: vi.fn(),
@@ -205,18 +213,33 @@ describe('Session Client stream adapters', () => {
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 vi.waitFor(() => { expect(remote.followRequests).toHaveLength(2) })
expect(remote.followRequests).toEqual([{ address: ADDRESS }, { address: ADDRESS }])
expect(remote.pageRequests).toEqual([])
await stream.dispose()
})
it('turns a page failure into a typed stream failure and closes follow', async () => {
it('repairs a live gap without adding an absent message limit', async () => {
const remote = new ScriptedSessionRemote(
[{ frames: [snapshot(0, [entry(0)]), { type: 'event', ...entry(2) }], hold: true }],
[{ ok: true, value: page([entry(0), entry(1), entry(2)]) }],
)
const changes: SessionJournalChange[] = []
const stream = new SessionEventStream(sessionClient(remote), ADDRESS, {
publish: (change) => { changes.push(change) },
failed: vi.fn(),
})
await stream.open({})
await vi.waitFor(() => { expect(changes).toHaveLength(2) })
expect(remote.pageRequests).toEqual([{ address: ADDRESS, throughSeq: 2 }])
await stream.dispose()
})
it('turns a pagination failure into a typed stream failure', 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 }],
[{ frames: [snapshot(-1, [])], hold: true }],
[{ ok: false, error: failure }],
)
const stream = new SessionEventStream(sessionClient(remote), ADDRESS, {
@@ -224,13 +247,16 @@ describe('Session Client stream adapters', () => {
failed: vi.fn(),
})
await expect(stream.open({})).rejects.toBeInstanceOf(RemoteStreamError)
await stream.open({})
await expect(stream.prepend({})).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.signals[0]?.aborted).toBe(false)
expect(remote.pageRequests).toEqual([{ address: ADDRESS, throughSeq: -1 }])
await stream.dispose()
expect(remote.signals[0]?.aborted).toBe(true)
})
it('maps the Host-wide control baseline and deltas into one snapshot stream', async () => {
@@ -2,9 +2,12 @@ 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 type { SessionObservation } from '@deepseek-ai/dsh-session-query'
import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
import { subagentIdentityProjectionDefinition } from '@deepseek-ai/dsh-subagent/src/projection.ts'
import { describe, expect, it, vi } from 'vitest'
import { SessionHistoryController } from '../src/history.ts'
import { installSessionReadTestServices, testSessionPersistence } from './test-remote.ts'
const signal = (): AbortSignal => new AbortController().signal
@@ -22,7 +25,13 @@ function append(
}
function event(type: string, seq: number, data: unknown = {}): SessionEvent {
return { type, seq, time: seq + 1, data } as SessionEvent
return {
type,
seq,
time: seq + 1,
data,
...type.startsWith('fixture/') ? { ignorable: true } : {},
} as SessionEvent
}
function cold(
@@ -30,10 +39,10 @@ function cold(
header: SessionHeader,
events: readonly SessionEvent[],
): void {
ctx.provide('sessionPersistence', {
ctx.provide('sessionPersistence', testSessionPersistence(ctx, {
list: () => Promise.resolve([header]),
inspect: () => Promise.resolve({ meta: header, events }),
} as never)
}) as never)
}
interface Deferred<T> {
@@ -50,7 +59,9 @@ function deferred<T>(): Deferred<T> {
async function setup(): Promise<{ ctx: Context; transport: SessionHistoryController }> {
const ctx = new Context()
await ctx.plugin(SessionStore)
const transport = new SessionHistoryController(ctx)
installSessionReadTestServices(ctx)
ctx.sessionProjections.register(subagentIdentityProjectionDefinition)
const transport = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() })
return { ctx, transport }
}
@@ -65,7 +76,7 @@ describe('SessionHistoryController', () => {
abort.signal,
)[Symbol.asyncIterator]()
expect(await iterator.next()).toMatchObject({ done: false, value: { type: 'opened', cursor: 0 } })
expect(await iterator.next()).toMatchObject({ done: false, value: { type: 'snapshot', cursor: 0 } })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
expect(await iterator.next()).toMatchObject({
done: false,
@@ -85,10 +96,13 @@ describe('SessionHistoryController', () => {
it('ends active followers when the owning Controller unloads', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
installSessionReadTestServices(ctx)
let transport!: SessionHistoryController
const owner = ctx.plugin(Object.assign(
(inner: Context) => { transport = new SessionHistoryController(inner) },
{ inject: ['sessions'] },
(inner: Context) => {
transport = new SessionHistoryController(inner, (observation) => { observation[Symbol.dispose]() })
},
{ inject: ['sessions', 'sessionQuery'] },
))
await owner.await()
const session = ctx.sessions.create(SessionId('controller-unload'), { meta: { cwd: '/workspace' } })
@@ -97,9 +111,9 @@ describe('SessionHistoryController', () => {
new AbortController().signal,
)[Symbol.asyncIterator]()
await expect(iterator.next()).resolves.toEqual({
await expect(iterator.next()).resolves.toMatchObject({
done: false,
value: { type: 'opened', cursor: -1 },
value: { type: 'snapshot', cursor: -1 },
})
const pending = iterator.next()
await owner.dispose()
@@ -107,7 +121,7 @@ describe('SessionHistoryController', () => {
await ctx.fiber.dispose()
})
it('resumes from the last applied seq before delivering later live events', async () => {
it('reconnects with a complete replacement snapshot before 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 })
@@ -116,12 +130,16 @@ describe('SessionHistoryController', () => {
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 } } })
expect(await iterator.next()).toMatchObject({
done: false,
value: {
type: 'snapshot',
cursor: 2,
events: [{ event: { seq: 0 } }, { event: { seq: 1 } }, { 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 } } })
@@ -133,34 +151,74 @@ describe('SessionHistoryController', () => {
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 inspected = deferred<{ meta: SessionHeader; events: readonly SessionEvent[] }>()
ctx.provide('sessionPersistence', testSessionPersistence(ctx, {
inspect: () => inspected.promise,
}) 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 } })
ctx.emit('session/event', {
id: SessionId('unrelated'), events: [event('fixture/other', 0)],
} as unknown as Session, event('fixture/other', 0))
ctx.emit('session/event', {
id: sessionId, events: [event('fixture/start', 0)],
} as unknown as Session, event('fixture/start', 0))
inspected.resolve({ meta: header, events: [event('fixture/start', 0)] })
await expect(opening).resolves.toMatchObject({ done: false, value: { type: 'snapshot', cursor: 0 } })
const waiting = iterator.next()
abort.abort()
await expect(waiting).resolves.toMatchObject({ done: true })
})
it('buffers creation while the opening observation is unresolved', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const sessionId = SessionId('created-during-observation')
const header = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' }
const observed = deferred<SessionObservation>()
ctx.provide('sessionQuery', { observeSession: () => observed.promise } as never)
const transport = new SessionHistoryController(ctx, vi.fn())
const abort = new AbortController()
const iterator = transport.follow({ address: { kind: 'session', sessionId } }, abort.signal)
[Symbol.asyncIterator]()
const opening = iterator.next()
const attached = ctx.sessions.create(sessionId, { meta: header, seed: [event('fixture/seed', 0)] })
observed.resolve({
source: 'live',
header: attached.header,
events: attached.events,
cursor: attached.seq - 1,
projections: { asOfSeq: attached.seq - 1, values: {} },
retain: vi.fn(),
[Symbol.dispose]: vi.fn(),
} as unknown as SessionObservation)
await expect(opening).resolves.toMatchObject({
done: false,
value: {
type: 'snapshot', cursor: 1, events: [{ event: { seq: 0 } }, { event: { seq: 1 } }],
},
})
expect(attached.id).toBe(sessionId)
abort.abort()
await expect(iterator.next()).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)
installSessionReadTestServices(ctx)
let transport!: SessionHistoryController
let agentCtx!: Context
await ctx.plugin(Object.assign(
(inner: Context) => { transport = new SessionHistoryController(inner) },
{ inject: ['sessions'] },
(inner: Context) => {
transport = new SessionHistoryController(inner, (observation) => { observation[Symbol.dispose]() })
},
{ inject: ['sessions', 'sessionQuery'] },
))
await ctx.plugin(Object.assign(
(inner: Context) => { agentCtx = createScope(inner, { name: 'agent' }).ctx },
@@ -179,7 +237,7 @@ describe('SessionHistoryController', () => {
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 } })
await expect(iterator.next()).resolves.toMatchObject({ done: false, value: { type: 'snapshot', cursor: 0 } })
agentCtx.sessions.create(SessionId('unrelated-created'), { meta: { cwd: '/workspace' } })
const attached = agentCtx.sessions.prepare(sessionId, { meta: header, seed })
agentCtx.sessions.enter(attached)
@@ -212,11 +270,9 @@ describe('SessionHistoryController', () => {
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,
address: { kind: 'session', sessionId: replayId },
}, 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' } })
await expect(replayed.next()).rejects.toMatchObject({ code: 'SESSION_QUERY_CORRUPT_SESSION' })
const live = await setup()
const session = live.ctx.sessions.create(SessionId('live-gap'), { meta: { cwd: '/workspace' } })
@@ -225,8 +281,13 @@ describe('SessionHistoryController', () => {
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()).resolves.toMatchObject({ done: false, value: { type: 'snapshot', cursor: 0 } })
const skipped = event('fixture/skipped', 1)
const gap = event('fixture/gap', 2)
live.ctx.emit('session/event', {
id: session.id,
events: [event('fixture/start', 0), skipped, gap],
} as unknown as Session, gap)
await expect(followed.next()).rejects.toMatchObject({ failure: { code: 'internal' } })
})
@@ -237,7 +298,7 @@ describe('SessionHistoryController', () => {
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(iterator.next()).resolves.toMatchObject({ done: false, value: { type: 'snapshot', cursor: -1 } })
await expect(transport.page({
address: { kind: 'session', sessionId: session.id }, throughSeq: -1,
}, signal())).resolves.toMatchObject({ events: [], hasMore: false })
@@ -245,6 +306,59 @@ describe('SessionHistoryController', () => {
await expect(iterator.next()).resolves.toMatchObject({ done: true })
})
it('publishes an empty projection baseline when the query has no registry', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const sessionId = SessionId('projectionless-follow')
const meta = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' }
ctx.provide('sessionQuery', {
observeSession: () => Promise.resolve({
source: 'live', header: meta, events: [], cursor: -1,
retain: vi.fn(), [Symbol.dispose]: vi.fn(),
} satisfies SessionObservation),
} as never)
const history = new SessionHistoryController(ctx, vi.fn())
const abort = new AbortController()
const iterator = history.follow({ address: { kind: 'session', sessionId } }, abort.signal)
[Symbol.asyncIterator]()
await expect(iterator.next()).resolves.toMatchObject({
value: { type: 'snapshot', projections: { asOfSeq: -1, values: {} } },
})
abort.abort()
await expect(iterator.next()).resolves.toMatchObject({ done: true })
await ctx.fiber.dispose()
})
it('disposes a retained promotion when background activation rejects synchronously', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const sessionId = SessionId('promotion-failure')
const meta = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' }
const disposePromotion = vi.fn()
const promotion = {
source: 'prepared', header: meta, events: [], cursor: -1,
projections: { asOfSeq: -1, values: {} },
retain: vi.fn(), [Symbol.dispose]: disposePromotion,
} as unknown as SessionObservation
const source = {
...promotion,
retain: () => promotion,
[Symbol.dispose]: vi.fn(),
} as SessionObservation
ctx.provide('sessionQuery', {
observeSession: () => Promise.resolve(source),
} as never)
const history = new SessionHistoryController(ctx, () => { throw new Error('activation failed') })
const iterator = history.follow({ address: { kind: 'session', sessionId } }, signal())
[Symbol.asyncIterator]()
await expect(iterator.next()).resolves.toMatchObject({ value: { type: 'snapshot' } })
await expect(iterator.next()).rejects.toThrow('activation failed')
expect(disposePromotion).toHaveBeenCalledOnce()
await ctx.fiber.dispose()
})
it('requires the durable parent and mode for a direct subagent address', async () => {
const { ctx, transport } = await setup()
const parentSessionId = SessionId('parent')
@@ -288,15 +402,18 @@ describe('SessionHistoryController', () => {
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', {
ctx.provide('sessionPersistence', testSessionPersistence(ctx, {
list: () => Promise.resolve([header]),
inspect: () => Promise.reject(failure),
} as never)
}) as never)
await expect(transport.page({
address: { kind: 'session', sessionId },
throughSeq: -1,
}, new AbortController().signal)).rejects.toBe(failure)
}, new AbortController().signal)).rejects.toMatchObject({
code: 'SESSION_QUERY_PERSISTENCE_FAILED',
cause: failure,
})
})
it('rejects malformed page and follow cursors at the service boundary', async () => {
@@ -325,25 +442,24 @@ describe('SessionHistoryController', () => {
)
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]()
}, signal())).rejects.toMatchObject({ code: 'SESSION_QUERY_CORRUPT_SESSION' })
for (const maxMessages of [0, 0.5]) {
const iterator = transport.follow({ address, maxMessages }, 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' } })
.rejects.toMatchObject({ failure: { code: 'session-not-found' } })
ctx.provide('sessionPersistence', {
const inspect = vi.fn(() => Promise.resolve(undefined))
ctx.provide('sessionPersistence', testSessionPersistence(ctx, {
list: () => Promise.resolve([]),
inspect: () => Promise.reject(new Error('must not inspect')),
} as never)
inspect,
}) as never)
await expect(transport.page({ address: ordinary, throughSeq: -1 }, signal()))
.rejects.toMatchObject({ failure: { code: 'session-not-found' } })
await expect(transport.page({
@@ -355,25 +471,28 @@ describe('SessionHistoryController', () => {
},
throughSeq: -1,
}, signal())).rejects.toMatchObject({ failure: { code: 'subagent-not-found' } })
expect(inspect).toHaveBeenCalledTimes(2)
})
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)
const firstHeader = { version: 0, id: sessionId, createdAt: 1 }
first.ctx.provide('sessionPersistence', testSessionPersistence(first.ctx, {
list: () => Promise.resolve([firstHeader]),
inspect: () => Promise.resolve({ meta: firstHeader, events: [] }),
}) 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', {
const inspected = { version: 0, id: sessionId, createdAt: 1 }
second.ctx.provide('sessionPersistence', testSessionPersistence(second.ctx, {
list: () => Promise.resolve([listed]),
inspect: () => Promise.resolve({ meta: { ...listed, cwd: undefined }, events: [] }),
} as never)
inspect: () => Promise.resolve({ meta: inspected, events: [] }),
}) as never)
await expect(second.transport.page({ address, throughSeq: -1 }, signal()))
.rejects.toMatchObject({ failure: { code: 'session-not-found' } })
})
@@ -407,7 +526,7 @@ describe('SessionHistoryController', () => {
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' } } })
.rejects.toMatchObject({ failure: { code: 'subagent-catalog-diagnostic', details: { reason: 'corrupt' } } })
const corrupt = await setup()
cold(corrupt.ctx, childHeader, [event('subagent/descriptor', 0, { version: 'bad' })])
@@ -421,44 +540,48 @@ describe('SessionHistoryController', () => {
.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' } })
it('reports an unavailable descriptor when an observed child has no projection value', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const parentSessionId = SessionId('missing-projection-parent')
const childSessionId = SessionId('missing-projection-child')
const meta: SessionHeader = {
version: 0,
id: childSessionId,
createdAt: 1,
cwd: '/workspace',
origin: 'subagent',
parentSession: parentSessionId,
}
ctx.provide('sessionQuery', {
observeSession: () => Promise.resolve({
source: 'live', header: meta, events: [], cursor: -1,
projections: { asOfSeq: -1, values: {} },
retain: vi.fn(), [Symbol.dispose]: vi.fn(),
} as unknown as SessionObservation),
} as never)
const history = new SessionHistoryController(ctx, vi.fn())
await expect(history.page({
address: { kind: 'subagent', parentSessionId, childSessionId, mode: 'continuable' },
throughSeq: -1,
}, signal())).rejects.toMatchObject({
failure: { code: 'subagent-catalog-diagnostic', details: { reason: 'unsupported' } },
})
await ctx.fiber.dispose()
})
it('keeps pages projection-free and computes projections only for child authorization', async () => {
const ordinary = await setup()
const session = ordinary.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({
const ordinarySnapshot = vi.spyOn(ordinary.ctx.sessionProjections, 'snapshot')
const ordinaryPage = await ordinary.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')
expect('projections' in ordinaryPage).toBe(false)
expect(ordinarySnapshot).not.toHaveBeenCalled()
const child = await setup()
const parentSessionId = SessionId('projection-parent')
@@ -469,71 +592,13 @@ describe('SessionHistoryController', () => {
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 childSnapshot = vi.spyOn(child.ctx.sessionProjections, 'snapshot')
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')
expect(childSnapshot).toHaveBeenCalledWith(childSession)
})
it('keeps message-aligned pagination contiguous across replacement provenance', async () => {
@@ -576,77 +641,4 @@ describe('SessionHistoryController', () => {
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'))
})
})