# Conflicts: # .agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml # .agents/notes/implemented/architecture/2026-07-31-goal-owned-durable-events.i18n.yaml # .agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml # .agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.i18n.yaml # docs/architecture.i18n.yaml # docs/architecture.md # docs/architecture.zh.md # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.i18n.yaml # docs/core-data-structures/core.md # docs/core-data-structures/core.zh.md # docs/event-producer-consumer.md # docs/module-graph.md # docs/persistence-catalog.md # examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl # examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl # examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl # examples/acp-agent/tests/snapshots/skill-load/session.jsonl # examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl # examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl # examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl # examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl # examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl # examples/acp-agent/tests/snapshots/workspace-context/session.jsonl # examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl # examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl # examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl # examples/headless-agent/tests/snapshots/pty-tools/session.jsonl # examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.expected.jsonl # examples/jsonrpc-agent/tests/snapshots/subagent-spawn/notifications.expected.jsonl # examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.1.jsonl # packages/acp/acp/README.i18n.yaml # packages/acp/acp/src/index.ts # packages/client/connection/src/client/index.ts # packages/client/runtime/README.i18n.yaml # packages/client/runtime/README.md # packages/client/runtime/README.zh.md # packages/client/runtime/src/client/sessions/conversation.ts # packages/client/runtime/src/client/sessions/session.ts # packages/client/runtime/tests/queue-store.spec.ts # packages/client/ui-conversation/src/client/input/contract.ts # packages/client/ui-goal/README.i18n.yaml # packages/compact/compact-basic/src/index.ts # packages/context/time-context/tests/time-context.spec.ts # packages/context/tmux-context/tests/tmux-context.spec.ts # packages/context/workspace-context/tests/workspace-context.spec.ts # packages/cordis/tool-cordis/src/api-catalog.ts # packages/core/agent-loop/README.i18n.yaml # packages/core/agent-loop/README.md # packages/core/agent-loop/README.zh.md # packages/core/agent-loop/src/agent.ts # packages/core/agent-loop/tests/agent.spec.ts # packages/core/agent-loop/tests/loop.spec.ts # packages/core/agent/README.i18n.yaml # packages/core/agent/README.md # packages/core/agent/README.zh.md # packages/core/agent/src/types.ts # packages/core/session/README.i18n.yaml # packages/core/session/README.md # packages/core/session/README.zh.md # packages/goal/command-goal/tests/command-goal.spec.ts # packages/goal/goal/tests/goal.spec.ts # packages/goal/tool-goal/README.i18n.yaml # packages/goal/tool-goal/README.md # packages/goal/tool-goal/README.zh.md # packages/host/apiproxy/README.i18n.yaml # packages/host/apiproxy/README.md # packages/host/apiproxy/README.zh.md # packages/host/apiproxy/src/api-proxy.ts # packages/host/apiproxy/src/api/events.schema.ts # packages/host/apiproxy/src/api/events.ts # packages/host/apiproxy/src/api/rpc.ts # packages/host/apiproxy/tests/api-proxy-commands.spec.ts # packages/host/apiproxy/tests/api-proxy-projections.spec.ts # packages/host/apiproxy/tests/rpc-schemas.spec.ts # packages/pty/pty-local/tests/index.spec.ts # packages/pty/pty-local/tests/local.spec.ts # packages/pty/tool-pty/tests/loader-composition.spec.ts # packages/pty/tool-pty/tests/tools.spec.ts # packages/session-persistence/session-checkpoint-policy/src/index.ts # packages/session-persistence/session-persistence/tests/coordinator-contract.ts # packages/skill/tool-skill/tests/tool-skill.spec.ts # packages/subagent/subagent-inprocess/README.i18n.yaml # packages/subagent/subagent-inprocess/README.md # packages/subagent/subagent-inprocess/README.zh.md # packages/subagent/subagent-inprocess/src/index.ts # packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts # packages/support/acp-snapshot/src/harness.ts # packages/ui/tui/tests/tui.spec.ts
1461 lines
58 KiB
TypeScript
1461 lines
58 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import { Context } from 'cordis'
|
|
import LlmService, { createUserMessage, freezeMessage, CallId, LlmError, MessageSource, ProviderRequestId, StreamChunk } from '@deepseek-ai/dsh-llm'
|
|
import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason, type UserMessage } from '@deepseek-ai/dsh-session'
|
|
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
|
import ToolRegistry, { defineContentToolFixture, type PostToolDecision } from '@deepseek-ai/dsh-tools'
|
|
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
|
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
|
import { ReactLoopAgent } from '../src/agent.ts'
|
|
import InvariantService from '@deepseek-ai/dsh-invariants'
|
|
import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
|
|
import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant'
|
|
import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
|
|
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
|
|
|
|
async function mountInvariants(ctx: Context): Promise<void> {
|
|
await ctx.plugin(InvariantService)
|
|
await ctx.plugin(SessionInvariant)
|
|
await ctx.plugin(AgentInvariant)
|
|
await ctx.plugin(AgentLoopInvariant)
|
|
}
|
|
|
|
function driverDone(agent: Agent): Promise<void> {
|
|
return (agent as Agent & { done: Promise<void> }).done
|
|
}
|
|
|
|
/** Regression tests for agent-loop boundary, identity, and lifecycle contracts. */
|
|
|
|
async function harness(adapter: MockAdapter) {
|
|
const ctx = new Context()
|
|
await ctx.plugin(LlmService)
|
|
await ctx.plugin(SessionStore)
|
|
await ctx.plugin(SystemPrompt)
|
|
await ctx.plugin(ToolRegistry)
|
|
await ctx.plugin(AgentRegistry)
|
|
await ctx.plugin(AgentLoop, { agents: [] })
|
|
ctx.llm.registerAdapter(['mock'], adapter)
|
|
return ctx
|
|
}
|
|
|
|
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
|
return new Promise((resolve) => {
|
|
const dispose = ctx.on('agent/status', (subject, status) => {
|
|
if (subject === agent && status === 'idle') {
|
|
dispose()
|
|
resolve()
|
|
}
|
|
})
|
|
})
|
|
}
|
|
|
|
function send(agent: Agent, text: string) {
|
|
agent.followup(createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } }))
|
|
}
|
|
|
|
function inboxText(message: UserMessage): string {
|
|
return message.content
|
|
.flatMap(block => block.type === 'text' ? [block.text] : [])
|
|
.join('')
|
|
}
|
|
|
|
describe('addressable inbox operations', () => {
|
|
it('edits in place and removes exactly one queued item', async () => {
|
|
const adapter = new MockAdapter([
|
|
textResponse('first reply'),
|
|
textResponse('edited reply'),
|
|
])
|
|
const ctx = await harness(adapter)
|
|
const agent = ctx.agentLoop.create(SessionId('inbox-actions'), { provider: 'mock', model: 'mock' })
|
|
const preStep = Promise.withResolvers<undefined>()
|
|
const release = Promise.withResolvers<undefined>()
|
|
ctx.on('agent/pre-step', async (_subject, messages, _signal, next) => {
|
|
if (messages[0]?.content[0]?.type === 'text' && messages[0].content[0].text === 'first') {
|
|
preStep.resolve(undefined)
|
|
await release.promise
|
|
}
|
|
return next()
|
|
})
|
|
|
|
send(agent, 'first')
|
|
await preStep.promise
|
|
send(agent, 'remove me')
|
|
send(agent, 'edit me')
|
|
const pending = agent.inbox.nextTurn
|
|
expect(pending.map(inboxText)).toEqual(['remove me', 'edit me'])
|
|
|
|
const remove = pending[0]!
|
|
const edit = pending[1]!
|
|
expect(agent.inbox.splice('next-turn', 1, 1, [freezeMessage({
|
|
...edit,
|
|
content: [{ type: 'text', text: 'edited' }],
|
|
})])).toEqual([edit])
|
|
expect(agent.inbox.splice('next-turn', 0, 1, [])).toEqual([remove])
|
|
|
|
const idle = waitForIdle(ctx, agent)
|
|
release.resolve(undefined)
|
|
await idle
|
|
expect(agent.session.events
|
|
.filter(event => event.type === 'user/message')
|
|
.map(event => event.type === 'user/message'
|
|
? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('')
|
|
: ''))
|
|
.toEqual(['first', 'edited'])
|
|
expect(agent.inbox.splice('next-turn', 0, 1, [])).toEqual([])
|
|
})
|
|
|
|
it('strictly transfers a queued occurrence into the open turn', async () => {
|
|
const adapter = new MockAdapter([textResponse('done')])
|
|
const ctx = await harness(adapter)
|
|
const agent = ctx.agentLoop.create(SessionId('queue-to-steer'), { provider: 'mock', model: 'mock' })
|
|
const entered = Promise.withResolvers<undefined>()
|
|
const decision = Promise.withResolvers<{ kind: 'allow' }>()
|
|
ctx.on('agent/prompt-submit', async () => {
|
|
entered.resolve(undefined)
|
|
return decision.promise
|
|
})
|
|
|
|
const enqueued: InboxItem[] = []
|
|
const discarded: InboxItem[] = []
|
|
ctx.on('agent/inbox/enqueue', (subject, item) => {
|
|
if (subject === agent) enqueued.push(item)
|
|
})
|
|
ctx.on('agent/inbox/discard', (subject, items) => {
|
|
if (subject === agent) discarded.push(...items)
|
|
})
|
|
|
|
const idle = waitForIdle(ctx, agent)
|
|
send(agent, 'open the turn')
|
|
const receipt = agent.steer(createUserMessage({
|
|
content: [{ type: 'text', text: 'steer this message' }],
|
|
source: { kind: 'user' },
|
|
}))
|
|
await entered.promise
|
|
const queued = enqueued.find(item => inboxText(item) === 'steer this message')!
|
|
|
|
expect(agent.updateInbox(queued.id, { kind: 'steer' })).toBe('applied')
|
|
const steering = enqueued.find(item => item.placement === 'steering')!
|
|
expect(steering.id).not.toBe(queued.id)
|
|
expect(steering.message).toBe(queued.message)
|
|
expect(discarded).toEqual([queued])
|
|
|
|
decision.resolve({ kind: 'allow' })
|
|
await idle
|
|
expect(agent.session.events.flatMap(event =>
|
|
event.type === 'steering/message' ? [event.data.message] : [],
|
|
)).toEqual([queued.message])
|
|
expect(await receipt.outcome).toEqual({ status: 'admitted', turn: 1, step: 1 })
|
|
expect(agent.updateInbox(queued.id, { kind: 'steer' })).toBe('not-found')
|
|
})
|
|
|
|
it('keeps a queued occurrence when the next-step window is closed', () => {
|
|
const ctx = new Context()
|
|
const session = new Session(SessionId('queue-to-steer-closed'))
|
|
const agent = new ReactLoopAgent(ctx, session.id, {}, session)
|
|
const enqueued: InboxItem[] = []
|
|
const discarded: InboxItem[] = []
|
|
ctx.on('agent/inbox/enqueue', (_subject, item) => { enqueued.push(item) })
|
|
ctx.on('agent/inbox/discard', (_subject, items) => { discarded.push(...items) })
|
|
|
|
agent.send(
|
|
createUserMessage({ content: [{ type: 'text', text: 'stay queued' }], source: { kind: 'user' } }),
|
|
{ target: 'next-turn', wakeup: false },
|
|
)
|
|
const queued = enqueued[0]!
|
|
expect(agent.updateInbox(queued.id, { kind: 'steer' })).toBe('steer-unavailable')
|
|
expect(discarded).toEqual([])
|
|
expect(agent.updateInbox(queued.id, { kind: 'remove' })).toBe('applied')
|
|
})
|
|
|
|
it('accounts for both occurrences when steering enqueue cancels reentrantly', async () => {
|
|
const adapter = new MockAdapter([textResponse('unused')])
|
|
const ctx = await harness(adapter)
|
|
const agent = ctx.agentLoop.create(SessionId('queue-to-steer-cancel'), { provider: 'mock', model: 'mock' })
|
|
const entered = Promise.withResolvers<undefined>()
|
|
const decision = Promise.withResolvers<{ kind: 'allow' }>()
|
|
ctx.on('agent/prompt-submit', async () => {
|
|
entered.resolve(undefined)
|
|
return decision.promise
|
|
})
|
|
|
|
const enqueued: InboxItem[] = []
|
|
const discarded: InboxItem[] = []
|
|
ctx.on('agent/inbox/enqueue', (subject, item) => {
|
|
if (subject !== agent) return
|
|
enqueued.push(item)
|
|
if (item.placement === 'steering') agent.cancel({ kind: 'user' })
|
|
})
|
|
ctx.on('agent/inbox/discard', (subject, items) => {
|
|
if (subject === agent) discarded.push(...items)
|
|
})
|
|
|
|
const idle = waitForIdle(ctx, agent)
|
|
send(agent, 'open the turn')
|
|
await entered.promise
|
|
send(agent, 'cancel during conversion')
|
|
const queued = enqueued.find(item => inboxText(item) === 'cancel during conversion')!
|
|
|
|
expect(agent.updateInbox(queued.id, { kind: 'steer' })).toBe('applied')
|
|
const steering = enqueued.find(item => item.placement === 'steering')!
|
|
expect(discarded).toEqual([steering, queued])
|
|
|
|
decision.resolve({ kind: 'allow' })
|
|
await idle
|
|
expect(agent.session.events.some(event => event.type === 'steering/message')).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('assistant replay provenance', () => {
|
|
it('records adapter replay state with the assembled assistant content', async () => {
|
|
const response = textResponse('unchanged')
|
|
const replayState = { private: 'state' }
|
|
response[response.length - 1] = { type: 'finish', reason: { kind: 'stop' }, replayState }
|
|
const adapter = new MockAdapter([response])
|
|
const ctx = await harness(adapter)
|
|
const agent = ctx.agentLoop.create(SessionId('replay-state'), { provider: 'mock', model: 'next-model' })
|
|
|
|
send(agent, 'go')
|
|
await waitForIdle(ctx, agent)
|
|
|
|
const recorded = agent.session.events.find(event => event.type === 'assistant/message')
|
|
expect(recorded?.type === 'assistant/message' && recorded.data.message.source).toEqual({
|
|
kind: 'model', provider: 'mock', model: 'next-model', replayState,
|
|
})
|
|
expect(agent.session.deriveMessages().at(-1)?.source).toEqual({
|
|
kind: 'model', provider: 'mock', model: 'next-model', replayState,
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('abort during tool execution ends the turn', () => {
|
|
it('parks context finalized after a tool-step abort until another wakeup', async () => {
|
|
const adapter = new MockAdapter([
|
|
toolCallResponse('c1', 'aborter', {}),
|
|
textResponse('after wake'),
|
|
])
|
|
const ctx = await harness(adapter)
|
|
const agent = ctx.agentLoop.create(SessionId('a-abort-injection'), { provider: 'mock', model: 'mock' })
|
|
ctx.tools.register(defineContentToolFixture({
|
|
name: 'aborter',
|
|
description: '',
|
|
parameters: {},
|
|
async execute() {
|
|
agent.inject(createUserMessage({ content: [{ type: 'text', text: 'accepted before abort' }], source: { kind: 'plugin', plugin: 'test' } }))
|
|
agent.cancel({ kind: 'user' })
|
|
return [{ type: 'text', text: 'done' }]
|
|
},
|
|
}))
|
|
ctx.on('tools/post-execute', async (): Promise<PostToolDecision> => ({
|
|
kind: 'accept',
|
|
additionalContexts: [createUserMessage({
|
|
content: [{ type: 'text', text: 'accepted result context after abort' }],
|
|
source: { kind: 'plugin', plugin: 'test' },
|
|
})],
|
|
}))
|
|
|
|
send(agent, 'go')
|
|
await waitForIdle(ctx, agent)
|
|
|
|
expect(agent.session.events
|
|
.filter(event => event.type === 'tool/result'
|
|
|| (event.type === 'user/message' && event.data.source.kind === 'plugin')
|
|
|| event.type === 'step/end' || event.type === 'turn/end')
|
|
.map(event => event.type))
|
|
.toEqual(['tool/result', 'step/end', 'turn/end'])
|
|
expect(agent.inbox.nextStep.map(inboxText))
|
|
.toEqual(['accepted result context after abort'])
|
|
|
|
const idle = waitForIdle(ctx, agent)
|
|
send(agent, 'wake')
|
|
await idle
|
|
|
|
expect(agent.session.events
|
|
.flatMap(event => event.type === 'user/message' && event.data.source.kind === 'plugin'
|
|
? [event.data.content]
|
|
: []))
|
|
.toEqual([
|
|
[{ type: 'text', text: 'accepted result context after abort' }],
|
|
])
|
|
})
|
|
|
|
it('records post-tool context when a later call aborts the batch', async () => {
|
|
const adapter = new MockAdapter([[
|
|
{ type: 'block-start', index: 0, blockType: 'tool-call' },
|
|
{ type: 'block-end', index: 0, block: { type: 'tool-call', id: CallId('c1'), name: 'first', arguments: '{}' } },
|
|
{ type: 'block-start', index: 1, blockType: 'tool-call' },
|
|
{ type: 'block-end', index: 1, block: { type: 'tool-call', id: CallId('c2'), name: 'aborter', arguments: '{}' } },
|
|
{ type: 'finish', reason: { kind: 'tool-calls' } },
|
|
] satisfies StreamChunk[]])
|
|
const ctx = await harness(adapter)
|
|
const agent = ctx.agentLoop.create(SessionId('a-later-abort-context'), { provider: 'mock', model: 'mock' })
|
|
ctx.tools.register(defineContentToolFixture({
|
|
name: 'first',
|
|
description: '',
|
|
parameters: {},
|
|
async execute() {
|
|
return [{ type: 'text', text: 'first done' }]
|
|
},
|
|
}))
|
|
ctx.tools.register(defineContentToolFixture({
|
|
name: 'aborter',
|
|
description: '',
|
|
parameters: {},
|
|
async execute() {
|
|
agent.cancel({ kind: 'user' })
|
|
return [{ type: 'text', text: 'aborted' }]
|
|
},
|
|
}))
|
|
ctx.on('tools/post-execute', async (exec, _result, next): Promise<PostToolDecision> => {
|
|
if (exec.callId !== CallId('c1')) return next()
|
|
return {
|
|
kind: 'accept',
|
|
additionalContexts: [createUserMessage({
|
|
content: [{ type: 'text', text: 'accepted after first result' }],
|
|
source: { kind: 'plugin', plugin: 'test' },
|
|
})],
|
|
}
|
|
})
|
|
|
|
send(agent, 'go')
|
|
await waitForIdle(ctx, agent)
|
|
|
|
const events = [...agent.session.events]
|
|
expect(events
|
|
.filter(event => event.type === 'tool/result'
|
|
|| (event.type === 'user/message' && event.data.source.kind === 'plugin')
|
|
|| event.type === 'step/end' || event.type === 'turn/end')
|
|
.map(event => event.type))
|
|
.toEqual(['tool/result', 'tool/result', 'step/end', 'turn/end'])
|
|
expect(events.flatMap(event =>
|
|
event.type === 'user/message' && event.data.source.kind === 'plugin'
|
|
? [event.data.content]
|
|
: [])[0])
|
|
.toBeUndefined()
|
|
})
|
|
|
|
it('parks result context finalized after disposal cancellation without opening another turn', async () => {
|
|
const adapter = new MockAdapter([toolCallResponse('c1', 'waiter', {})])
|
|
const ctx = await harness(adapter)
|
|
const started = Promise.withResolvers<undefined>()
|
|
let agent!: Agent
|
|
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
|
agent = inner.agentLoop.create(SessionId('a-dispose-injection'), { provider: 'mock', model: 'mock' })
|
|
}, { inject: ['agentLoop'] }))
|
|
ctx.tools.register(defineContentToolFixture({
|
|
name: 'waiter',
|
|
description: '',
|
|
parameters: {},
|
|
async execute(_args, exec) {
|
|
agent.inject(createUserMessage({ content: [{ type: 'text', text: 'accepted before disposal' }], source: { kind: 'plugin', plugin: 'test' } }))
|
|
started.resolve(undefined)
|
|
const signal = exec.signal
|
|
if (!signal) throw new Error('tool execution signal is missing')
|
|
await new Promise<void>((resolve) => {
|
|
if (signal.aborted) resolve()
|
|
else signal.addEventListener('abort', () => { resolve() }, { once: true })
|
|
})
|
|
return [{ type: 'text', text: 'done' }]
|
|
},
|
|
}))
|
|
ctx.on('tools/post-execute', async (): Promise<PostToolDecision> => ({
|
|
kind: 'accept',
|
|
additionalContexts: [createUserMessage({
|
|
content: [{ type: 'text', text: 'accepted result context during disposal' }],
|
|
source: { kind: 'plugin', plugin: 'test' },
|
|
})],
|
|
}))
|
|
|
|
send(agent, 'go')
|
|
await started.promise
|
|
await fiber.dispose()
|
|
|
|
expect(agent.session.events
|
|
.flatMap(event => event.type === 'user/message' && event.data.source.kind === 'plugin'
|
|
? [event.data.content]
|
|
: []))
|
|
.toEqual([])
|
|
expect(agent.inbox.nextStep.map(inboxText))
|
|
.toEqual(['accepted result context during disposal'])
|
|
expect(agent.session.events.filter(event => event.type === 'turn/start'))
|
|
.toHaveLength(1)
|
|
expect(agent.session.events.find(event => event.type === 'turn/end')?.data.reason)
|
|
.toEqual({ kind: 'aborted', reason: { kind: 'disposed' } })
|
|
})
|
|
|
|
it('limits injection deferral to the current tool batch', async () => {
|
|
const adapter = new MockAdapter([
|
|
[
|
|
{ type: 'block-start', index: 0, blockType: 'tool-call' },
|
|
{ type: 'block-end', index: 0, block: { type: 'tool-call', id: CallId('c1'), name: 'aborter', arguments: '{}' } },
|
|
{ type: 'block-start', index: 1, blockType: 'tool-call' },
|
|
{ type: 'block-end', index: 1, block: { type: 'tool-call', id: CallId('c2'), name: 'second', arguments: '{}' } },
|
|
{ type: 'finish', reason: { kind: 'tool-calls' } },
|
|
] satisfies StreamChunk[],
|
|
textResponse('later turn'),
|
|
])
|
|
const ctx = await harness(adapter)
|
|
const agent = ctx.agentLoop.create(SessionId('a-historical-tool-pair'), { provider: 'mock', model: 'mock' })
|
|
ctx.tools.register(defineContentToolFixture({
|
|
name: 'aborter',
|
|
description: '',
|
|
parameters: {},
|
|
async execute() {
|
|
agent.cancel({ kind: 'user' })
|
|
return [{ type: 'text', text: 'done' }]
|
|
},
|
|
}))
|
|
ctx.tools.register(defineContentToolFixture({
|
|
name: 'second',
|
|
description: '',
|
|
parameters: {},
|
|
async execute() {
|
|
return [{ type: 'text', text: 'must not run' }]
|
|
},
|
|
}))
|
|
|
|
send(agent, 'leave an unmatched historical call')
|
|
await waitForIdle(ctx, agent)
|
|
const disposeInjection = ctx.on('agent/pre-step', async (subject, _messages, { turn }, next) => {
|
|
const decision = await next()
|
|
if (subject === agent && turn === 2 && decision.kind === 'enter') {
|
|
disposeInjection()
|
|
return {
|
|
kind: 'enter' as const,
|
|
messages: [...decision.messages, createUserMessage({
|
|
content: [{ type: 'text', text: 'new turn context' }],
|
|
source: { kind: 'plugin', plugin: 'test' },
|
|
})],
|
|
}
|
|
}
|
|
return decision
|
|
})
|
|
send(agent, 'start a text-only turn')
|
|
await waitForIdle(ctx, agent)
|
|
|
|
expect(agent.session.events.flatMap(event =>
|
|
event.type === 'user/message' && event.data.source.kind === 'plugin'
|
|
? [event.data.content]
|
|
: [])[0])
|
|
.toEqual([{ type: 'text', text: 'new turn context' }])
|
|
expect(JSON.stringify(adapter.requests[1]?.messages)).toContain('new turn context')
|
|
})
|
|
})
|
|
|
|
describe('steering from late extension points is never stranded', () => {
|
|
it('steer() from an agent/turn-stopping listener continues the same turn', async () => {
|
|
const adapter = new MockAdapter([
|
|
textResponse('no tools, would stop here'),
|
|
textResponse('continued because of steering'),
|
|
])
|
|
const ctx = await harness(adapter)
|
|
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
let steeredOnce = false
|
|
ctx.on('agent/turn-stopping', () => {
|
|
if (!steeredOnce) {
|
|
steeredOnce = true
|
|
agent.steer(createUserMessage({ content: [{ type: 'text', text: 'one more thing' }], source: { kind: 'user' } }))
|
|
}
|
|
})
|
|
|
|
send(agent, 'go')
|
|
await waitForIdle(ctx, agent)
|
|
|
|
// the default decision was false (no tools), but steering forced step 2
|
|
expect(adapter.requests).toHaveLength(2)
|
|
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('one more thing')
|
|
})
|
|
|
|
})
|
|
|
|
describe('plugin exceptions are contained', () => {
|
|
it('a throwing agent/turn-stopping listener ends the turn with an error, loop survives', async () => {
|
|
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
|
|
const ctx = await harness(adapter)
|
|
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
|
|
|
let threwOnce = false
|
|
ctx.on('agent/turn-stopping', async () => {
|
|
if (!threwOnce) {
|
|
threwOnce = true
|
|
throw new Error('broken continuation plugin')
|
|
}
|
|
})
|
|
|
|
send(agent, 'first')
|
|
await waitForIdle(ctx, agent)
|
|
expect(agent.session.events.findLast(event => event.type === 'turn/end')).toMatchObject({
|
|
data: { step: 1, reason: { kind: 'error', error: 'broken continuation plugin' } },
|
|
})
|
|
|
|
// the loop is still alive: a second send works normally
|
|
send(agent, 'second')
|
|
await waitForIdle(ctx, agent)
|
|
expect(adapter.requests).toHaveLength(2)
|
|
expect(agent.status).toBe('idle')
|
|
})
|
|
|
|
})
|
|
|
|
describe('disposal leaves the two-state status contract balanced', () => {
|
|
it('disposing the fiber ends the active turn and never starts its queued tail', async () => {
|
|
const adapter = new MockAdapter(['hang'])
|
|
const ctx = await harness(adapter)
|
|
|
|
let agent!: Agent
|
|
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
|
agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' })
|
|
}, { inject: ['agentLoop'] }))
|
|
|
|
const statuses: string[] = []
|
|
const reasons: TurnEndReason[] = []
|
|
ctx.on('agent/status', (_agent, status) => void statuses.push(status))
|
|
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
|
|
|
send(agent, 'go')
|
|
await new Promise(r => setTimeout(r, 30))
|
|
send(agent, 'queued tail')
|
|
await fiber.dispose()
|
|
await driverDone(agent)
|
|
|
|
expect(statuses).toEqual(['running', 'idle'])
|
|
expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'disposed' } }])
|
|
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
|
|
const messages = agent.session.events
|
|
.filter(event => event.type === 'user/message')
|
|
.flatMap(event => event.data.content)
|
|
.flatMap(block => block.type === 'text' ? [block.text] : [])
|
|
expect(messages).toEqual(['go'])
|
|
expect(adapter.requests).toHaveLength(1)
|
|
})
|
|
|
|
it('a throwing agent/status listener cannot break disposal or leak the registry entry', async () => {
|
|
const adapter = new MockAdapter(['hang'])
|
|
const ctx = await harness(adapter)
|
|
|
|
let agent!: Agent
|
|
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
|
agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' })
|
|
}, { inject: ['agentLoop'] }))
|
|
|
|
ctx.on('agent/status', (_agent, status) => {
|
|
if (status === 'idle') throw new Error('broken status listener')
|
|
})
|
|
|
|
send(agent, 'go')
|
|
await new Promise(r => setTimeout(r, 30))
|
|
await fiber.dispose()
|
|
await driverDone(agent) // must not hang
|
|
|
|
expect(ctx.agents.get(SessionId('scoped'))).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('adapter registration, routing, and accepted-input ownership', () => {
|
|
it('duplicate adapter registration is rejected', async () => {
|
|
const ctx = new Context()
|
|
await ctx.plugin(LlmService)
|
|
const adapter = new MockAdapter([])
|
|
ctx.llm.registerAdapter(['m1'], adapter)
|
|
expect(() => ctx.llm.registerAdapter(['m1'], new MockAdapter([])))
|
|
.toThrow('already registered')
|
|
// the original registration survives the failed attempt
|
|
expect(ctx.llm.listProviders()).toEqual([{ id: 'm1', name: 'm1' }])
|
|
})
|
|
|
|
it('an agent without a model fails the step with a clear error (not NO_ADAPTER for "default")', async () => {
|
|
const adapter = new MockAdapter([textResponse('never')])
|
|
const ctx = await harness(adapter)
|
|
const agent = ctx.agentLoop.create(SessionId('a1'), {}) // no model
|
|
|
|
send(agent, 'go')
|
|
await waitForIdle(ctx, agent)
|
|
const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end')
|
|
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error'
|
|
? turnEnd.data.reason.error
|
|
: undefined).toContain('has no provider/model')
|
|
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error'
|
|
? turnEnd.data.reason.error
|
|
: undefined).toContain('agent/request')
|
|
})
|
|
|
|
it('the agent/request waterfall can supply the model for a model-less agent', async () => {
|
|
const adapter = new MockAdapter([textResponse('routed')])
|
|
const ctx = await harness(adapter)
|
|
const agent = ctx.agentLoop.create(SessionId('a1'), {}) // no model — router plugin decides
|
|
|
|
ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => {
|
|
return { ...await next(), provider: 'mock', model: 'mock' }
|
|
})
|
|
|
|
send(agent, 'go')
|
|
await waitForIdle(ctx, agent)
|
|
expect(adapter.requests).toHaveLength(1)
|
|
expect(agent.session.deriveMessages().at(-1)?.content).toEqual([{ type: 'text', text: 'routed' }])
|
|
})
|
|
|
|
it('durable inbox splices carry exact messages and steering/message preserves its source', async () => {
|
|
const adapter = new MockAdapter([toolCallResponse('c1', 'noop', {}), textResponse('done')])
|
|
const ctx = await harness(adapter)
|
|
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
|
ctx.tools.register(defineContentToolFixture({
|
|
name: 'noop',
|
|
description: '',
|
|
parameters: {},
|
|
async execute() {
|
|
agent.steer(createUserMessage({ content: [{ type: 'text', text: 's' }], source: { kind: 'plugin', plugin: 'goal' } }))
|
|
return []
|
|
},
|
|
}))
|
|
|
|
const insertedSources: MessageSource[] = []
|
|
const insertedShapes: string[][] = []
|
|
const targets: string[] = []
|
|
ctx.on('session/event', (session, event) => {
|
|
if (session !== agent.session || event.type !== 'agent/inbox/spliced') return
|
|
for (const message of event.data.inserted) {
|
|
insertedSources.push(message.source)
|
|
insertedShapes.push(Object.keys(message).sort())
|
|
targets.push(event.data.target)
|
|
}
|
|
})
|
|
|
|
send(agent, 'go') // no explicit source → default {kind:'user'} must be visible
|
|
await waitForIdle(ctx, agent)
|
|
|
|
expect(insertedSources).toEqual([
|
|
{ kind: 'user' },
|
|
{ kind: 'plugin', plugin: 'goal' },
|
|
])
|
|
expect(insertedShapes).toEqual([
|
|
['content', 'id', 'role', 'source'],
|
|
['content', 'id', 'role', 'source'],
|
|
])
|
|
expect(targets).toEqual(['next-turn', 'next-step'])
|
|
const steeringSources = agent.session.events.flatMap(e =>
|
|
e.type === 'user/message' && e.data.source.kind === 'plugin' ? [e.data.source] : [])
|
|
expect(steeringSources).toEqual([{ kind: 'plugin', plugin: 'goal' }])
|
|
})
|
|
|
|
})
|
|
|
|
describe('turn numbering continues across seeded sessions', () => {
|
|
it('a forked agent continues turn numbers after the seed log', async () => {
|
|
const first = new MockAdapter([textResponse('turn one')])
|
|
const ctx = await harness(first)
|
|
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
|
send(agent, 'first')
|
|
await waitForIdle(ctx, agent)
|
|
|
|
// fork: seed a second context's agent with the first session's log
|
|
const second = new MockAdapter([textResponse('turn two')])
|
|
const ctx2 = new Context()
|
|
await ctx2.plugin(LlmService)
|
|
await ctx2.plugin(SessionStore)
|
|
await ctx2.plugin(SystemPrompt)
|
|
await ctx2.plugin(ToolRegistry)
|
|
await ctx2.plugin(AgentRegistry)
|
|
await ctx2.plugin(AgentLoop, { agents: [] })
|
|
ctx2.llm.registerAdapter(['mock'], second)
|
|
|
|
const seeded = ctx2.sessions.create(SessionId('forked'), { seed: [...agent.session.events] })
|
|
const forked = new ReactLoopAgent(
|
|
ctx2, SessionId('forked-agent'), { provider: 'mock', model: 'mock' }, seeded,
|
|
)
|
|
|
|
const turns: number[] = []
|
|
ctx2.on('session/event', (_s, event) => { if (event.type === 'turn/start') turns.push(event.data.turn) })
|
|
forked.followup(createUserMessage({ content: [{ type: 'text', text: 'continue' }], source: { kind: 'user' } }))
|
|
await new Promise<void>((resolve) => {
|
|
ctx2.on('agent/status', (subject, status) => {
|
|
if (subject === forked && status === 'idle') resolve()
|
|
})
|
|
})
|
|
|
|
expect(turns).toEqual([2])
|
|
})
|
|
})
|
|
|
|
describe('discriminated SessionEvent narrows without casts', () => {
|
|
it('narrows event.data from event.type', () => {
|
|
const session = new Session(SessionId('s'))
|
|
const appended: SessionEvent = session.append('tool/call', {
|
|
turn: 1, step: 1, callId: CallId('c1'), name: 'echo', arguments: '{}',
|
|
})
|
|
// compile-time: this switch narrows; runtime: values flow through
|
|
switch (appended.type) {
|
|
case 'tool/call': {
|
|
expect(appended.data.callId).toBe('c1')
|
|
expect(appended.data.name).toBe('echo')
|
|
break
|
|
}
|
|
default: throw new Error('wrong narrow')
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('a finish-error stream chunk ends the turn as error, not completed', () => {
|
|
it('translates finish {kind:error} into a turn error with a logged error event', async () => {
|
|
// A finish-error chunk must not produce a completed assistant turn.
|
|
const failure = {
|
|
message: 'provider 401',
|
|
code: 'AUTH',
|
|
status: 401,
|
|
providerRetryAfterMs: 2_000,
|
|
requestId: ProviderRequestId('finish-request-1'),
|
|
}
|
|
const errorStream: StreamChunk[] = [
|
|
{ type: 'finish', reason: { kind: 'error', failure } },
|
|
]
|
|
const adapter = new MockAdapter([errorStream])
|
|
const ctx = await harness(adapter)
|
|
const agent = ctx.agentLoop.create(SessionId('a-finish-error'), { provider: 'mock', model: 'mock' })
|
|
|
|
const reasons: TurnEndReason[] = []
|
|
const errors: unknown[] = []
|
|
ctx.on('agent/error', (_agent, turn, step, error) => {
|
|
expect({ turn, step }).toEqual({ turn: 1, step: 1 })
|
|
errors.push(error)
|
|
})
|
|
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
|
|
|
send(agent, 'go')
|
|
await waitForIdle(ctx, agent)
|
|
|
|
expect(reasons).toEqual([{ kind: 'error', error: failure }])
|
|
expect(errors).toHaveLength(1)
|
|
expect(errors[0]).toBeInstanceOf(LlmError)
|
|
expect((errors[0] as LlmError).failure).toEqual(failure)
|
|
|
|
const events = [...agent.session.events]
|
|
const turnEnd = events.find(event => event.type === 'turn/end')
|
|
expect(turnEnd).toMatchObject({ data: { step: 1, reason: { kind: 'error', error: failure } } })
|
|
// A failed step must not synthesize an assistant message.
|
|
expect(events.some(event => event.type === 'assistant/message')).toBe(false)
|
|
})
|
|
|
|
it('translates finish {kind:aborted} into a turn error coded ABORTED', async () => {
|
|
const abortedStream: StreamChunk[] = [
|
|
{ type: 'finish', reason: { kind: 'aborted', failure: { message: 'model stream aborted', code: 'ABORTED' } } },
|
|
]
|
|
const adapter = new MockAdapter([abortedStream])
|
|
const ctx = await harness(adapter)
|
|
const agent = ctx.agentLoop.create(SessionId('a-finish-aborted'), { provider: 'mock', model: 'mock' })
|
|
|
|
const reasons: TurnEndReason[] = []
|
|
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
|
|
|
send(agent, 'go')
|
|
await waitForIdle(ctx, agent)
|
|
|
|
expect(reasons).toEqual([{ kind: 'error', error: { message: 'model stream aborted', code: 'ABORTED' } }])
|
|
expect([...agent.session.events].some(event => event.type === 'assistant/message')).toBe(false)
|
|
})
|
|
|
|
it('handles a finish error without a code (code key omitted)', async () => {
|
|
const errorStream: StreamChunk[] = [
|
|
{ type: 'finish', reason: { kind: 'error', failure: { message: 'codeless failure', code: 'UNKNOWN' } } },
|
|
]
|
|
const adapter = new MockAdapter([errorStream])
|
|
const ctx = await harness(adapter)
|
|
const agent = ctx.agentLoop.create(SessionId('a-finish-error-nocode'), { provider: 'mock', model: 'mock' })
|
|
|
|
const reasons: TurnEndReason[] = []
|
|
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
|
|
|
send(agent, 'go')
|
|
await waitForIdle(ctx, agent)
|
|
|
|
expect(reasons).toEqual([{ kind: 'error', error: { message: 'codeless failure', code: 'UNKNOWN' } }])
|
|
})
|
|
})
|
|
|
|
describe('step boundary publication order', () => {
|
|
it('the step/start event is in session.events when its session/event listener fires', async () => {
|
|
const adapter = new MockAdapter([textResponse('done')])
|
|
const ctx = await harness(adapter)
|
|
const agent = ctx.agentLoop.create(SessionId('a-step-order'), { provider: 'mock', model: 'mock' })
|
|
|
|
const observed: { turn: number; step: number; lastEventType: string | undefined; sawStepStart: boolean }[] = []
|
|
ctx.on('session/event', (subject, event) => {
|
|
if (subject !== agent.session || event.type !== 'step/start') return
|
|
const events = [...subject.events]
|
|
const last = events.at(-1)
|
|
observed.push({
|
|
turn: event.data.turn,
|
|
step: event.data.step,
|
|
lastEventType: last?.type,
|
|
sawStepStart: events.some(e => e.type === 'step/start' && e.data.turn === event.data.turn && e.data.step === event.data.step),
|
|
})
|
|
})
|
|
|
|
send(agent, 'go')
|
|
await waitForIdle(ctx, agent)
|
|
|
|
expect(observed).toHaveLength(1)
|
|
expect(observed[0]).toMatchObject({ turn: 1, step: 1, lastEventType: 'step/start', sawStepStart: true })
|
|
})
|
|
})
|
|
|
|
describe('turn and step boundary recovery', () => {
|
|
// The session invariant companion makes an unbalanced log fail the test.
|
|
async function balancedHarness(adapter: MockAdapter) {
|
|
const ctx = new Context()
|
|
await ctx.plugin(LlmService)
|
|
await ctx.plugin(SessionStore)
|
|
await ctx.plugin(SystemPrompt)
|
|
await ctx.plugin(ToolRegistry)
|
|
await ctx.plugin(AgentRegistry)
|
|
await ctx.plugin(AgentLoop, { agents: [] })
|
|
await mountInvariants(ctx)
|
|
ctx.llm.registerAdapter(['mock'], adapter)
|
|
return ctx
|
|
}
|
|
|
|
/** Count turn/step boundary events for balance assertions. */
|
|
function boundaryCounts(agent: Agent) {
|
|
const e = [...agent.session.events]
|
|
return {
|
|
turnStart: e.filter(x => x.type === 'turn/start').length,
|
|
turnEnd: e.filter(x => x.type === 'turn/end').length,
|
|
stepStart: e.filter(x => x.type === 'step/start').length,
|
|
stepEnd: e.filter(x => x.type === 'step/end').length,
|
|
errors: e.filter(x => x.type === 'turn/end' && x.data.reason.kind === 'error').length,
|
|
lastTurnEnd: e.findLast(x => x.type === 'turn/end'),
|
|
}
|
|
}
|
|
|
|
it('a throwing step/start observer cannot change a successful turn', async () => {
|
|
const adapter = new MockAdapter([textResponse('request completed')])
|
|
const ctx = await balancedHarness(adapter)
|
|
const agent = ctx.agentLoop.create(SessionId('a-stepstart'), { provider: 'mock', model: 'mock' })
|
|
|
|
// Session owns post-commit containment. The loop sees a successful append,
|
|
// runs the request, and balances the ordinary step and turn boundaries.
|
|
let threw = false
|
|
ctx.on('session/event', (_s, event) => {
|
|
if (event.type === 'step/start' && !threw) { threw = true; throw new Error('boom step-start') }
|
|
})
|
|
const errors: Error[] = []
|
|
ctx.on('agent/error', (_a, _t, _s, error) => {
|
|
if (error instanceof Error) errors.push(error)
|
|
})
|
|
|
|
send(agent, 'go')
|
|
await waitForIdle(ctx, agent)
|
|
|
|
const e = [...agent.session.events]
|
|
const c = boundaryCounts(agent)
|
|
expect(c).toMatchObject({ turnStart: 1, turnEnd: 1, stepStart: 1, stepEnd: 1, errors: 0 })
|
|
expect(errors).toEqual([])
|
|
// step/end precedes turn/end (the invariants oracle would reject
|
|
// turn/end-while-step-open, but assert the order explicitly too).
|
|
const stepEndIdx = e.findIndex(x => x.type === 'step/end')
|
|
const turnEndIdx = e.findIndex(x => x.type === 'turn/end')
|
|
expect(stepEndIdx).toBeGreaterThanOrEqual(0)
|
|
expect(stepEndIdx).toBeLessThan(turnEndIdx)
|
|
})
|
|
|
|
it('a pre-commit turn/start rejection leaves no durable turn state', async () => {
|
|
const adapter = new MockAdapter([])
|
|
const ctx = await balancedHarness(adapter)
|
|
const agent = ctx.agentLoop.create(SessionId('a-turnstart-veto'), { 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('reject turn-start before commit')
|
|
}
|
|
})
|
|
const errors: Error[] = []
|
|
ctx.on('agent/error', (_agent, _turn, _step, error) => {
|
|
if (error instanceof Error) errors.push(error)
|
|
})
|
|
|
|
send(agent, 'rejected')
|
|
await waitForIdle(ctx, agent)
|
|
|
|
expect(agent.session.events.some(event => event.type === 'turn/start'
|
|
|| event.type === 'user/message')).toBe(false)
|
|
expect(errors.map(error => error.message)).toEqual(['reject turn-start before commit'])
|
|
expect(adapter.requests).toHaveLength(0)
|
|
})
|
|
|
|
it('a pre-commit step/start validation failure does not invent a step boundary', async () => {
|
|
const adapter = new MockAdapter([textResponse('never reached')])
|
|
const ctx = await balancedHarness(adapter)
|
|
const agent = ctx.agentLoop.create(SessionId('a-stepstart-veto'), { 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 === 'step/start' && !rejected) {
|
|
rejected = true
|
|
throw new Error('reject step-start before commit')
|
|
}
|
|
})
|
|
send(agent, 'go')
|
|
await waitForIdle(ctx, agent)
|
|
|
|
expect(adapter.requests).toEqual([])
|
|
expect(boundaryCounts(agent)).toMatchObject({
|
|
turnStart: 1,
|
|
turnEnd: 1,
|
|
stepStart: 0,
|
|
stepEnd: 0,
|
|
errors: 1,
|
|
})
|
|
expect(agent.session.events.findLast(event => event.type === 'turn/end')).toMatchObject({
|
|
data: { step: 0, reason: { kind: 'error', error: 'reject step-start before commit' } },
|
|
})
|
|
})
|
|
|
|
it('a step/end validation failure surfaces the resulting open-step invariant', async () => {
|
|
const adapter = new MockAdapter([textResponse('completed before close validation')])
|
|
const ctx = await balancedHarness(adapter)
|
|
const agent = ctx.agentLoop.create(SessionId('a-stepend-veto'), { 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 === 'step/end' && !rejected) {
|
|
rejected = true
|
|
throw new Error('reject first step-end')
|
|
}
|
|
})
|
|
const errors: Error[] = []
|
|
ctx.on('agent/error', (_agent, _turn, _step, error) => {
|
|
if (error instanceof Error) errors.push(error)
|
|
})
|
|
|
|
send(agent, 'go')
|
|
await waitForIdle(ctx, agent)
|
|
|
|
expect(adapter.requests).toHaveLength(1)
|
|
expect(errors.map(error => error.message)).toEqual([
|
|
'reject first step-end',
|
|
'invariant violated by "@deepseek-ai/dsh-session": turn/end 1 while step 1 is still open',
|
|
])
|
|
expect(boundaryCounts(agent)).toMatchObject({
|
|
turnStart: 1,
|
|
turnEnd: 0,
|
|
stepStart: 1,
|
|
stepEnd: 0,
|
|
errors: 0,
|
|
})
|
|
})
|
|
|
|
it('a throwing agent/error listener during a step-error path still balances the turn, loop survives', async () => {
|
|
// Listener failure cannot interrupt error finalization or the next turn.
|
|
const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', failure: { message: 'provider 500', code: 'SERVER' } } }]
|
|
const adapter = new MockAdapter([errorStream, textResponse('turn 2 ok')])
|
|
const ctx = await balancedHarness(adapter)
|
|
const agent = ctx.agentLoop.create(SessionId('a-errorlistener'), { provider: 'mock', model: 'mock' })
|
|
|
|
let threw = false
|
|
ctx.on('agent/error', () => { if (!threw) { threw = true; throw new Error('boom error-listener') } })
|
|
|
|
send(agent, 'go')
|
|
await waitForIdle(ctx, agent)
|
|
|
|
const c = boundaryCounts(agent)
|
|
// turn 1 balanced despite the throwing agent/error listener.
|
|
expect(c.turnStart).toBe(1)
|
|
expect(c.turnEnd).toBe(1)
|
|
expect(c.stepStart).toBe(c.stepEnd)
|
|
expect(c.lastTurnEnd?.type === 'turn/end' && c.lastTurnEnd.data.reason).toMatchObject({
|
|
kind: 'error',
|
|
error: { message: 'provider 500', code: 'SERVER' },
|
|
})
|
|
expect(threw).toBe(true)
|
|
|
|
// loop survives: a second turn runs to completion (invariants oracle would
|
|
// throw on its turn/start if turn 1 had been left open).
|
|
send(agent, 'again')
|
|
await waitForIdle(ctx, agent)
|
|
const c2 = boundaryCounts(agent)
|
|
expect(c2.turnStart).toBe(2)
|
|
expect(c2.turnEnd).toBe(2)
|
|
expect(c2.stepStart).toBe(c2.stepEnd)
|
|
})
|
|
|
|
it('disposal during a running turn ends the turn with reason disposed (balanced)', async () => {
|
|
// The 'hang' adapter blocks in stream() until the signal aborts; disposing
|
|
// the agent's fiber mid-turn aborts the in-flight step. The turn must close
|
|
// balanced with reason disposed (no error event for a disposal).
|
|
const adapter = new MockAdapter(['hang'])
|
|
const ctx = await balancedHarness(adapter)
|
|
let agent!: Agent
|
|
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
|
agent = inner.agentLoop.create(SessionId('a-dispose'), { provider: 'mock', model: 'mock' })
|
|
}, { inject: ['agentLoop'] }))
|
|
|
|
const reasons: TurnEndReason[] = []
|
|
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
|
|
|
send(agent, 'go')
|
|
await new Promise(r => setTimeout(r, 30))
|
|
await fiber.dispose() // dispose during the hanging step
|
|
await driverDone(agent)
|
|
|
|
const e = [...agent.session.events]
|
|
const turnStarts = e.filter(x => x.type === 'turn/start').length
|
|
const turnEnds = e.filter(x => x.type === 'turn/end').length
|
|
expect(turnStarts).toBe(1)
|
|
expect(turnEnds).toBe(1) // balanced — the turn was closed despite disposal
|
|
expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'disposed' } }])
|
|
// no error reason: disposal is not a failure.
|
|
expect(e.some(x => x.type === 'turn/end' && x.data.reason.kind === 'error')).toBe(false)
|
|
})
|
|
|
|
it('contains a pre-step throw after disposal without opening a turn', async () => {
|
|
const adapter = new MockAdapter([textResponse('never reached')])
|
|
const ctx = await balancedHarness(adapter)
|
|
let agent!: Agent
|
|
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
|
agent = inner.agentLoop.create(SessionId('a-prestep-dispose-throw'), { provider: 'mock', model: 'mock' })
|
|
}, { inject: ['agentLoop'] }))
|
|
|
|
let threw = false
|
|
ctx.on('agent/pre-step', (_subject, _messages, _context, next) => {
|
|
if (threw) return next()
|
|
threw = true
|
|
void fiber.dispose()
|
|
throw new Error('boom pre-step during disposal')
|
|
})
|
|
const errorEmits: Error[] = []
|
|
ctx.on('agent/error', (_a, _t, _s, error) => {
|
|
if (error instanceof Error) errorEmits.push(error)
|
|
})
|
|
|
|
send(agent, 'go')
|
|
await agent.whenIdle()
|
|
|
|
const e = [...agent.session.events]
|
|
expect(e.some(x => x.type === 'turn/start')).toBe(false)
|
|
expect(e.some(x => x.type === 'turn/end')).toBe(false)
|
|
expect(e.some(x => x.type === 'step/start')).toBe(false)
|
|
expect(errorEmits).toHaveLength(0)
|
|
})
|
|
|
|
it('a throwing turn/start observer cannot starve the loop or later turns', async () => {
|
|
const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')])
|
|
const ctx = await harness(adapter)
|
|
const agent = ctx.agentLoop.create(SessionId('a-preturn'), { provider: 'mock', model: 'mock' })
|
|
|
|
let threw = false
|
|
ctx.on('session/event', (_session, event) => {
|
|
if (!threw && event.type === 'turn/start') { threw = true; throw new Error('boom turn/start append') }
|
|
})
|
|
const errors: Error[] = []
|
|
ctx.on('agent/error', (_a, _t, _s, error) => {
|
|
if (error instanceof Error) errors.push(error)
|
|
})
|
|
|
|
send(agent, 'go')
|
|
await waitForIdle(ctx, agent)
|
|
|
|
expect(errors).toEqual([])
|
|
// Session contains the observer failure per listener, so the committed turn
|
|
// remains visible to later observers and executes normally.
|
|
const types = [...agent.session.events].map(e => e.type)
|
|
expect(types.filter(t => t === 'turn/start')).toHaveLength(1)
|
|
expect(types.filter(t => t === 'turn/end')).toHaveLength(1)
|
|
const lastBoundary = [...agent.session.events].reverse().find(e => e.type === 'turn/start' || e.type === 'turn/end')
|
|
expect(lastBoundary?.type).toBe('turn/end')
|
|
expect(agent.session.events.at(-1)?.type).toBe('turn/end')
|
|
|
|
// loop survives: a second turn runs normally.
|
|
send(agent, 'second')
|
|
await waitForIdle(ctx, agent)
|
|
expect(adapter.requests).toHaveLength(2)
|
|
})
|
|
|
|
it('a throwing step/end observer cannot rewrite the turn outcome', async () => {
|
|
const adapter = new MockAdapter([textResponse('all good'), textResponse('turn 2 ok')])
|
|
const ctx = await balancedHarness(adapter)
|
|
const agent = ctx.agentLoop.create(SessionId('a-stepend-throw'), { provider: 'mock', model: 'mock' })
|
|
|
|
let threw = false
|
|
ctx.on('session/event', (_s, event) => {
|
|
if (event.type === 'step/end' && !threw) { threw = true; throw new Error('boom step-end') }
|
|
})
|
|
const errors: Error[] = []
|
|
ctx.on('agent/error', (_a, _t, _s, error) => {
|
|
if (error instanceof Error) errors.push(error)
|
|
})
|
|
|
|
send(agent, 'go')
|
|
await waitForIdle(ctx, agent)
|
|
|
|
const c = boundaryCounts(agent)
|
|
expect(c).toMatchObject({ turnStart: 1, turnEnd: 1, stepStart: 1, stepEnd: 1, errors: 0 })
|
|
expect(errors).toEqual([])
|
|
expect(c.lastTurnEnd?.type === 'turn/end' && c.lastTurnEnd.data.reason)
|
|
.toEqual({ kind: 'completed' })
|
|
|
|
// step/end precedes turn/end (ordering contract)
|
|
const e = [...agent.session.events]
|
|
const stepEndIdx = e.findIndex(x => x.type === 'step/end')
|
|
const turnEndIdx = e.findIndex(x => x.type === 'turn/end')
|
|
expect(stepEndIdx).toBeGreaterThanOrEqual(0)
|
|
expect(stepEndIdx).toBeLessThan(turnEndIdx)
|
|
|
|
// loop survives: a subsequent turn runs to completion
|
|
send(agent, 'again')
|
|
await waitForIdle(ctx, agent)
|
|
const c2 = boundaryCounts(agent)
|
|
expect(c2.turnStart).toBe(2)
|
|
expect(c2.turnEnd).toBe(2)
|
|
expect(c2.stepStart).toBe(c2.stepEnd)
|
|
})
|
|
|
|
it('a throwing step/end observer cannot interrupt error finalization', async () => {
|
|
// Observer failure after step/end commit cannot interrupt turn finalization.
|
|
const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', failure: { message: 'provider 500', code: 'SERVER' } } }]
|
|
const adapter = new MockAdapter([errorStream, textResponse('turn 2 ok')])
|
|
const ctx = await harness(adapter)
|
|
const agent = ctx.agentLoop.create(SessionId('a-stependthrow'), { provider: 'mock', model: 'mock' })
|
|
|
|
let threw = false
|
|
ctx.on('session/event', (_s, event) => {
|
|
if (!threw && event.type === 'step/end') { threw = true; throw new Error('boom step/end listener') }
|
|
})
|
|
const errors: Error[] = []
|
|
ctx.on('agent/error', (_a, _t, _s, error) => {
|
|
if (error instanceof Error) errors.push(error)
|
|
})
|
|
|
|
send(agent, 'go')
|
|
await waitForIdle(ctx, agent)
|
|
|
|
const e = [...agent.session.events]
|
|
// Both step/end and turn/end are present — finalization ran to completion.
|
|
expect(e.some(x => x.type === 'step/end')).toBe(true)
|
|
expect(e.some(x => x.type === 'turn/end')).toBe(true)
|
|
expect(e.at(-1)?.type).toBe('turn/end')
|
|
expect(errors).toHaveLength(1)
|
|
expect(errors[0]).toBeInstanceOf(LlmError)
|
|
expect((errors[0] as LlmError).failure).toEqual({ message: 'provider 500', code: 'SERVER' })
|
|
|
|
// loop survives.
|
|
send(agent, 'again')
|
|
await waitForIdle(ctx, agent)
|
|
expect(e.filter(x => x.type === 'turn/start').length).toBeGreaterThanOrEqual(1)
|
|
})
|
|
|
|
it('a throwing session/event listener on turn/end is contained (turn still balanced, loop survives)', async () => {
|
|
// Session contains the observer failure after committing turn/end, so the
|
|
// boundary stays authoritative and the loop continues normally.
|
|
const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')])
|
|
const ctx = await harness(adapter)
|
|
const agent = ctx.agentLoop.create(SessionId('a-turnendappend'), { provider: 'mock', model: 'mock' })
|
|
|
|
let threw = false
|
|
ctx.on('session/event', (_s, event) => {
|
|
if (!threw && event.type === 'turn/end') { threw = true; throw new Error('boom turn/end listener') }
|
|
})
|
|
|
|
send(agent, 'go')
|
|
await waitForIdle(ctx, agent)
|
|
// turn 1 is balanced despite the throwing turn/end listener.
|
|
const e1 = [...agent.session.events]
|
|
expect(e1.filter(x => x.type === 'turn/start')).toHaveLength(1)
|
|
expect(e1.filter(x => x.type === 'turn/end')).toHaveLength(1)
|
|
expect(e1.at(-1)?.type).toBe('turn/end')
|
|
|
|
// loop survives: a second turn runs to completion.
|
|
send(agent, 'again')
|
|
await waitForIdle(ctx, agent)
|
|
expect(adapter.requests).toHaveLength(2)
|
|
expect([...agent.session.events].filter(x => x.type === 'turn/end')).toHaveLength(2)
|
|
})
|
|
})
|
|
|
|
describe('tool result call identity', () => {
|
|
it('the loop records tool/result under the model call.id even when a post-execute listener replaces content', async () => {
|
|
// Model emits a tool-call with id "c1", then a final text turn.
|
|
const adapter = new MockAdapter([
|
|
toolCallResponse('c1', 'echo', { x: 1 }),
|
|
textResponse('done'),
|
|
])
|
|
const ctx = await harness(adapter)
|
|
ctx.tools.register(defineContentToolFixture({
|
|
name: 'echo',
|
|
description: 'echo',
|
|
parameters: { x: { type: 'number' } },
|
|
async execute() { return [{ type: 'text', text: 'ok' }] },
|
|
}))
|
|
|
|
// A post-execute listener transforms the result (accept-with-replacement).
|
|
// The loop must still record the tool/result under the model's authoritative
|
|
// call.id, which is the immutable identity carried by the execution input.
|
|
ctx.on('tools/post-execute', (exec, _result) => {
|
|
expect(exec.callId).toBe(CallId('c1')) // the loop passed the real id in
|
|
return Promise.resolve({ kind: 'accept', content: [{ type: 'text', text: 'ok' }] })
|
|
}, { prepend: true })
|
|
|
|
const agent = ctx.agentLoop.create(SessionId('a-callid'), { provider: 'mock', model: 'mock' })
|
|
send(agent, 'use tool')
|
|
await waitForIdle(ctx, agent)
|
|
|
|
// The logged tool/result.callId is the originating call.id.
|
|
const resultEvent = [...agent.session.events].find(e => e.type === 'tool/result')
|
|
expect(resultEvent?.type).toBe('tool/result')
|
|
if (resultEvent?.type === 'tool/result') {
|
|
expect(resultEvent.data.message.source.callId).toBe(CallId('c1'))
|
|
}
|
|
|
|
// And deriveMessages pairs the tool-result with the assistant tool-call:
|
|
// the derived tool-result block's toolCallId equals the original call.id.
|
|
const messages = agent.session.deriveMessages()
|
|
const toolResultBlock = messages
|
|
.flatMap(m => m.content)
|
|
.find(b => b.type === 'tool-result')
|
|
expect(toolResultBlock?.type).toBe('tool-result')
|
|
if (toolResultBlock?.type === 'tool-result') {
|
|
expect(toolResultBlock.toolCallId).toBe(CallId('c1'))
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('disposal and cancellation during pre-step assembly', () => {
|
|
it('disposal during system-prompt assembly prevents the turn from opening', { timeout: 30000 }, async () => {
|
|
// Start disposal, then release assembly. Do not await disposal first: it
|
|
// waits for the blocked driver to exit.
|
|
const adapter = new MockAdapter(['hang'])
|
|
let releaseAssemble!: () => void
|
|
const blocked = new Promise<void>(r => void (releaseAssemble = r))
|
|
|
|
const ctx = new Context()
|
|
await ctx.plugin(LlmService)
|
|
await ctx.plugin(SessionStore)
|
|
await ctx.plugin(SystemPrompt)
|
|
await ctx.plugin(ToolRegistry)
|
|
await ctx.plugin(AgentRegistry)
|
|
await ctx.plugin(AgentLoop, { agents: [] })
|
|
await mountInvariants(ctx)
|
|
ctx.llm.registerAdapter(['mock'], adapter)
|
|
|
|
// Parent-owned listener survives agent-fiber disposal.
|
|
const unlisten = ctx.on('system-prompt/assemble', async function (_assembly, _context, next) {
|
|
await blocked
|
|
return next()
|
|
})
|
|
|
|
let agent!: Agent
|
|
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
|
agent = inner.agentLoop.create(SessionId('a-dispose-assemble'), { provider: 'mock', model: 'mock' })
|
|
}, { inject: ['agentLoop'] }))
|
|
|
|
const reasons: TurnEndReason[] = []
|
|
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
|
|
|
send(agent, 'go')
|
|
// Give the loop time to reach pre-step assembly.
|
|
await new Promise(r => setTimeout(r, 50))
|
|
|
|
// Release assembly before awaiting disposal because disposal joins the blocked driver.
|
|
const disposalDone = fiber.dispose()
|
|
|
|
releaseAssemble()
|
|
await disposalDone
|
|
await driverDone(agent)
|
|
unlisten()
|
|
|
|
const e = [...agent.session.events]
|
|
expect(e.some(x => x.type === 'turn/start')).toBe(false)
|
|
expect(e.some(x => x.type === 'turn/end')).toBe(false)
|
|
expect(e.some(x => x.type === 'step/start')).toBe(false)
|
|
expect(e.some(x => x.type === 'step/end')).toBe(false)
|
|
expect(e.some(x => x.type === 'assistant/chunk')).toBe(false)
|
|
})
|
|
|
|
it('cancel during system-prompt assembly prevents the turn from opening', { timeout: 30000 }, async () => {
|
|
const adapter = new MockAdapter([textResponse('should not appear')])
|
|
let releaseAssemble!: () => void
|
|
const blocker = new Promise<void>(r => void (releaseAssemble = r))
|
|
|
|
const ctx = new Context()
|
|
await ctx.plugin(LlmService)
|
|
await ctx.plugin(SessionStore)
|
|
await ctx.plugin(SystemPrompt)
|
|
await ctx.plugin(ToolRegistry)
|
|
await ctx.plugin(AgentRegistry)
|
|
await ctx.plugin(AgentLoop, { agents: [] })
|
|
await mountInvariants(ctx)
|
|
ctx.llm.registerAdapter(['mock'], adapter)
|
|
|
|
const unlisten = ctx.on('system-prompt/assemble', async function (_assembly, _context, next) {
|
|
await blocker
|
|
return next()
|
|
})
|
|
|
|
let agent!: Agent
|
|
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
|
agent = inner.agentLoop.create(SessionId('a-cancel-assemble'), { provider: 'mock', model: 'mock' })
|
|
}, { inject: ['agentLoop'] }))
|
|
|
|
const reasons: TurnEndReason[] = []
|
|
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
|
|
|
send(agent, 'go')
|
|
await new Promise(r => setTimeout(r, 50))
|
|
agent.cancel({ kind: 'user' })
|
|
|
|
releaseAssemble()
|
|
await waitForIdle(ctx, agent)
|
|
await fiber.dispose()
|
|
await driverDone(agent)
|
|
unlisten()
|
|
|
|
const e = [...agent.session.events]
|
|
expect(e.some(x => x.type === 'turn/start')).toBe(false)
|
|
expect(e.some(x => x.type === 'turn/end')).toBe(false)
|
|
expect(e.some(x => x.type === 'step/start')).toBe(false)
|
|
expect(e.some(x => x.type === 'step/end')).toBe(false)
|
|
expect(e.some(x => x.type === 'assistant/chunk')).toBe(false)
|
|
expect(e.some(x => x.type === 'assistant/message')).toBe(false)
|
|
expect(adapter.requests).toHaveLength(0)
|
|
expect(reasons).toEqual([])
|
|
})
|
|
|
|
it('disposal during pre-step prevents the turn from opening', { timeout: 15000 }, async () => {
|
|
// Start disposal, then release pre-step; awaiting disposal first would deadlock on the blocked driver.
|
|
const adapter = new MockAdapter(['hang'])
|
|
let releasePreStep!: () => void
|
|
const blocker = new Promise<void>(r => void (releasePreStep = r))
|
|
|
|
const ctx = new Context()
|
|
await ctx.plugin(LlmService)
|
|
await ctx.plugin(SessionStore)
|
|
await ctx.plugin(SystemPrompt)
|
|
await ctx.plugin(ToolRegistry)
|
|
await ctx.plugin(AgentRegistry)
|
|
await ctx.plugin(AgentLoop, { agents: [] })
|
|
await mountInvariants(ctx)
|
|
ctx.llm.registerAdapter(['mock'], adapter)
|
|
|
|
ctx.on('agent/pre-step', async (_subject, _messages, _context, next) => {
|
|
await blocker
|
|
return next()
|
|
})
|
|
|
|
let agent!: Agent
|
|
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
|
agent = inner.agentLoop.create(SessionId('a-dispose-prestep'), { provider: 'mock', model: 'mock' })
|
|
}, { inject: ['agentLoop'] }))
|
|
|
|
const reasons: TurnEndReason[] = []
|
|
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
|
|
|
send(agent, 'go')
|
|
await new Promise(r => setTimeout(r, 50))
|
|
|
|
const disposalDone = fiber.dispose()
|
|
releasePreStep()
|
|
await disposalDone
|
|
await driverDone(agent)
|
|
|
|
// The post-listener cancellation check catches disposal before any turn or LLM call.
|
|
const e = [...agent.session.events]
|
|
expect(e.some(x => x.type === 'turn/start')).toBe(false)
|
|
expect(e.some(x => x.type === 'turn/end')).toBe(false)
|
|
expect(e.some(x => x.type === 'step/start')).toBe(false)
|
|
expect(e.some(x => x.type === 'assistant/chunk')).toBe(false)
|
|
expect(reasons).toEqual([])
|
|
})
|
|
|
|
it('cancel during pre-step prevents the turn from opening', { timeout: 15000 }, async () => {
|
|
// Release pre-step after cancellation to exercise the post-listener check.
|
|
const adapter = new MockAdapter(['hang'])
|
|
let releasePreStep!: () => void
|
|
const blocker = new Promise<void>(r => void (releasePreStep = r))
|
|
|
|
const ctx = new Context()
|
|
await ctx.plugin(LlmService)
|
|
await ctx.plugin(SessionStore)
|
|
await ctx.plugin(SystemPrompt)
|
|
await ctx.plugin(ToolRegistry)
|
|
await ctx.plugin(AgentRegistry)
|
|
await ctx.plugin(AgentLoop, { agents: [] })
|
|
await mountInvariants(ctx)
|
|
ctx.llm.registerAdapter(['mock'], adapter)
|
|
|
|
ctx.on('agent/pre-step', async (_subject, _messages, _context, next) => {
|
|
await blocker
|
|
return next()
|
|
})
|
|
|
|
let agent!: Agent
|
|
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
|
agent = inner.agentLoop.create(SessionId('a-cancel-prestep'), { provider: 'mock', model: 'mock' })
|
|
}, { inject: ['agentLoop'] }))
|
|
|
|
const reasons: TurnEndReason[] = []
|
|
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
|
|
|
send(agent, 'go')
|
|
await new Promise(r => setTimeout(r, 30))
|
|
agent.cancel({ kind: 'user' })
|
|
|
|
releasePreStep()
|
|
await waitForIdle(ctx, agent)
|
|
await fiber.dispose()
|
|
await driverDone(agent)
|
|
|
|
const e = [...agent.session.events]
|
|
expect(e.some(x => x.type === 'turn/start')).toBe(false)
|
|
expect(e.some(x => x.type === 'turn/end')).toBe(false)
|
|
expect(e.some(x => x.type === 'step/start')).toBe(false)
|
|
expect(e.some(x => x.type === 'assistant/chunk')).toBe(false)
|
|
expect(reasons).toEqual([])
|
|
})
|
|
|
|
it('disposal during assembly does not leak an LLM call or append assistant/chunk', { timeout: 15000 }, async () => {
|
|
// The key assertion from the original bug report: after disposal, no
|
|
// assistant/chunk or assistant/message appears — the turn ends disposed
|
|
// before any model interaction.
|
|
const adapter = new MockAdapter([textResponse('should not appear')])
|
|
let releaseAssemble!: () => void
|
|
const blocker = new Promise<void>(r => void (releaseAssemble = r))
|
|
|
|
const ctx = new Context()
|
|
await ctx.plugin(LlmService)
|
|
await ctx.plugin(SessionStore)
|
|
await ctx.plugin(SystemPrompt)
|
|
await ctx.plugin(ToolRegistry)
|
|
await ctx.plugin(AgentRegistry)
|
|
await ctx.plugin(AgentLoop, { agents: [] })
|
|
await mountInvariants(ctx)
|
|
ctx.llm.registerAdapter(['mock'], adapter)
|
|
|
|
ctx.on('system-prompt/assemble', async function (_assembly, _context, next) {
|
|
await blocker
|
|
return next()
|
|
})
|
|
|
|
let agent!: Agent
|
|
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
|
agent = inner.agentLoop.create(SessionId('a-dispose-no-leak'), { provider: 'mock', model: 'mock' })
|
|
}, { inject: ['agentLoop'] }))
|
|
|
|
send(agent, 'go')
|
|
await new Promise(r => setTimeout(r, 50))
|
|
|
|
const disposalDone = fiber.dispose()
|
|
releaseAssemble()
|
|
await disposalDone
|
|
await driverDone(agent)
|
|
|
|
const e = [...agent.session.events]
|
|
expect(e.some(x => x.type === 'turn/start')).toBe(false)
|
|
expect(e.some(x => x.type === 'turn/end')).toBe(false)
|
|
expect(e.some(x => x.type === 'assistant/chunk')).toBe(false)
|
|
expect(e.some(x => x.type === 'assistant/message')).toBe(false)
|
|
expect(adapter.requests).toHaveLength(0)
|
|
})
|
|
})
|