session.create resolved the requested preset before queueing on the named identity, so a later request whose preset resolved first could run first, and an undeclared preset masked the directory conflict the README promises. Queue first, check the named Session's directory and ownership, then resolve the preset inside the queue, and report the preset each request left the Session running instead of reading it after the queue moved on. The no-roster conflict message now names its actual cause.
675 lines
30 KiB
TypeScript
675 lines
30 KiB
TypeScript
/** Session creation, blank-Session reuse, and Agent preset identity through `session.create`. */
|
|
|
|
import { mkdtempSync, realpathSync, rmSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { Context } from '@deepseek-ai/cordis'
|
|
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
|
import type { Agent, AgentFactory } from '@deepseek-ai/dsh-agent'
|
|
import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop'
|
|
import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-preset-registry'
|
|
import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionSeq } from '@deepseek-ai/dsh-session'
|
|
import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
|
|
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
|
|
import type { WorkspaceId } from '@deepseek-ai/dsh-workspace'
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import { createSessionTestRemote, testSessionPersistence } from './test-remote.ts'
|
|
|
|
/** Booted contexts and their temp roots, torn down after each test. */
|
|
const contexts: Context[] = []
|
|
const tempDirs: string[] = []
|
|
afterEach(async () => {
|
|
await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
|
|
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true })
|
|
})
|
|
|
|
function stubAgent(session: Session): Agent {
|
|
return { id: session.id, session, status: 'idle' } as unknown as Agent
|
|
}
|
|
|
|
/** A stored Session the persistence double serves until an Agent resumes it. */
|
|
interface StoredSession {
|
|
readonly header: SessionHeader
|
|
readonly events: readonly SessionEvent[]
|
|
}
|
|
|
|
interface HarnessOptions {
|
|
/** Declared presets; the first is the default. Omitted, no roster is composed. */
|
|
readonly presets?: readonly string[]
|
|
/** Stored Sessions keyed by id. */
|
|
readonly stored?: ReadonlyMap<SessionId, StoredSession>
|
|
/** Stored Sessions whose writer another owner holds. */
|
|
readonly held?: ReadonlySet<SessionId>
|
|
/** Stored Sessions another resume in this process publishes while this one waits for the writer. */
|
|
readonly publishedByOtherResume?: ReadonlySet<SessionId>
|
|
/** Stored Sessions whose storage fails to open for writing. */
|
|
readonly unreadable?: ReadonlySet<SessionId>
|
|
/** Events a resume finds beyond the stored log, as when another Host wrote after the observation. */
|
|
readonly writtenSinceObservation?: ReadonlyMap<SessionId, readonly SessionEvent[]>
|
|
/** Live Sessions whose preset switch the roster refuses as already started. */
|
|
readonly locked?: ReadonlySet<SessionId>
|
|
/** Declared presets whose activation failed. */
|
|
readonly broken?: ReadonlySet<string>
|
|
/** Requested presets whose resolution waits for the paired promise. */
|
|
readonly resolving?: ReadonlyMap<string, Promise<void>>
|
|
/** Workspace placement served to `workspaceId` requests. */
|
|
readonly workspace?: { readonly members: readonly SessionId[]; readonly archived: readonly SessionId[] }
|
|
}
|
|
|
|
const WORKSPACE = 'workspace-1' as WorkspaceId
|
|
|
|
async function harness(options: HarnessOptions = {}) {
|
|
const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-')))
|
|
tempDirs.push(cwd)
|
|
const ctx = new Context()
|
|
contexts.push(ctx)
|
|
await ctx.plugin(SessionStore)
|
|
await ctx.plugin(AgentRegistry)
|
|
const presets = options.presets
|
|
const selections: { sessionId: SessionId; agentPreset: string }[] = []
|
|
const resumed: SessionId[] = []
|
|
if (presets !== undefined) {
|
|
const known = (id?: string): string => {
|
|
const wanted = id ?? presets[0] ?? ''
|
|
if (!presets.includes(wanted)) {
|
|
throw new RemoteError('agent-preset/not-found', `Unknown agent preset: ${wanted}`, {
|
|
agentPreset: wanted, available: [...presets],
|
|
})
|
|
}
|
|
return wanted
|
|
}
|
|
const usable = (id?: string): string => {
|
|
const wanted = known(id)
|
|
if (options.broken?.has(wanted) === true) {
|
|
throw new RemoteError('agent-preset/invalid', 'activation failed', { agentPreset: wanted, reason: 'activation failed' })
|
|
}
|
|
return wanted
|
|
}
|
|
ctx.provide('agentPresets', {
|
|
defaultId: presets[0],
|
|
resolve: (id?: string) => Promise.resolve(id === undefined ? undefined : options.resolving?.get(id))
|
|
.then(() => ({ id: known(id) })),
|
|
mount: (_ctx: Context, id?: string) => Promise.resolve().then(() => ({ id: usable(id) })),
|
|
select: async (agent: Agent, agentPreset: string) => {
|
|
usable(agentPreset)
|
|
if (options.locked?.has(agent.id) === true) {
|
|
throw new RemoteError('agent-preset/locked', 'This session has already started', {
|
|
sessionId: agent.id, agentPreset,
|
|
})
|
|
}
|
|
selections.push({ sessionId: agent.id, agentPreset })
|
|
agent.session.append('agent-preset/selected', { agentPreset })
|
|
return agentPreset
|
|
},
|
|
} as never)
|
|
}
|
|
const stored = options.stored ?? new Map<SessionId, StoredSession>()
|
|
ctx.provide('sessionPersistence', testSessionPersistence(ctx, {
|
|
list: () => Promise.resolve([...stored.values()].map(record => record.header)),
|
|
inspect: (id: SessionId) => {
|
|
const record = stored.get(id)
|
|
return Promise.resolve(record === undefined ? undefined : { meta: record.header, events: [...record.events] })
|
|
},
|
|
}) as never)
|
|
const { members, archived } = options.workspace ?? { members: [], archived: [] }
|
|
const workspace = { id: WORKSPACE, path: cwd, sessionIds: members, attachSession: () => Promise.resolve() }
|
|
ctx.provide('workspaceRegistry', {
|
|
get: (id: WorkspaceId) => id === WORKSPACE ? workspace : undefined,
|
|
list: () => [workspace],
|
|
archivedSessionIds: archived,
|
|
} as never)
|
|
|
|
/** Run setup on the unpublished Session, then publish it and its Agent, as the Agent factory does. */
|
|
const publish = async (
|
|
session: Session,
|
|
setup: Parameters<AgentFactory['createAgent']>[1]['setup'],
|
|
): Promise<{ agent: Agent; dispose: () => Promise<void> }> => {
|
|
const agent = stubAgent(session)
|
|
;(agent as { ctx?: Context }).ctx = ctx
|
|
await setup?.(ctx, agent)
|
|
ctx.effect(function* () {
|
|
yield ctx.sessions.enter(session)
|
|
ctx.sessions.announce(session)
|
|
}, 'test: published Session')
|
|
const unregister = await ctx.agents.register(agent)
|
|
return { agent, dispose: async () => { await unregister() } }
|
|
}
|
|
const factory: AgentFactory = {
|
|
async createAgent(_ownerCtx, create) {
|
|
const session = ctx.sessions.prepare(create.sessionId, create.meta === undefined ? {} : { meta: create.meta })
|
|
return publish(session, create.setup)
|
|
},
|
|
async resume(_ownerCtx, resume) {
|
|
const id = resume.resumeSessionId
|
|
if (options.held?.has(id) === true) {
|
|
throw Object.assign(new Error(`session "${id}" is written by another owner`), { name: 'SessionAlreadyOwnedError' })
|
|
}
|
|
const record = stored.get(id)
|
|
if (record === undefined || options.unreadable?.has(id) === true) {
|
|
throw new Error(`test persistence cannot open Session "${id}" for writing`)
|
|
}
|
|
if (options.publishedByOtherResume?.has(id) === true) {
|
|
await publish(ctx.sessions.prepare(id, { meta: record.header, seed: [...record.events] }), undefined)
|
|
throw Object.assign(new Error(`session "${id}" is written by this process`), { name: 'SessionAlreadyOwnedError' })
|
|
}
|
|
resumed.push(id)
|
|
const session = ctx.sessions.prepare(id, {
|
|
meta: record.header,
|
|
seed: [...record.events, ...options.writtenSinceObservation?.get(id) ?? []],
|
|
})
|
|
return publish(session, resume.setup)
|
|
},
|
|
}
|
|
ctx.agents.setFactory(factory)
|
|
const remote = createSessionTestRemote(ctx, {
|
|
defaultModelSelection: () => ({ provider: 'test', model: 'test-model' }),
|
|
cwd,
|
|
})
|
|
ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
|
|
if (presets !== undefined) ctx.sessionProjections.register(agentPresetProjectionDefinition)
|
|
const presetOf = (id: SessionId): string | null | undefined => {
|
|
const session = ctx.sessions.get(id)
|
|
return session === undefined ? undefined : ctx.sessionProjections.stateOf(session, 'agentPreset')
|
|
}
|
|
const selectedEvents = (id: SessionId): unknown[] => {
|
|
const session = ctx.sessions.get(id)
|
|
return session === undefined ? [] : session.snapshotEvents()
|
|
.filter(event => event.type === 'agent-preset/selected')
|
|
.map(event => event.data)
|
|
}
|
|
return { ctx, remote, cwd, selections, resumed, presetOf, selectedEvents }
|
|
}
|
|
|
|
function storedHeader(id: SessionId, cwd: string, extra: Partial<SessionHeader> = {}): SessionHeader {
|
|
return { version: SESSION_FORMAT_VERSION, id, createdAt: 1, isSeeded: false, cwd, ...extra }
|
|
}
|
|
|
|
function modelSelection(seq: number): SessionEvent {
|
|
return { type: 'model/selection', seq: SessionSeq(seq), time: 1, data: { provider: 'test', model: 'test-model' } }
|
|
}
|
|
|
|
function presetSelection(seq: number, agentPreset: string): SessionEvent {
|
|
return { type: 'agent-preset/selected', seq: SessionSeq(seq), time: 1, data: { agentPreset } }
|
|
}
|
|
|
|
function completedTurn(seq: number): SessionEvent[] {
|
|
return [
|
|
{ type: 'turn/start', seq: SessionSeq(seq), time: 1, data: { turn: 1 } },
|
|
{ type: 'turn/end', seq: SessionSeq(seq + 1), time: 2, data: { turn: 1, reason: { kind: 'completed' } } },
|
|
]
|
|
}
|
|
|
|
describe('session.create Agent preset identity', () => {
|
|
it('records the requested preset on the Session header', async () => {
|
|
const { ctx, remote } = await harness({ presets: ['standard', 'minimal'] })
|
|
|
|
const created = await remote.create({ sessionId: SessionId('s1'), agentPreset: 'minimal' })
|
|
|
|
expect(created.ok).toBe(true)
|
|
expect(ctx.sessions.get(SessionId('s1'))?.header.agentPreset).toBe('minimal')
|
|
})
|
|
|
|
it('records the roster default when the caller names no preset', async () => {
|
|
const { ctx, remote } = await harness({ presets: ['standard', 'minimal'] })
|
|
|
|
await remote.create({ sessionId: SessionId('s2') })
|
|
|
|
expect(ctx.sessions.get(SessionId('s2'))?.header.agentPreset).toBe('standard')
|
|
})
|
|
|
|
it('rejects an unknown preset', async () => {
|
|
const { remote } = await harness({ presets: ['standard'] })
|
|
|
|
const response = await remote.create({ sessionId: SessionId('s3'), agentPreset: 'nope' })
|
|
|
|
expect(response).toMatchObject({ ok: false, error: { code: 'agent-preset/not-found' } })
|
|
})
|
|
|
|
it('leaves the header preset-less when no roster is composed', async () => {
|
|
const { ctx, remote } = await harness()
|
|
|
|
await remote.create({ sessionId: SessionId('s6') })
|
|
|
|
expect(ctx.sessions.get(SessionId('s6'))?.header.agentPreset).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('session.create reuse of a live blank Session', () => {
|
|
it('recomposes a blank Session under the default when the caller names no preset', async () => {
|
|
const { remote, selections, presetOf } = await harness({ presets: ['standard', 'minimal'] })
|
|
await remote.create({ sessionId: SessionId('draft'), agentPreset: 'minimal' })
|
|
|
|
const reused = await remote.create({ sessionId: SessionId('draft') })
|
|
|
|
expect(reused).toMatchObject({ ok: true, value: { sessionId: 'draft', agentPreset: 'standard' } })
|
|
expect(selections).toEqual([{ sessionId: 'draft', agentPreset: 'standard' }])
|
|
expect(presetOf(SessionId('draft'))).toBe('standard')
|
|
})
|
|
|
|
it('recomposes a blank Session under the named preset', async () => {
|
|
const { remote, selections } = await harness({ presets: ['standard', 'minimal'] })
|
|
await remote.create({ sessionId: SessionId('draft') })
|
|
|
|
const reused = await remote.create({ sessionId: SessionId('draft'), agentPreset: 'minimal' })
|
|
|
|
expect(reused).toMatchObject({ ok: true, value: { sessionId: 'draft', agentPreset: 'minimal' } })
|
|
expect(selections).toEqual([{ sessionId: 'draft', agentPreset: 'minimal' }])
|
|
})
|
|
|
|
it('keeps a blank Session that already runs the requested preset', async () => {
|
|
const { ctx, remote, selections } = await harness({ presets: ['standard', 'minimal'] })
|
|
await remote.create({ sessionId: SessionId('draft') })
|
|
ctx.sessions.get(SessionId('draft'))?.append('agent-preset/selected', { agentPreset: 'minimal' })
|
|
|
|
const reused = await remote.create({ sessionId: SessionId('draft'), agentPreset: 'minimal' })
|
|
|
|
expect(reused).toMatchObject({ ok: true, value: { sessionId: 'draft', agentPreset: 'minimal' } })
|
|
expect(selections).toEqual([])
|
|
})
|
|
|
|
it('validates the requested preset before touching the blank Session', async () => {
|
|
const { remote, selections, presetOf } = await harness({ presets: ['standard', 'minimal'] })
|
|
await remote.create({ sessionId: SessionId('draft'), agentPreset: 'minimal' })
|
|
|
|
const response = await remote.create({ sessionId: SessionId('draft'), agentPreset: 'deleted' })
|
|
|
|
expect(response).toMatchObject({ ok: false, error: { code: 'agent-preset/not-found' } })
|
|
expect(selections).toEqual([])
|
|
expect(presetOf(SessionId('draft'))).toBe('minimal')
|
|
})
|
|
|
|
it('keeps the blank Session unchanged when the requested preset fails to activate', async () => {
|
|
const { remote, selections, presetOf } = await harness({
|
|
presets: ['standard', 'broken'],
|
|
broken: new Set(['broken']),
|
|
})
|
|
await remote.create({ sessionId: SessionId('draft') })
|
|
|
|
const response = await remote.create({ sessionId: SessionId('draft'), agentPreset: 'broken' })
|
|
|
|
expect(response).toMatchObject({ ok: false, error: { code: 'agent-preset/invalid' } })
|
|
expect(selections).toEqual([])
|
|
expect(presetOf(SessionId('draft'))).toBe('standard')
|
|
})
|
|
|
|
it('creates a new Session instead of reusing one that has started', async () => {
|
|
const { ctx, remote, selections, presetOf } = await harness({ presets: ['standard', 'minimal'] })
|
|
await remote.create({ sessionId: SessionId('talk'), agentPreset: 'minimal' })
|
|
const talk = ctx.sessions.get(SessionId('talk'))
|
|
talk?.append('turn/start', { turn: 1 })
|
|
talk?.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
|
|
|
const created = await remote.create({ sessionId: SessionId('talk') })
|
|
|
|
if (!created.ok) throw new Error(`create failed: ${created.error.message}`)
|
|
expect(created.value.sessionId).not.toBe('talk')
|
|
expect(created.value.agentPreset).toBe('standard')
|
|
expect(selections).toEqual([])
|
|
expect(presetOf(SessionId('talk'))).toBe('minimal')
|
|
})
|
|
|
|
it('creates a new Session when a turn starts before the preset switch', async () => {
|
|
const { remote } = await harness({
|
|
presets: ['standard', 'minimal'],
|
|
locked: new Set([SessionId('racing')]),
|
|
})
|
|
await remote.create({ sessionId: SessionId('racing'), agentPreset: 'minimal' })
|
|
|
|
const created = await remote.create({ sessionId: SessionId('racing') })
|
|
|
|
if (!created.ok) throw new Error(`create failed: ${created.error.message}`)
|
|
expect(created.value.sessionId).not.toBe('racing')
|
|
})
|
|
|
|
it('serializes concurrent reuse of one Session in request order', async () => {
|
|
const { remote, selections, presetOf } = await harness({ presets: ['standard', 'minimal'] })
|
|
await remote.create({ sessionId: SessionId('draft') })
|
|
|
|
const [first, second] = await Promise.all([
|
|
remote.create({ sessionId: SessionId('draft'), agentPreset: 'minimal' }),
|
|
remote.create({ sessionId: SessionId('draft') }),
|
|
])
|
|
|
|
expect(first).toMatchObject({ ok: true, value: { sessionId: 'draft' } })
|
|
expect(second).toMatchObject({ ok: true, value: { sessionId: 'draft', agentPreset: 'standard' } })
|
|
expect(selections).toEqual([
|
|
{ sessionId: 'draft', agentPreset: 'minimal' },
|
|
{ sessionId: 'draft', agentPreset: 'standard' },
|
|
])
|
|
expect(presetOf(SessionId('draft'))).toBe('standard')
|
|
})
|
|
|
|
it('claims one Session in arrival order when an earlier preset resolves later', async () => {
|
|
const minimal = Promise.withResolvers<undefined>()
|
|
const { remote, selections, presetOf } = await harness({
|
|
presets: ['standard', 'minimal'], resolving: new Map([['minimal', minimal.promise]]),
|
|
})
|
|
await remote.create({ sessionId: SessionId('draft') })
|
|
|
|
const first = remote.create({ sessionId: SessionId('draft'), agentPreset: 'minimal' })
|
|
const second = remote.create({ sessionId: SessionId('draft') })
|
|
await new Promise(resolve => setTimeout(resolve, 0))
|
|
minimal.resolve(undefined)
|
|
|
|
expect(await first).toMatchObject({ ok: true, value: { sessionId: 'draft', agentPreset: 'minimal' } })
|
|
expect(await second).toMatchObject({ ok: true, value: { sessionId: 'draft', agentPreset: 'standard' } })
|
|
expect(selections).toEqual([
|
|
{ sessionId: 'draft', agentPreset: 'minimal' },
|
|
{ sessionId: 'draft', agentPreset: 'standard' },
|
|
])
|
|
expect(presetOf(SessionId('draft'))).toBe('standard')
|
|
})
|
|
|
|
it('shares one creation between concurrent callers of a new identity', async () => {
|
|
const { ctx, remote } = await harness({ presets: ['standard'] })
|
|
|
|
const results = await Promise.all([
|
|
remote.create({ sessionId: SessionId('fresh') }),
|
|
remote.create({ sessionId: SessionId('fresh') }),
|
|
])
|
|
|
|
expect(results).toMatchObject([
|
|
{ ok: true, value: { sessionId: 'fresh' } },
|
|
{ ok: true, value: { sessionId: 'fresh' } },
|
|
])
|
|
expect(ctx.agents.list().map(agent => agent.id)).toEqual(['fresh'])
|
|
})
|
|
|
|
it('refuses a Session in another directory', async () => {
|
|
const { ctx, remote } = await harness({ presets: ['standard'] })
|
|
const elsewhere = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-elsewhere-')))
|
|
tempDirs.push(elsewhere)
|
|
await remote.create({ sessionId: SessionId('other'), cwd: elsewhere })
|
|
|
|
const response = await remote.create({ sessionId: SessionId('other') })
|
|
|
|
expect(response).toMatchObject({
|
|
ok: false,
|
|
error: { code: 'session/conflict', details: { sessionId: 'other', existingCwd: elsewhere } },
|
|
})
|
|
expect(ctx.agents.list().map(agent => agent.id)).toEqual(['other'])
|
|
})
|
|
|
|
it('refuses a Session in another directory before resolving the requested preset', async () => {
|
|
const { remote } = await harness({ presets: ['standard'] })
|
|
const elsewhere = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-elsewhere-')))
|
|
tempDirs.push(elsewhere)
|
|
await remote.create({ sessionId: SessionId('other'), cwd: elsewhere })
|
|
|
|
const response = await remote.create({ sessionId: SessionId('other'), agentPreset: 'nope' })
|
|
|
|
expect(response).toMatchObject({ ok: false, error: { code: 'session/conflict', details: { sessionId: 'other' } } })
|
|
})
|
|
|
|
it('leaves a subagent identity to subagent routing', async () => {
|
|
const { ctx, remote, cwd } = await harness({ presets: ['standard'] })
|
|
ctx.sessions.create(SessionId('child'), {
|
|
meta: { cwd, parentSession: SessionId('parent'), origin: 'subagent' },
|
|
})
|
|
|
|
const response = await remote.create({ sessionId: SessionId('child') })
|
|
|
|
expect(response).toMatchObject({ ok: false, error: { code: 'session/agent-busy' } })
|
|
})
|
|
|
|
it('reuses a preset-less blank Session unchanged when no roster is composed', async () => {
|
|
const { ctx, remote } = await harness()
|
|
await remote.create({ sessionId: SessionId('s5') })
|
|
|
|
const reused = await remote.create({ sessionId: SessionId('s5') })
|
|
|
|
expect(reused).toMatchObject({ ok: true, value: { sessionId: 's5' } })
|
|
expect(ctx.sessions.get(SessionId('s5'))?.header.agentPreset).toBeUndefined()
|
|
})
|
|
|
|
it('explains why a preset-less Session cannot be adopted under one', async () => {
|
|
const { remote } = await harness()
|
|
await remote.create({ sessionId: SessionId('s7') })
|
|
|
|
const response = await remote.create({ sessionId: SessionId('s7'), agentPreset: 'standard' })
|
|
|
|
expect(response).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
code: 'agent-preset/conflict',
|
|
details: {
|
|
sessionId: 's7',
|
|
requestedPreset: 'standard',
|
|
},
|
|
},
|
|
})
|
|
if (response.ok) throw new Error('unreachable')
|
|
expect('existingPreset' in response.error.details).toBe(false)
|
|
expect(response.error.message).toBe('the deployment composes no agent preset roster, so session "s7" cannot run preset "standard"')
|
|
})
|
|
})
|
|
|
|
describe('session.create reuse of a stored blank Session', () => {
|
|
it('resumes a blank Session whose recorded preset is no longer declared under the default', async () => {
|
|
const draft = SessionId('stale-draft')
|
|
const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-stored-')))
|
|
tempDirs.push(cwd)
|
|
const { remote, resumed, presetOf, selectedEvents } = await harness({
|
|
presets: ['standard', 'minimal'],
|
|
stored: new Map([[draft, { header: storedHeader(draft, cwd, { agentPreset: 'liangshen' }), events: [modelSelection(0)] }]]),
|
|
})
|
|
|
|
const reused = await remote.create({ sessionId: draft, cwd })
|
|
|
|
expect(reused).toMatchObject({ ok: true, value: { sessionId: 'stale-draft', agentPreset: 'standard' } })
|
|
expect(resumed).toEqual(['stale-draft'])
|
|
expect(presetOf(draft)).toBe('standard')
|
|
expect(selectedEvents(draft)).toEqual([{ agentPreset: 'standard' }])
|
|
})
|
|
|
|
it('resumes a blank Session under the default even when its previous choice is still declared', async () => {
|
|
const draft = SessionId('picked-draft')
|
|
const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-stored-')))
|
|
tempDirs.push(cwd)
|
|
const { remote, presetOf, selectedEvents } = await harness({
|
|
presets: ['standard', 'minimal'],
|
|
stored: new Map([[draft, { header: storedHeader(draft, cwd, { agentPreset: 'standard' }), events: [presetSelection(0, 'minimal')] }]]),
|
|
})
|
|
|
|
const reused = await remote.create({ sessionId: draft, cwd })
|
|
|
|
expect(reused).toMatchObject({ ok: true, value: { sessionId: 'picked-draft', agentPreset: 'standard' } })
|
|
expect(presetOf(draft)).toBe('standard')
|
|
expect(selectedEvents(draft)).toEqual([{ agentPreset: 'minimal' }, { agentPreset: 'standard' }])
|
|
})
|
|
|
|
it('resumes a blank Session under its recorded preset when the caller names it', async () => {
|
|
const draft = SessionId('restored-draft')
|
|
const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-stored-')))
|
|
tempDirs.push(cwd)
|
|
const { remote, presetOf, selectedEvents } = await harness({
|
|
presets: ['standard', 'minimal'],
|
|
stored: new Map([[draft, { header: storedHeader(draft, cwd, { agentPreset: 'standard' }), events: [presetSelection(0, 'minimal')] }]]),
|
|
})
|
|
|
|
const reused = await remote.create({ sessionId: draft, cwd, agentPreset: 'minimal' })
|
|
|
|
expect(reused).toMatchObject({ ok: true, value: { sessionId: 'restored-draft', agentPreset: 'minimal' } })
|
|
expect(presetOf(draft)).toBe('minimal')
|
|
expect(selectedEvents(draft)).toEqual([{ agentPreset: 'minimal' }])
|
|
})
|
|
|
|
it('rejects a named preset that is no longer declared without resuming the Session', async () => {
|
|
const draft = SessionId('stale-draft')
|
|
const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-stored-')))
|
|
tempDirs.push(cwd)
|
|
const { ctx, remote, resumed } = await harness({
|
|
presets: ['standard'],
|
|
stored: new Map([[draft, { header: storedHeader(draft, cwd, { agentPreset: 'liangshen' }), events: [modelSelection(0)] }]]),
|
|
})
|
|
|
|
const response = await remote.create({ sessionId: draft, cwd, agentPreset: 'liangshen' })
|
|
|
|
expect(response).toMatchObject({
|
|
ok: false,
|
|
error: { code: 'agent-preset/not-found', details: { agentPreset: 'liangshen', available: ['standard'] } },
|
|
})
|
|
expect(resumed).toEqual([])
|
|
expect(ctx.agents.list()).toEqual([])
|
|
})
|
|
|
|
it('creates a new Session instead of resuming one that has started', async () => {
|
|
const talk = SessionId('stored-talk')
|
|
const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-stored-')))
|
|
tempDirs.push(cwd)
|
|
const { remote, resumed } = await harness({
|
|
presets: ['standard'],
|
|
stored: new Map([[talk, { header: storedHeader(talk, cwd, { agentPreset: 'liangshen' }), events: completedTurn(0) }]]),
|
|
})
|
|
|
|
const created = await remote.create({ sessionId: talk, cwd })
|
|
|
|
if (!created.ok) throw new Error(`create failed: ${created.error.message}`)
|
|
expect(created.value.sessionId).not.toBe('stored-talk')
|
|
expect(created.value.agentPreset).toBe('standard')
|
|
expect(resumed).toEqual([])
|
|
})
|
|
|
|
it('creates a new Session when another owner holds the blank Session', async () => {
|
|
const draft = SessionId('held-draft')
|
|
const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-stored-')))
|
|
tempDirs.push(cwd)
|
|
const { ctx, remote } = await harness({
|
|
presets: ['standard'],
|
|
stored: new Map([[draft, { header: storedHeader(draft, cwd, { agentPreset: 'standard' }), events: [modelSelection(0)] }]]),
|
|
held: new Set([draft]),
|
|
})
|
|
|
|
const created = await remote.create({ sessionId: draft, cwd })
|
|
|
|
if (!created.ok) throw new Error(`create failed: ${created.error.message}`)
|
|
expect(created.value.sessionId).not.toBe('held-draft')
|
|
expect(ctx.sessions.get(draft)).toBeUndefined()
|
|
})
|
|
|
|
it('reuses the Agent another resume published while holding the writer', async () => {
|
|
const draft = SessionId('contended-draft')
|
|
const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-stored-')))
|
|
tempDirs.push(cwd)
|
|
const { remote, selections, presetOf } = await harness({
|
|
presets: ['standard', 'minimal'],
|
|
stored: new Map([[draft, { header: storedHeader(draft, cwd, { agentPreset: 'minimal' }), events: [modelSelection(0)] }]]),
|
|
publishedByOtherResume: new Set([draft]),
|
|
})
|
|
|
|
const reused = await remote.create({ sessionId: draft, cwd })
|
|
|
|
expect(reused).toMatchObject({ ok: true, value: { sessionId: 'contended-draft', agentPreset: 'standard' } })
|
|
expect(selections).toEqual([{ sessionId: 'contended-draft', agentPreset: 'standard' }])
|
|
expect(presetOf(draft)).toBe('standard')
|
|
})
|
|
|
|
it('reports an unreadable Session instead of creating another one', async () => {
|
|
const { ctx, remote } = await harness({ presets: ['standard'] })
|
|
vi.spyOn(ctx.sessionQuery, 'observeSession').mockRejectedValue(new Error('storage offline'))
|
|
|
|
const response = await remote.create({ sessionId: SessionId('unreadable') })
|
|
|
|
expect(response).toMatchObject({ ok: false, error: { code: 'gateway/internal' } })
|
|
expect(ctx.agents.list()).toEqual([])
|
|
})
|
|
|
|
it('reports a storage failure instead of creating another Session', async () => {
|
|
const draft = SessionId('unreadable-draft')
|
|
const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-stored-')))
|
|
tempDirs.push(cwd)
|
|
const { ctx, remote } = await harness({
|
|
presets: ['standard'],
|
|
stored: new Map([[draft, { header: storedHeader(draft, cwd, { agentPreset: 'standard' }), events: [modelSelection(0)] }]]),
|
|
unreadable: new Set([draft]),
|
|
})
|
|
|
|
const response = await remote.create({ sessionId: draft, cwd })
|
|
|
|
expect(response).toMatchObject({ ok: false, error: { code: 'gateway/internal' } })
|
|
expect(ctx.agents.list()).toEqual([])
|
|
})
|
|
|
|
it('creates a new Session when the blank Session started after it was observed', async () => {
|
|
const draft = SessionId('overtaken-draft')
|
|
const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-stored-')))
|
|
tempDirs.push(cwd)
|
|
const { ctx, remote, resumed } = await harness({
|
|
presets: ['standard', 'minimal'],
|
|
stored: new Map([[draft, { header: storedHeader(draft, cwd, { agentPreset: 'minimal' }), events: [modelSelection(0)] }]]),
|
|
writtenSinceObservation: new Map([[draft, completedTurn(1)]]),
|
|
})
|
|
|
|
const created = await remote.create({ sessionId: draft, cwd })
|
|
|
|
if (!created.ok) throw new Error(`create failed: ${created.error.message}`)
|
|
expect(created.value.sessionId).not.toBe('overtaken-draft')
|
|
expect(resumed).toEqual(['overtaken-draft'])
|
|
expect(ctx.sessions.get(draft)).toBeUndefined()
|
|
})
|
|
|
|
it('refuses a stored Session in another directory', async () => {
|
|
const other = SessionId('stored-other')
|
|
const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-stored-')))
|
|
tempDirs.push(cwd)
|
|
const { remote, resumed } = await harness({
|
|
presets: ['standard'],
|
|
stored: new Map([[other, { header: storedHeader(other, '/elsewhere'), events: [modelSelection(0)] }]]),
|
|
})
|
|
|
|
const response = await remote.create({ sessionId: other, cwd })
|
|
|
|
expect(response).toMatchObject({
|
|
ok: false,
|
|
error: { code: 'session/conflict', details: { sessionId: 'stored-other', existingCwd: '/elsewhere' } },
|
|
})
|
|
expect(resumed).toEqual([])
|
|
})
|
|
|
|
it('leaves a stored subagent identity to subagent routing', async () => {
|
|
const child = SessionId('stored-child')
|
|
const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-stored-')))
|
|
tempDirs.push(cwd)
|
|
const { remote, resumed } = await harness({
|
|
presets: ['standard'],
|
|
stored: new Map([[child, {
|
|
header: storedHeader(child, cwd, { parentSession: SessionId('parent'), origin: 'subagent' }),
|
|
events: [modelSelection(0)],
|
|
}]]),
|
|
})
|
|
|
|
const response = await remote.create({ sessionId: child, cwd })
|
|
|
|
expect(response).toMatchObject({ ok: false, error: { code: 'session/agent-busy' } })
|
|
expect(resumed).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe('session.create Workspace placement', () => {
|
|
it('reuses a blank member of the requested Workspace', async () => {
|
|
const draft = SessionId('member-draft')
|
|
const { remote } = await harness({ presets: ['standard'], workspace: { members: [draft], archived: [] } })
|
|
await remote.create({ sessionId: draft })
|
|
|
|
const reused = await remote.create({ sessionId: draft, workspaceId: WORKSPACE })
|
|
|
|
expect(reused).toMatchObject({ ok: true, value: { sessionId: 'member-draft' } })
|
|
})
|
|
|
|
it.each([
|
|
{ name: 'outside the Workspace', members: [], archived: [] },
|
|
{ name: 'archived', members: [SessionId('placed-draft')], archived: [SessionId('placed-draft')] },
|
|
])('creates a new Session instead of reusing a blank Session $name', async ({ members, archived }) => {
|
|
const draft = SessionId('placed-draft')
|
|
const { remote, selections, presetOf } = await harness({ presets: ['standard', 'minimal'], workspace: { members, archived } })
|
|
await remote.create({ sessionId: draft, agentPreset: 'minimal' })
|
|
|
|
const created = await remote.create({ sessionId: draft, workspaceId: WORKSPACE })
|
|
|
|
if (!created.ok) throw new Error(`create failed: ${created.error.message}`)
|
|
expect(created.value.sessionId).not.toBe('placed-draft')
|
|
expect(selections).toEqual([])
|
|
expect(presetOf(draft)).toBe('minimal')
|
|
})
|
|
})
|