Merge origin/master into codex/send-one-turn

This commit is contained in:
pku-xht
2026-07-20 11:52:30 +08:00
1250 changed files with 64146 additions and 16415 deletions

View File

@@ -4,10 +4,15 @@ import LlmService, { CallId, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, maxTokensResponse, textResponse, toolCallResponse } from './mock-adapter.ts'
function driverDone(agent: Agent): Promise<void> {
return (agent as Agent & { done: Promise<void> }).done
}
async function harness(adapter: MockAdapter, persona = '') {
const ctx = new Context()
await ctx.plugin(LlmService)
@@ -25,7 +30,7 @@ async function harness(adapter: MockAdapter, persona = '') {
* invoke this right after send(), when the loop hasn't woken yet (status is
* still 'idle' synchronously), so polling the current status would lie.
*/
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
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') {
@@ -36,7 +41,7 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
})
}
function send(agent: ReactLoopAgent, text: string) {
function send(agent: Agent, text: string) {
agent.send([{ type: 'text', text }])
}
@@ -44,7 +49,7 @@ describe('agent loop', () => {
it('runs a simple turn: queued message → model → idle, with ordered events', async () => {
const adapter = new MockAdapter([textResponse('hello there')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
// All boundaries — turn and step — are durable session events on the
// session/event feed (no agent/* mirror). Record them in fire order to
@@ -92,7 +97,7 @@ describe('agent loop', () => {
return [{ type: 'text', text: `echo: ${args.text}` }]
},
}))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
send(agent, 'use the tool')
await waitForIdle(ctx, agent)
@@ -131,7 +136,7 @@ describe('agent loop', () => {
return { content: [{ type: 'text', text: 'ok' }], meta: { diffs: [{ path: 'a.txt', oldText: null, newText: 'x' }] } }
},
}))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
send(agent, 'use the tool')
await waitForIdle(ctx, agent)
@@ -155,7 +160,7 @@ describe('agent loop', () => {
return []
},
}))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
send(agent, 'hi')
await waitForIdle(ctx, agent)
@@ -169,13 +174,12 @@ describe('agent loop', () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter, 'Working in {{cwd}}.')
const handle = await ctx.agents.create({
agentId: AgentId('a-cwd'),
sessionId: SessionId('s-cwd'),
meta: { cwd: '/work/space' },
agentOptions: { model: 'mock' },
agentOptions: { provider: 'mock', model: 'mock' },
})
const agent = handle.agent as ReactLoopAgent
const agent = handle.agent
send(agent, 'hi')
await waitForIdle(ctx, agent)
@@ -188,7 +192,7 @@ describe('agent loop', () => {
const ctx = await harness(adapter, 'In {{cwd}}.')
const errors: Error[] = []
ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
send(agent, 'hi')
await waitForIdle(ctx, agent)
@@ -223,13 +227,14 @@ describe('agent loop', () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter, 'You run on {{model}}.')
ctx.on('system-prompt/assemble', async (assembly, _context, next) => {
assembly.variables['provider'] = 'mock'
assembly.variables['model'] = 'mock'
return next()
})
ctx.on('agent/request', async (_agent, _turn, _step, config, _next) => {
return { ...config, model: 'mock' }
return { ...config, provider: 'mock', model: 'mock' }
})
const agent = ctx.agentLoop.create(AgentId('a-late-model'), {})
const agent = ctx.agentLoop.create(SessionId('a-late-model'), {})
send(agent, 'hi')
await waitForIdle(ctx, agent)
@@ -255,7 +260,7 @@ describe('agent loop', () => {
parameters: {},
execute: () => Promise.resolve({ content: [{ type: 'text' as const, text: 'apparent success' }], meta }),
}))
const agent = ctx.agentLoop.create(AgentId('bad-meta-agent'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('bad-meta-agent'), { provider: 'mock', model: 'mock' })
send(agent, 'use the tool')
await waitForIdle(ctx, agent)
@@ -284,7 +289,7 @@ describe('agent loop', () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
ctx.on('system-prompt/assemble', async () => ({ sections: [], tools: [], variables: {} }))
const agent = ctx.agentLoop.create(AgentId('a-no-system'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a-no-system'), { provider: 'mock', model: 'mock' })
send(agent, 'hi')
await waitForIdle(ctx, agent)
@@ -296,7 +301,7 @@ describe('agent loop', () => {
it('records raw chunks for replay as assistant/chunk session events', async () => {
const adapter = new MockAdapter([textResponse('abc')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
send(agent, 'hi')
await waitForIdle(ctx, agent)
@@ -320,7 +325,7 @@ describe('agent loop', () => {
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
ctx.tools.register(defineTool({
name: 'slow',
description: '',
@@ -352,7 +357,7 @@ describe('agent loop', () => {
it('same-tick idle steering inherits one-send-one-turn FIFO behavior', async () => {
const adapter = new MockAdapter([textResponse('first'), textResponse('second')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const idle = waitForIdle(ctx, agent)
agent.steer([{ type: 'text', text: 'first idle steer' }])
@@ -372,7 +377,7 @@ describe('agent loop', () => {
it('inject() while idle wraps context in a one-shot turn, visible to the next request', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.inject([{ type: 'text', text: 'file changed: a.ts' }], { source: { kind: 'plugin', plugin: 'watcher' } })
// The idle inject records a self-contained turn (turn/start → context/message
@@ -396,7 +401,7 @@ describe('agent loop', () => {
it('inject() can persist raw structured context without the generic context envelope', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('raw-context'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('raw-context'), { provider: 'mock', model: 'mock' })
const text = '<system-reminder>Additional instructions from: pkg/AGENTS.md</system-reminder>'
const meta = {
kind: 'workspace-instructions',
@@ -419,22 +424,30 @@ describe('agent loop', () => {
expect(requestText).not.toContain('<context source=')
})
it('inject() while running appends into the open turn (no extra synthetic turn)', async () => {
it('defers inject() during tool execution until after the tool result', async () => {
const adapter = new MockAdapter([
toolCallResponse('c1', 'noticer', {}, 'calling'),
textResponse('done'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
// A tool that injects mid-execution: at this point the agent is running, so
// inject must append the context/message into the ALREADY-open turn rather
// than wrap it in its own one-shot turn.
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let visibleDuringTool = false
const meta = { kind: 'deferred-test', version: 1 }
ctx.tools.register(defineTool({
name: 'noticer',
description: 'injects a notice',
parameters: {},
async execute() {
agent.inject([{ type: 'text', text: 'mid-turn notice' }], { source: { kind: 'plugin', plugin: 'x' } })
await Promise.resolve()
const first = { type: 'text' as const, text: 'mid-turn notice' }
agent.inject([first], {
source: { kind: 'plugin', plugin: 'x' },
envelope: 'raw',
meta,
})
first.text = 'mutated after inject'
agent.inject([{ type: 'text', text: 'second notice' }], { source: { kind: 'plugin', plugin: 'x' } })
visibleDuringTool = agent.session.events.some(e => e.type === 'context/message')
return [{ type: 'text', text: 'ok' }]
},
}))
@@ -442,13 +455,67 @@ describe('agent loop', () => {
send(agent, 'go')
await waitForIdle(ctx, agent)
// Exactly ONE turn ran (no synthetic injection turn), and the mid-turn
// context/message sits inside it.
expect(visibleDuringTool).toBe(false)
// The injection stays in the open turn, but its user-role context cannot
// split the assistant tool call from the provider's tool-result message.
const turnStarts = agent.session.events.filter(e => e.type === 'turn/start')
expect(turnStarts).toHaveLength(1)
const ts0 = turnStarts[0]!
expect(ts0.type === 'turn/start' && ts0.data.trigger.kind).toBe('message')
expect(agent.session.events.some(e => e.type === 'context/message')).toBe(true)
const result = agent.session.events.find(e => e.type === 'tool/result')!
const contexts = agent.session.events.filter(e => e.type === 'context/message')
expect(contexts).toHaveLength(2)
expect(result.seq).toBeLessThan(contexts[0]!.seq)
expect(contexts[0]?.type === 'context/message' && contexts[0].data).toMatchObject({
envelope: 'raw',
meta,
})
expect(contexts.flatMap(event => event.type === 'context/message' ? event.data.content : []))
.toEqual([
{ type: 'text', text: 'mid-turn notice' },
{ type: 'text', text: 'second notice' },
])
const secondRequest = adapter.requests[1]!.messages
const resultIndex = secondRequest.findIndex(message =>
message.content.some(block => block.type === 'tool-result'))
const contextIndexes = secondRequest.flatMap((message, index) =>
message.content.some(block => block.type === 'text'
&& (block.text.includes('mid-turn notice') || block.text.includes('second notice')))
? [index]
: [])
expect(resultIndex).toBeGreaterThanOrEqual(0)
expect(contextIndexes).toHaveLength(2)
expect(contextIndexes.every(index => index > resultIndex)).toBe(true)
})
it('rejects non-JSON context before it enters the active tool-batch FIFO', async () => {
const adapter = new MockAdapter([
toolCallResponse('c1', 'invalid-injector', {}, 'calling'),
textResponse('done'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('invalid-context'), { provider: 'mock', model: 'mock' })
ctx.tools.register(defineTool({
name: 'invalid-injector',
description: 'attempts an invalid context injection',
parameters: {},
async execute() {
expect(() => {
agent.inject([{ type: 'text', text: 'invalid' }], {
source: { kind: 'plugin', plugin: 'test' },
meta: { bigint: 1n } as never,
})
}).toThrow('agent context must be losslessly JSON-serializable')
return [{ type: 'text', text: 'rejected invalid context' }]
},
}))
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(agent.session.events.some(event => event.type === 'context/message')).toBe(false)
})
it('agent/turn-continuation can force-continue (/loop pattern) and force-stop', async () => {
@@ -459,7 +526,7 @@ describe('agent loop', () => {
textResponse('step 3'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let steps = 0
ctx.on('session/event', (_session, event) => { if (event.type === 'step/end') steps++ })
@@ -485,7 +552,7 @@ describe('agent loop', () => {
return [{ type: 'text', text: String(args.text) }]
},
}))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
ctx.on('agent/turn-continuation', async () => ({ action: 'stop' }) as const)
@@ -500,8 +567,7 @@ describe('agent loop', () => {
it('agent/request waterfall switches models by returning a replacement config; the switch is logged', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
ctx.llm.registerAdapter(['other-model'], adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
ctx.on('agent/request', async (_agent, _turn, _step, config, _next) => {
// The seed is frozen — config is not a mutable per-call knob; a switch
@@ -521,10 +587,6 @@ describe('agent loop', () => {
})
it('agent/pre-step fires once per step before the step is opened', async () => {
// Two steps (a tool call, then a final text turn) → two model calls → two
// pre-step fires, each carrying the assembled full system prompt, BEFORE
// the step is opened and its request is derived (the request the adapter
// sees reflects any surface state at fire time).
const adapter = new MockAdapter([
toolCallResponse('c1', 'echo', {}, 'calling echo'),
textResponse('done'),
@@ -534,23 +596,21 @@ describe('agent loop', () => {
name: 'echo', description: 'echo', parameters: {},
async execute() { return [{ type: 'text', text: 'echoed' }] },
}))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const fires: { turn: number; step: number; fullSystemPrompt: string }[] = []
ctx.on('agent/pre-step', (subject, turn, step, fullSystemPrompt) => {
if (subject === agent) fires.push({ turn, step, fullSystemPrompt })
const fires: { turn: number; step: number; signal: AbortSignal }[] = []
ctx.on('agent/pre-step', (subject, turn, step, signal) => {
if (subject === agent) fires.push({ turn, step, signal })
})
send(agent, 'go')
await waitForIdle(ctx, agent)
// One fire per step, in order, each with the assembled system prompt
// (here just the loop's own harness-identity section — no persona set).
const HARNESS = 'You are an AI agent powered by the DeepSeek Harness SDK.'
expect(fires).toEqual([
{ turn: 1, step: 1, fullSystemPrompt: HARNESS },
{ turn: 1, step: 2, fullSystemPrompt: HARNESS },
expect(fires.map(({ turn, step }) => ({ turn, step }))).toEqual([
{ turn: 1, step: 1 },
{ turn: 1, step: 2 },
])
expect(fires.every(({ signal }) => signal instanceof AbortSignal)).toBe(true)
})
it('agent/pre-step fires BEFORE the step it precedes opens (events land outside the step)', async () => {
@@ -558,7 +618,7 @@ describe('agent loop', () => {
// same step's request must include it.
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let injected = false
ctx.on('agent/pre-step', (subject) => {
@@ -592,7 +652,7 @@ describe('agent loop', () => {
// closing, the turn records error, and the loop remains available.
const adapter = new MockAdapter([textResponse('second turn ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let throwOnce = true
ctx.on('agent/pre-step', () => {
@@ -626,7 +686,7 @@ describe('agent loop', () => {
it('cancel() mid-stream ends the turn with reason aborted', async () => {
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const reasons: TurnEndReason[] = []
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
@@ -646,7 +706,7 @@ describe('agent loop', () => {
// turn stops by default and ends max-tokens, not completed.
const adapter = new MockAdapter([maxTokensResponse('truncat')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const reasons: TurnEndReason[] = []
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
@@ -669,7 +729,7 @@ describe('agent loop', () => {
textResponse('second half'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let steps = 0
ctx.on('session/event', (_session, event) => { if (event.type === 'step/end') steps++ })
@@ -690,7 +750,7 @@ describe('agent loop', () => {
expect(adapter.requests).toHaveLength(2)
expect(adapter.requests[1]!.messages).toEqual([
{ role: 'user', content: [{ type: 'text', text: 'go' }] },
{ role: 'assistant', content: [{ type: 'text', text: 'first half' }] },
{ role: 'assistant', content: [{ type: 'text', text: 'first half' }], provenance: { provider: 'mock', model: 'mock' } },
])
expect(reasons).toEqual([{ kind: 'max-tokens' }])
})
@@ -700,7 +760,7 @@ describe('agent loop', () => {
// stop. The per-turn reason must be independent — turn 2 ends completed.
const adapter = new MockAdapter([maxTokensResponse('cut'), textResponse('clean')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const reasons: TurnEndReason[] = []
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
@@ -733,7 +793,7 @@ describe('agent loop', () => {
return [{ type: 'text', text: 'should not run' }]
},
}))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const reasons: TurnEndReason[] = []
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
@@ -749,14 +809,13 @@ describe('agent loop', () => {
// skips that host so it does not create a spurious assistant turn.
const assistantMessage = agent.session.events.find(e => e.type === 'assistant/message')
expect(assistantMessage?.type === 'assistant/message' && assistantMessage.data).toEqual({
turn: 1, step: 1, content: [], usage: { inputTokens: 10, outputTokens: 5 },
turn: 1, step: 1, content: [], provenance: { provider: 'mock', model: 'mock' }, usage: { inputTokens: 10, outputTokens: 5 },
})
})
it('appends no assistant/message for a max-tokens step with empty content and no usage', async () => {
// A max-tokens step truncated to a dropped tool call AND with no usage chunk has nothing to
// record: empty content and no accounting → no assistant/message (the empty-content host
// exists only to carry usage).
it('appends an empty completion anchor for a max-tokens step with no usage', async () => {
// The truncated tool call is dropped from durable content, while the
// successful provider call still needs an exact replay anchor.
const callId = CallId('c1')
const adapter = new MockAdapter([[
{ type: 'block-start', index: 0, blockType: 'tool-call' },
@@ -771,7 +830,7 @@ describe('agent loop', () => {
parameters: { text: { type: 'string' } },
async execute() { return [{ type: 'text', text: 'should not run' }] },
}))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const reasons: TurnEndReason[] = []
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
@@ -780,17 +839,23 @@ describe('agent loop', () => {
await waitForIdle(ctx, agent)
expect(reasons).toEqual([{ kind: 'max-tokens' }])
expect(agent.session.events.some(e => e.type === 'assistant/message')).toBe(false)
const assistant = agent.session.events.find(e => e.type === 'assistant/message')!
expect(assistant.type === 'assistant/message' && assistant.data).toEqual({
turn: 1,
step: 1,
content: [],
provenance: { provider: 'mock', model: 'mock' },
})
expect(assistant.sourceEventSeqs?.length).toBeGreaterThan(0)
expect(agent.session.deriveMessages()).toEqual([{ role: 'user', content: [{ type: 'text', text: 'go' }] }])
})
it('appends no assistant/message for a normal stop finish with empty content and no usage', async () => {
// A clean `stop` finish that streamed nothing assembled (no blocks) and
// carried no usage chunk has nothing to record: the content-or-usage guard
// on the normal step path suppresses a pure trace-only empty assistant/message.
it('appends an empty completion anchor for a normal stop with no usage', async () => {
// A clean content-less call stays absent from derived messages but remains
// a durable successful-call boundary for replay consumers.
const adapter = new MockAdapter([[{ type: 'finish', reason: { kind: 'stop' } }]])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const reasons: TurnEndReason[] = []
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
@@ -799,7 +864,14 @@ describe('agent loop', () => {
await waitForIdle(ctx, agent)
expect(reasons).toEqual([{ kind: 'completed' }])
expect(agent.session.events.some(e => e.type === 'assistant/message')).toBe(false)
const assistant = agent.session.events.find(e => e.type === 'assistant/message')!
expect(assistant.type === 'assistant/message' && assistant.data).toEqual({
turn: 1,
step: 1,
content: [],
provenance: { provider: 'mock', model: 'mock' },
})
expect(assistant.sourceEventSeqs?.length).toBe(1)
expect(agent.session.deriveMessages()).toEqual([{ role: 'user', content: [{ type: 'text', text: 'go' }] }])
})
@@ -820,7 +892,7 @@ describe('agent loop', () => {
expect(message.content).toEqual([{ type: 'text', text: 'partial text' }])
return next()
})
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
send(agent, 'go')
await waitForIdle(ctx, agent)
@@ -829,7 +901,7 @@ describe('agent loop', () => {
expect(agent.session.events.some(e => e.type === 'tool/call')).toBe(false)
expect(agent.session.deriveMessages()).toEqual([
{ role: 'user', content: [{ type: 'text', text: 'go' }] },
{ role: 'assistant', content: [{ type: 'text', text: 'partial text' }] },
{ role: 'assistant', content: [{ type: 'text', text: 'partial text' }], provenance: { provider: 'mock', model: 'mock' } },
])
})
@@ -847,7 +919,7 @@ describe('agent loop', () => {
return [{ type: 'text', text: String(args.text) }]
},
}))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let threw = false
// Post-commit session observers cannot control the loop. The tool call still
// drives the second model request, and the turn completes normally.
@@ -866,7 +938,7 @@ describe('agent loop', () => {
it('keeps same-tick sends in separate turns and checkpoints before the next starts', async () => {
const adapter = new MockAdapter([textResponse('first answer'), textResponse('second answer')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const firstFlush = Promise.withResolvers<undefined>()
const releaseFirstFlush = Promise.withResolvers<undefined>()
@@ -906,7 +978,7 @@ describe('agent loop', () => {
it('holds a turn-end listener send behind the closing turn checkpoint', async () => {
const adapter = new MockAdapter([textResponse('first answer'), textResponse('second answer')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const firstFlush = Promise.withResolvers<undefined>()
const releaseFirstFlush = Promise.withResolvers<undefined>()
@@ -951,7 +1023,7 @@ describe('agent loop', () => {
it('keeps a reentrant agent/queued send as the next independent turn', async () => {
const adapter = new MockAdapter([textResponse('first'), textResponse('second')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let nested = false
ctx.on('agent/queued', (subject) => {
@@ -978,7 +1050,7 @@ describe('agent loop', () => {
it('preserves independent turn sources across an adjacent microtask send', async () => {
const adapter = new MockAdapter([textResponse('first'), textResponse('second')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const idle = waitForIdle(ctx, agent)
agent.send([{ type: 'text', text: 'user message' }])
@@ -1008,7 +1080,7 @@ describe('agent loop', () => {
it('keeps a session-listener send after dequeue in the following turn', async () => {
const adapter = new MockAdapter([textResponse('first'), textResponse('second')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const turns: number[] = []
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/start') turns.push(event.data.turn) })
@@ -1033,7 +1105,7 @@ describe('agent loop', () => {
})
it('keeps a model-adapter callback send in the following turn', async () => {
const agentRef: { current?: ReactLoopAgent } = {}
const agentRef: { current?: Agent } = {}
const adapter = new MockAdapter([
() => {
const agent = agentRef.current
@@ -1044,7 +1116,7 @@ describe('agent loop', () => {
textResponse('second'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agentRef.current = agent
const idle = waitForIdle(ctx, agent)
@@ -1064,7 +1136,7 @@ describe('agent loop', () => {
it('awaits session/flush at turn end (persistence checkpoint)', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let flushed = 0
let flushedBeforeIdle = false
@@ -1084,7 +1156,7 @@ describe('agent loop', () => {
it('errors from the model surface as agent/error and end the turn', async () => {
const adapter = new MockAdapter([]) // script exhausted → throws
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const errors: Error[] = []
const reasons: TurnEndReason[] = []
@@ -1107,21 +1179,21 @@ describe('agent loop', () => {
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
let agent!: ReactLoopAgent
let agent!: Agent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(AgentId('scoped'), { model: 'mock' })
agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' })
}, { inject: ['agentLoop'] }))
expect(ctx.agents.get(AgentId('scoped'))).toBe(agent)
expect(ctx.agents.get(SessionId('scoped'))).toBe(agent)
send(agent, 'go')
await new Promise(r => setTimeout(r, 30))
expect(agent.status).toBe('running')
await fiber.dispose()
await agent.done
await driverDone(agent)
expect(agent.status).toBe('disposed')
expect(ctx.agents.get(AgentId('scoped'))).toBeUndefined()
expect(ctx.agents.get(SessionId('scoped'))).toBeUndefined()
expect(() => { send(agent, 'too late') }).toThrow('disposed')
})
@@ -1134,13 +1206,14 @@ describe('agent loop', () => {
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, {
agents: [{ id: AgentId('config-agent'), model: 'mock' }],
agents: [{ id: SessionId('config-agent'), provider: 'mock', model: 'mock' }],
})
ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agents.get(AgentId('config-agent'))! as ReactLoopAgent
const agent = ctx.agents.list()[0]!
expect(agent).toBeDefined()
expect(agent.id).toBe('config-agent')
expect(agent.id).toBe(agent.session.id)
expect(agent.id).toMatch(/^config-agent-session-/)
expect(agent.options.model).toBe('mock')
// the agent is alive: send triggers a turn
@@ -1157,10 +1230,10 @@ describe('agent loop', () => {
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, {
agents: [{ id: AgentId('config-agent'), model: 'mock', cwd: '/work/project' }],
agents: [{ id: SessionId('config-agent'), provider: 'mock', model: 'mock', cwd: '/work/project' }],
})
const agent = ctx.agents.get(AgentId('config-agent'))! as ReactLoopAgent
const agent = ctx.agents.list()[0]!
expect(agent.session.header.cwd).toBe('/work/project')
})
@@ -1178,7 +1251,7 @@ describe('agent loop', () => {
return [{ type: 'text', text: String(args.text) }]
},
}))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
send(agent, 'run')
await waitForIdle(ctx, agent)