session.create treats a named sessionId as a blank Session to reuse while the Host can verify it is still reusable: no turn has started, it belongs to the requested directory, it is an unarchived member of the requested Workspace, and no other owner writes it. The reused Session runs the requested agentPreset or the configured default; its recorded preset is not resolved, so a blank recorded under a preset that is no longer declared no longer breaks New Session or startup restoration. A change is logged as agent-preset/selected before the Agent is published. A candidate that is no longer reusable stays unchanged and the Host creates a new Session, which replaces the client's writer-held fallback. The Web client restores a saved draft with its recorded preset and falls back to the default when that preset is missing or fails to activate, passes the creator entry's preset through creation instead of issuing a concurrent select, and reloads the projections of a declined candidate so a stale blank hint no longer hides a started conversation. Related to #2566
363 lines
14 KiB
TypeScript
363 lines
14 KiB
TypeScript
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { Context } from '@deepseek-ai/cordis'
|
|
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
|
import type { Agent } from '@deepseek-ai/dsh-agent'
|
|
import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-preset-registry'
|
|
import SessionStore, { SESSION_FORMAT_VERSION, SessionLogOffset, SessionId } from '@deepseek-ai/dsh-session'
|
|
import type { SessionHeader } from '@deepseek-ai/dsh-session'
|
|
import { SessionAlreadyOwnedError } from '@deepseek-ai/dsh-session-persistence'
|
|
import type { SessionObservation } from '@deepseek-ai/dsh-session-query'
|
|
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import {
|
|
ApiSessionAgentController,
|
|
ApiSessionCwdConflict,
|
|
ApiSessionNotFound,
|
|
inspectApiSession,
|
|
} from '../src/agent.ts'
|
|
import { installModelSelectionProjection } from '../src/model-selection-projection.ts'
|
|
import { installSessionReadTestServices, testSessionPersistence } from './test-remote.ts'
|
|
|
|
const roots: Context[] = []
|
|
|
|
/** Session cwd roots created per test, removed after their context settles. */
|
|
const tempDirs: string[] = []
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(roots.splice(0).map(ctx => ctx.fiber.dispose()))
|
|
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true })
|
|
})
|
|
|
|
async function harness(): Promise<{ ctx: Context; agents: ApiSessionAgentController }> {
|
|
const ctx = new Context()
|
|
roots.push(ctx)
|
|
await ctx.plugin(TypertRegistry)
|
|
await ctx.plugin(SessionStore)
|
|
await ctx.plugin(AgentRegistry)
|
|
installSessionReadTestServices(ctx)
|
|
ctx.sessionProjections.register(agentPresetProjectionDefinition)
|
|
installModelSelectionProjection(ctx)
|
|
ctx.provide('agentDefaultModel', {
|
|
currentSelection: () => ({ provider: 'fixture', model: 'fixture-model' }),
|
|
saveSelection: () => Promise.resolve(),
|
|
} as never)
|
|
return { ctx, agents: new ApiSessionAgentController(ctx) }
|
|
}
|
|
|
|
function header(id: string, cwd: string | null = '/workspace'): SessionHeader {
|
|
return {
|
|
version: SESSION_FORMAT_VERSION,
|
|
id: SessionId(id),
|
|
createdAt: 1,
|
|
isSeeded: false,
|
|
...(cwd === null ? {} : { cwd }),
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
function unpublishedAgent(ctx: Context, meta: SessionHeader): Agent {
|
|
return {
|
|
id: meta.id,
|
|
session: { id: meta.id, header: meta, events: [] },
|
|
status: 'idle',
|
|
ctx,
|
|
} as unknown as Agent
|
|
}
|
|
|
|
describe('ApiSession identity failures', () => {
|
|
it('describes cwd conflicts with and without a recorded cwd', () => {
|
|
expect(new ApiSessionCwdConflict(SessionId('missing-cwd'), '/wanted', undefined).message)
|
|
.toContain('records no cwd')
|
|
expect(new ApiSessionCwdConflict(SessionId('wrong-cwd'), '/wanted', '/existing').message)
|
|
.toContain('belongs to "/existing"')
|
|
})
|
|
|
|
it('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.toBeInstanceOf(ApiSessionNotFound)
|
|
|
|
const inspect = vi.fn(() => Promise.resolve(undefined))
|
|
const stat = vi.fn(() => Promise.resolve(undefined))
|
|
const disposeMissing = providePersistence(ctx, {
|
|
list: () => Promise.resolve([]),
|
|
stat,
|
|
inspect,
|
|
})
|
|
await expect(inspectApiSession(ctx, SessionId('missing'))).rejects.toBeInstanceOf(ApiSessionNotFound)
|
|
// Absence is decided by the stat preflight; the log itself is never opened.
|
|
expect(stat).toHaveBeenCalledOnce()
|
|
expect(inspect).not.toHaveBeenCalled()
|
|
disposeMissing()
|
|
|
|
const listed = header('cwd-less-catalog', null)
|
|
const disposeListed = providePersistence(ctx, {
|
|
list: () => Promise.resolve([listed]),
|
|
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)
|
|
providePersistence(ctx, {
|
|
list: () => Promise.resolve([catalog]),
|
|
inspect: () => Promise.resolve({ meta: inspected, events: [] }),
|
|
})
|
|
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, inheritedEventCount: SessionLogOffset(0), events: [] }))
|
|
providePersistence(ctx, {
|
|
list: () => Promise.resolve([meta]),
|
|
inspect,
|
|
})
|
|
const signal = new AbortController().signal
|
|
|
|
await expect(inspectApiSession(ctx, meta.id, signal)).resolves.toEqual({ meta, inheritedEventCount: SessionLogOffset(0), 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'))
|
|
await ctx.agents.register(live)
|
|
providePersistence(ctx, {
|
|
list: () => Promise.resolve([]),
|
|
inspect: vi.fn(),
|
|
})
|
|
const host = ctx.typert.contexts.getHost('agent')
|
|
if (host === undefined) throw new Error('Agent Context resolver was not registered')
|
|
|
|
await expect(host.resolve(live.id)).resolves.toBe(live.ctx)
|
|
await expect(host.resolve(SessionId('missing'))).rejects.toMatchObject({ code: 'session/not-found' })
|
|
})
|
|
|
|
it('returns raced ordinary Agents and ownership failures after resume throws', async () => {
|
|
const ordinary = await harness()
|
|
const ordinaryMeta = header('ordinary-race')
|
|
providePersistence(ordinary.ctx, {
|
|
list: () => Promise.resolve([ordinaryMeta]),
|
|
inspect: () => Promise.resolve({ meta: ordinaryMeta, events: [] }),
|
|
})
|
|
const winner = agent(ordinary.ctx, ordinaryMeta)
|
|
vi.spyOn(ordinary.ctx.agents, 'resume').mockImplementation(async () => {
|
|
await ordinary.ctx.agents.register(winner)
|
|
throw new Error('raced publication')
|
|
})
|
|
await expect(ordinary.agents.resolveAgent(ordinaryMeta.id)).resolves.toEqual({ agent: winner })
|
|
|
|
const child = await harness()
|
|
const childMeta = header('child-race')
|
|
providePersistence(child.ctx, {
|
|
list: () => Promise.resolve([childMeta]),
|
|
inspect: () => Promise.resolve({ meta: childMeta, events: [] }),
|
|
})
|
|
vi.spyOn(child.ctx.agents, 'resume').mockImplementation(async () => {
|
|
child.ctx.sessions.create(childMeta.id, {
|
|
meta: { ...childMeta, parentSession: SessionId('parent'), origin: 'subagent' },
|
|
})
|
|
throw new Error('raced child publication')
|
|
})
|
|
await expect(child.agents.resolveAgent(childMeta.id)).resolves.toMatchObject({
|
|
error: { code: 'session/agent-busy' },
|
|
})
|
|
})
|
|
|
|
it('reports not-found and ordinary resume failures without fabricating an Agent', async () => {
|
|
const missing = await harness()
|
|
providePersistence(missing.ctx, {
|
|
list: () => Promise.resolve([]),
|
|
inspect: vi.fn(),
|
|
})
|
|
await expect(missing.agents.resolveAgent(SessionId('missing'))).resolves.toMatchObject({
|
|
error: { code: 'session/not-found' },
|
|
})
|
|
|
|
const failed = await harness()
|
|
const meta = header('failed')
|
|
providePersistence(failed.ctx, {
|
|
list: () => Promise.resolve([meta]),
|
|
inspect: () => Promise.resolve({ meta, events: [] }),
|
|
})
|
|
vi.spyOn(failed.ctx.agents, 'resume').mockRejectedValue(new Error('factory unavailable'))
|
|
await expect(failed.agents.resolveAgent(meta.id)).resolves.toMatchObject({
|
|
error: { code: 'gateway/internal', message: expect.stringContaining('factory unavailable') as string },
|
|
})
|
|
})
|
|
|
|
it('identifies a held Session writer without classifying other resume failures as contention', async () => {
|
|
const { ctx, agents } = await harness()
|
|
const meta = header('owned-session')
|
|
providePersistence(ctx, {
|
|
list: () => Promise.resolve([meta]),
|
|
inspect: () => Promise.resolve({ meta, events: [] }),
|
|
})
|
|
const resume = vi.spyOn(ctx.agents, 'resume').mockRejectedValue(new SessionAlreadyOwnedError(meta.id))
|
|
await expect(agents.resolveAgent(meta.id)).resolves.toMatchObject({
|
|
error: { code: 'session/writer-held', details: { sessionId: meta.id } },
|
|
})
|
|
resume.mockRejectedValue(Object.assign(new Error('another module copy'), { name: 'SessionAlreadyOwnedError' }))
|
|
await expect(agents.resolveAgent(meta.id)).resolves.toMatchObject({
|
|
error: { code: 'session/writer-held', details: { sessionId: meta.id } },
|
|
})
|
|
resume.mockRejectedValue(new Error('unrelated failure'))
|
|
await expect(agents.resolveAgent(meta.id)).resolves.toMatchObject({
|
|
error: { code: 'gateway/internal' },
|
|
})
|
|
})
|
|
|
|
it('retains resume diagnostics without a persistence service', async () => {
|
|
const { ctx, agents } = await harness()
|
|
const meta = header('memory-only-resume')
|
|
ctx.sessions.create(meta.id, { meta })
|
|
vi.spyOn(ctx.agents, 'resume').mockRejectedValue(new Error('factory unavailable'))
|
|
await expect(agents.resolveAgent(meta.id)).resolves.toMatchObject({
|
|
error: { code: 'gateway/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 creation', () => {
|
|
it('rejects a resume that subagent routing adopts before publication', async () => {
|
|
const child = await harness()
|
|
const childMeta = header('resume-child-race')
|
|
providePersistence(child.ctx, {
|
|
list: () => Promise.resolve([childMeta]),
|
|
inspect: () => Promise.resolve({ meta: childMeta, events: [] }),
|
|
})
|
|
child.ctx.provide('agentPresets', {
|
|
resolve: () => {
|
|
child.ctx.sessions.create(childMeta.id, {
|
|
meta: { ...childMeta, parentSession: SessionId('parent'), origin: 'subagent' },
|
|
})
|
|
return Promise.resolve({ id: 'standard' })
|
|
},
|
|
mount: () => Promise.resolve(),
|
|
} as never)
|
|
await expect(child.agents.resolveAgent(childMeta.id)).resolves.toMatchObject({
|
|
error: { code: 'session/agent-busy' },
|
|
})
|
|
})
|
|
|
|
it('surfaces directory creation failure', async () => {
|
|
const { agents } = await harness()
|
|
const parent = mkdtempSync(join(tmpdir(), 'dsh-session-controller-file-'))
|
|
tempDirs.push(parent)
|
|
const file = join(parent, 'file')
|
|
writeFileSync(file, 'not a directory')
|
|
await expect(agents.createSession({ cwd: join(file, 'child'), reusable: () => true }))
|
|
.rejects.toThrow('failed to ensure project directory')
|
|
})
|
|
})
|