test: close per-file coverage gaps opened by the message-machine refactor
Downstream packages lost the tests that exercised their agent-loop-facing edges when the loop was rewritten. Restore 100% per-file coverage with behavior tests through public seams: llm-retry config validation and cancellation races, goal replay drift/staleness/teardown edges, plan-mode disposed-flush, workspace-context empty-change commits, api-proxy synchronous send failures, acp-snapshot spill-path extraction and refresh write-back, ACP injection-triggered turns, cli-demo and tui inbox lifecycle edges, and agent-loop retry/settlement/lifecycle branches. The only source changes are narrowly-justified v8 ignore annotations on invariant guards and one redundant-guard removal (workspace-context).
This commit is contained in:
@@ -441,3 +441,36 @@ describe('config-driven session id', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('startup reporting after factory teardown', () => {
|
||||
it('suppresses the configured-restore failure report once the loop is disposed', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-cfg-disposed-report-'))
|
||||
dirs.push(root)
|
||||
const ctx = await makeCoreContext()
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root })
|
||||
ctx.llm.registerAdapter(['mock'], new MockAdapter([textResponse('x')]))
|
||||
|
||||
// A restore lookup that hangs until after the loop is gone: the eventual
|
||||
// failure lands with ownership inactive and must be silently dropped.
|
||||
const gate = Promise.withResolvers<never>()
|
||||
// The teardown path may drop the pending lookup without awaiting it.
|
||||
gate.promise.catch(() => undefined)
|
||||
vi.spyOn(ctx.sessionPersistence, 'list').mockReturnValue(gate.promise)
|
||||
const failures: unknown[] = []
|
||||
ctx.on('agent-loop/config-start-failed', (_id, error) => { failures.push(error) })
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
|
||||
|
||||
const loop = await ctx.plugin(AgentLoop, {
|
||||
agents: [{ id: 'main', sessionId: SessionId('config-disposed-report'), model: 'mock' }],
|
||||
})
|
||||
const disposal = loop.dispose()
|
||||
gate.reject(new Error('backend failed after teardown began'))
|
||||
await disposal
|
||||
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
expect(failures).toEqual([])
|
||||
expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('config-driven restore'))
|
||||
warn.mockRestore()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -254,3 +254,247 @@ describe('structured tool error propagation (the runtime-validation Agent Note,
|
||||
.toEqual({ name: 'HarnessError', code: 'BOOM' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('retry() edges', () => {
|
||||
it('throws while a turn runs with no request-error window open', async () => {
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('retry-busy'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
send(agent, 'go')
|
||||
// Wait until the hung request is in flight (the run owns this.abort).
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
expect(() => { agent.retry() }).toThrow('cannot retry while busy')
|
||||
agent.cancel({ kind: 'user' })
|
||||
await agent.whenIdle()
|
||||
})
|
||||
|
||||
it('ignores a retry request arriving after the recovery window was aborted', async () => {
|
||||
const { LlmError } = await import('@deepseek-ai/dsh-llm')
|
||||
const adapter = new MockAdapter([
|
||||
() => { throw new LlmError('busy', 'RATE_LIMIT') },
|
||||
textResponse('never used'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('retry-after-cancel'), { provider: 'mock', model: 'mock' })
|
||||
ctx.on('agent/request-error', async (subject) => {
|
||||
// Cancellation lands first; the window survives structurally but its
|
||||
// signal is aborted, so the request must not arm a retry turn.
|
||||
subject.cancel({ kind: 'user' })
|
||||
subject.retry()
|
||||
})
|
||||
|
||||
send(agent, 'go')
|
||||
await agent.whenIdle()
|
||||
|
||||
// One failed request, no retry turn.
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
const ends = agent.session.events.filter(e => e.type === 'turn/end')
|
||||
expect(ends).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('completed recovery does not retry when cancellation raced the waterfall', async () => {
|
||||
const { LlmError } = await import('@deepseek-ai/dsh-llm')
|
||||
const adapter = new MockAdapter([
|
||||
() => { throw new LlmError('busy', 'RATE_LIMIT') },
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('retry-raced'), { provider: 'mock', model: 'mock' })
|
||||
ctx.on('agent/request-error', async (subject, _turn, _step, _error, _failure, signal, next) => {
|
||||
await next()
|
||||
// Recovery completes and requested the retry, but the turn signal
|
||||
// aborts before the loop reads the window.
|
||||
subject.retry()
|
||||
subject.cancel({ kind: 'user' })
|
||||
expect(signal.aborted).toBe(true)
|
||||
})
|
||||
|
||||
send(agent, 'go')
|
||||
await agent.whenIdle()
|
||||
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
const end = agent.session.events.findLast(e => e.type === 'turn/end')
|
||||
expect(end?.type === 'turn/end' && end.data.reason.kind).toBe('aborted')
|
||||
})
|
||||
})
|
||||
|
||||
describe('stream failure edges', () => {
|
||||
it('rethrows a mid-stream throw that carries no adapter failure facts', async () => {
|
||||
const adapter = new MockAdapter([textResponse('will be vetoed')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('stream-no-facts'), { provider: 'mock', model: 'mock' })
|
||||
let recoveries = 0
|
||||
ctx.on('agent/request-error', async () => { recoveries += 1 })
|
||||
// A pre-commit chunk veto throws INSIDE the stream-consumption try, but it
|
||||
// is not an adapter-boundary failure, so llmFailureOf yields no facts.
|
||||
let vetoed = false
|
||||
ctx.on('internal/dispatch', (_mode, name, args) => {
|
||||
if (name !== 'session/event') return
|
||||
const event = args[1] as SessionEvent
|
||||
if (event.type === 'assistant/chunk' && !vetoed) {
|
||||
vetoed = true
|
||||
throw new Error('reject the first chunk')
|
||||
}
|
||||
})
|
||||
|
||||
send(agent, 'go')
|
||||
await agent.whenIdle()
|
||||
|
||||
// No facts -> not offered to recovery; the turn fails through settle().
|
||||
expect(recoveries).toBe(0)
|
||||
const end = agent.session.events.findLast(e => e.type === 'turn/end')
|
||||
expect(end?.type === 'turn/end' && end.data.reason.kind).toBe('error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('post-turn continuation edges', () => {
|
||||
it('an agent/idle listener that enqueues a waking prompt preempts continueOrIdle', async () => {
|
||||
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('idle-preempt'), { provider: 'mock', model: 'mock' })
|
||||
let injected = false
|
||||
ctx.on('agent/idle', (subject) => {
|
||||
if (subject !== agent || injected) return
|
||||
injected = true
|
||||
// kick() installs the next admission synchronously, so the following
|
||||
// continueOrIdle() sees an abort owner and yields to it.
|
||||
send(agent, 'follow-up from idle listener')
|
||||
})
|
||||
|
||||
send(agent, 'go')
|
||||
await agent.whenIdle()
|
||||
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
const starts = agent.session.events.filter(e => e.type === 'turn/start')
|
||||
expect(starts).toHaveLength(2)
|
||||
// The busy interval never broke between the turns: one running->idle cycle.
|
||||
})
|
||||
|
||||
it('whenIdle resolves for a waiter whose awaited run fails', async () => {
|
||||
const adapter = new MockAdapter([textResponse('unused')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('whenidle-reject'), { provider: 'mock', model: 'mock' })
|
||||
let rejected = false
|
||||
ctx.on('internal/dispatch', (_mode, name, args) => {
|
||||
if (name !== 'session/event') return
|
||||
const event = args[1] as SessionEvent
|
||||
if (event.type === 'turn/start' && !rejected) {
|
||||
rejected = true
|
||||
throw new Error('veto turn start while a waiter is pending')
|
||||
}
|
||||
})
|
||||
|
||||
send(agent, 'go')
|
||||
await expect(agent.whenIdle()).resolves.toBeUndefined()
|
||||
expect(agent.status).toBe('idle')
|
||||
})
|
||||
})
|
||||
|
||||
describe('tool result meta persistence', () => {
|
||||
it('records a presentationMeta payload on the tool/result event', async () => {
|
||||
const { defineTool } = await import('@deepseek-ai/dsh-tools')
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('c1', 'meta-tool', {}),
|
||||
textResponse('done'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('tool-meta'), { provider: 'mock', model: 'mock' })
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'meta-tool',
|
||||
description: 'carries presentation meta',
|
||||
parameters: {},
|
||||
output: {
|
||||
schema: { type: 'string' },
|
||||
render: (_args, value) => [{ type: 'text', text: value }],
|
||||
presentationMeta: () => ({ presentation: 'diff-card' }),
|
||||
},
|
||||
async execute() {
|
||||
return 'ran'
|
||||
},
|
||||
}))
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const result = agent.session.events.find(e => e.type === 'tool/result')
|
||||
expect(result?.type === 'tool/result' && result.data.meta).toEqual({ presentation: 'diff-card' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('turn close failure containment', () => {
|
||||
it('a rejected turn/end append is contained: warn + agent/error, no retry', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('turnend-veto'), { provider: 'mock', model: 'mock' })
|
||||
let vetoed = false
|
||||
ctx.on('internal/dispatch', (_mode, name, args) => {
|
||||
if (name !== 'session/event') return
|
||||
const event = args[1] as SessionEvent
|
||||
if (event.type === 'turn/end' && !vetoed) {
|
||||
vetoed = true
|
||||
throw new Error('reject turn end')
|
||||
}
|
||||
})
|
||||
const errors: unknown[] = []
|
||||
ctx.on('agent/error', (_agent, _turn, _step, error) => { errors.push(error) })
|
||||
|
||||
send(agent, 'go')
|
||||
await agent.whenIdle()
|
||||
|
||||
// The close failure is reported live; the machine still reaches idle.
|
||||
expect(errors.map(e => e instanceof Error && e.message)).toContain('reject turn end')
|
||||
expect(agent.status).toBe('idle')
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('recovery without a retry request', () => {
|
||||
it('a completed recovery that never calls retry() leaves the failed turn terminal', async () => {
|
||||
const { LlmError } = await import('@deepseek-ai/dsh-llm')
|
||||
const adapter = new MockAdapter([
|
||||
() => { throw new LlmError('down', 'SERVICE_UNAVAILABLE') },
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('recovery-no-retry'), { provider: 'mock', model: 'mock' })
|
||||
let recoveries = 0
|
||||
ctx.on('agent/request-error', async () => { recoveries += 1 })
|
||||
|
||||
send(agent, 'go')
|
||||
await agent.whenIdle()
|
||||
|
||||
expect(recoveries).toBe(1)
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
const end = agent.session.events.findLast(e => e.type === 'turn/end')
|
||||
expect(end?.type === 'turn/end' && end.data.reason.kind).toBe('error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('unrenderable failure settlement', () => {
|
||||
it('drops the rendered message when the error chain cannot be rendered', async () => {
|
||||
const { LlmError } = await import('@deepseek-ai/dsh-llm')
|
||||
const adapter = new MockAdapter([
|
||||
() => {
|
||||
const error = new LlmError('will become hostile', 'SERVER')
|
||||
// A hostile message getter makes errorChain collapse to its sentinel;
|
||||
// settle() must then fall back to the failure facts alone.
|
||||
Object.defineProperty(error, 'message', {
|
||||
get() { throw new Error('hostile accessor') },
|
||||
})
|
||||
throw error
|
||||
},
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('unrenderable'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
send(agent, 'go')
|
||||
await agent.whenIdle()
|
||||
|
||||
const end = agent.session.events.findLast(e => e.type === 'turn/end')
|
||||
expect(end?.type === 'turn/end' && end.data.reason.kind).toBe('error')
|
||||
if (end?.type === 'turn/end' && end.data.reason.kind === 'error') {
|
||||
// The durable failure keeps the adapter facts' message, not the
|
||||
// unrenderable chain.
|
||||
expect(end.data.reason.failure?.message).not.toBe('<unrenderable value>')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -555,3 +555,97 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('creation and resume cancellation edges', () => {
|
||||
it('rejects create() with a pre-aborted signal, including a non-Error reason', async () => {
|
||||
const { ctx } = await persistentHarness(new MockAdapter([]))
|
||||
|
||||
const errorReason = new AbortController()
|
||||
errorReason.abort(new Error('caller gave up'))
|
||||
await expect(promptly(ctx.agents.create({
|
||||
sessionId: SessionId('pre-aborted-error'),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
signal: errorReason.signal,
|
||||
}))).rejects.toThrow('caller gave up')
|
||||
|
||||
// A non-Error reason is wrapped into the creation-aborted error.
|
||||
const stringReason = new AbortController()
|
||||
stringReason.abort('operator string reason')
|
||||
await expect(promptly(ctx.agents.create({
|
||||
sessionId: SessionId('pre-aborted-string'),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
signal: stringReason.signal,
|
||||
}))).rejects.toThrow(/creation aborted/)
|
||||
|
||||
expect(ctx.agents.get(SessionId('pre-aborted-error'))).toBeUndefined()
|
||||
expect(ctx.agents.get(SessionId('pre-aborted-string'))).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('a non-Error abort reason arriving during setup is wrapped for the caller', async () => {
|
||||
const { ctx } = await persistentHarness(new MockAdapter([]))
|
||||
const controller = new AbortController()
|
||||
const setupEntered = Promise.withResolvers<undefined>()
|
||||
const setupGate = Promise.withResolvers<undefined>()
|
||||
|
||||
const creating = ctx.agents.create({
|
||||
sessionId: SessionId('setup-string-abort'),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
signal: controller.signal,
|
||||
async setup() {
|
||||
setupEntered.resolve(undefined)
|
||||
await setupGate.promise
|
||||
},
|
||||
})
|
||||
await setupEntered.promise
|
||||
controller.abort('mid-setup string reason')
|
||||
setupGate.resolve(undefined)
|
||||
|
||||
await expect(promptly(creating)).rejects.toThrow(/creation aborted/)
|
||||
expect(ctx.agents.get(SessionId('setup-string-abort'))).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('resume with a pre-aborted caller signal rejects out of the load race', async () => {
|
||||
const sessionId = SessionId('resume-pre-aborted')
|
||||
const root = await persistSession(sessionId)
|
||||
const ctx = await mountPersistentHarness(root, new MockAdapter([]))
|
||||
const controller = new AbortController()
|
||||
controller.abort(new Error('resume abandoned'))
|
||||
|
||||
await expect(promptly(ctx.agents.resume({
|
||||
resumeSessionId: sessionId,
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
signal: controller.signal,
|
||||
}))).rejects.toThrow('resume abandoned')
|
||||
|
||||
expect(ctx.agents.get(sessionId)).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('factory teardown during a hung resume load rejects with loop-inactive', async () => {
|
||||
const sessionId = SessionId('resume-loop-teardown')
|
||||
const root = await persistSession(sessionId)
|
||||
const ctx = await mountPersistentHarness(root, new MockAdapter([]))
|
||||
const snapshot = await ctx.sessionPersistence.load(sessionId)
|
||||
const gate = Promise.withResolvers<typeof snapshot>()
|
||||
const loadStarted = Promise.withResolvers<undefined>()
|
||||
ctx.sessionPersistence.load = () => {
|
||||
loadStarted.resolve(undefined)
|
||||
return gate.promise
|
||||
}
|
||||
|
||||
const resuming = ctx.agents.resume({
|
||||
resumeSessionId: sessionId,
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
await loadStarted.promise
|
||||
// Resolve the load only after teardown began: the post-load ownership
|
||||
// check, not the abort race, must reject the wrapper.
|
||||
const rejection = expect(promptly(resuming)).rejects.toThrow()
|
||||
const disposal = ctx.fiber.dispose()
|
||||
gate.resolve(structuredClone(snapshot))
|
||||
await rejection
|
||||
await disposal
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user