Carries the session seq-brand master sync forward; unions the seq/offset imports with this branch's jobs augmentation in commands.ts and regenerates the catalogs and zh ports over the merged source.
246 lines
9.5 KiB
TypeScript
246 lines
9.5 KiB
TypeScript
import { Context } from '@deepseek-ai/cordis'
|
|
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
|
import type { Agent } from '@deepseek-ai/dsh-agent'
|
|
import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local'
|
|
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
import SessionStore, { SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session'
|
|
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
|
|
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
|
|
import { describe, expect, it, vi } from 'vitest'
|
|
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' }),
|
|
cwd: '/tmp',
|
|
}
|
|
|
|
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)
|
|
await ctx.plugin(AgentRegistry)
|
|
const sessionId = SessionId('controller-session')
|
|
const header: SessionHeader = {
|
|
version: 0,
|
|
id: sessionId,
|
|
createdAt: 1,
|
|
cwd: '/workspace',
|
|
isSeeded: false,
|
|
}
|
|
const events: SessionEvent[] = []
|
|
const inspect = vi.fn(() => Promise.resolve({
|
|
meta: header,
|
|
inheritedEventCount: SessionLogOffset(0),
|
|
events,
|
|
}))
|
|
ctx.provide('sessionPersistence', testSessionPersistence(ctx, {
|
|
list: () => Promise.resolve([header]),
|
|
inspect,
|
|
}) as never)
|
|
const controller = createSessionTestController(ctx, defaults)
|
|
const status = vi.fn()
|
|
const failure = vi.fn()
|
|
const activity = vi.fn()
|
|
ctx.on('api-session/status', status)
|
|
ctx.on('api-session/error', failure)
|
|
ctx.on('api-session/activity', activity)
|
|
|
|
await expect(controller.inspect(sessionId)).resolves.toEqual({
|
|
meta: header,
|
|
inheritedEventCount: SessionLogOffset(0),
|
|
events,
|
|
})
|
|
expect(inspect).toHaveBeenCalledOnce()
|
|
|
|
const session = ctx.sessions.create(sessionId, { meta: header })
|
|
const agent = {
|
|
id: sessionId,
|
|
session,
|
|
status: 'idle',
|
|
ctx,
|
|
} as Agent
|
|
ctx.agents.register(agent)
|
|
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,
|
|
inheritedEventCount: SessionLogOffset(0),
|
|
events,
|
|
})
|
|
expect(inspect).toHaveBeenCalledOnce()
|
|
ctx.emit('agent/status', { agent, status: 'running' })
|
|
ctx.emit('agent/error', { agent, turn: 1, step: 0, error: new Error('fixture failure') })
|
|
session.append('user/message', createUserMessage({
|
|
content: [{ type: 'text', text: 'hello' }],
|
|
source: { kind: 'user' },
|
|
}), { surfaceOp: 'append' })
|
|
expect(status).toHaveBeenCalledWith(sessionId, true)
|
|
expect(failure).toHaveBeenCalledWith(sessionId, expect.stringContaining('fixture failure'))
|
|
expect(activity).toHaveBeenCalledWith(sessionId, expect.any(Number))
|
|
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({
|
|
address: { kind: 'session', sessionId },
|
|
}, abort.signal)[Symbol.asyncIterator]()
|
|
await expect(iterator.next()).resolves.toMatchObject({
|
|
done: false,
|
|
value: { type: 'snapshot', cursor: 1 },
|
|
})
|
|
abort.abort()
|
|
await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined })
|
|
})
|
|
|
|
it('delegates killJob to the command controller', async () => {
|
|
const ctx = new Context()
|
|
await ctx.plugin(SessionStore)
|
|
await ctx.plugin(AgentRegistry)
|
|
await ctx.plugin(LocalJobRegistry)
|
|
ctx.jobs.attachController('controller-facade-test')
|
|
ctx.provide('sessionPersistence', testSessionPersistence(ctx, {
|
|
list: () => Promise.resolve([]),
|
|
inspect: () => Promise.resolve(undefined),
|
|
}) as never)
|
|
const controller = createSessionTestController(ctx, defaults)
|
|
const session = ctx.sessions.create()
|
|
// An unowned job is killable from any session, live agent or not.
|
|
let settle!: (outcome: { status: 'killed' }) => void
|
|
const id = ctx.jobs.start({
|
|
kind: 'bash',
|
|
label: 'facade kill',
|
|
run: () => ({
|
|
cancel: () => { settle({ status: 'killed' }) },
|
|
done: new Promise((resolve) => { settle = resolve }),
|
|
}),
|
|
})
|
|
expect(controller.killJob({ sessionId: session.id, jobId: id })).toEqual({ outcome: 'requested' })
|
|
await ctx.fiber.dispose()
|
|
})
|
|
|
|
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', isSeeded: false,
|
|
}
|
|
ctx.provide('sessionPersistence', testSessionPersistence(ctx, {
|
|
list: () => Promise.resolve([header]),
|
|
inspect: () => Promise.resolve({
|
|
meta: header,
|
|
inheritedEventCount: SessionLogOffset(0),
|
|
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: new RemoteError('gateway/internal', 'activation unavailable', {}),
|
|
})
|
|
} 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', isSeeded: false,
|
|
}
|
|
ctx.provide('sessionPersistence', testSessionPersistence(ctx, {
|
|
list: () => Promise.resolve([header]),
|
|
inspect: () => Promise.resolve({
|
|
meta: header,
|
|
inheritedEventCount: SessionLogOffset(0),
|
|
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 })
|
|
})
|
|
})
|