refactor(agent): unify sourced message delivery

This commit is contained in:
_Kerman
2026-07-24 22:38:50 +08:00
parent 009d113e0e
commit 992cf894af
197 changed files with 1890 additions and 2132 deletions

View File

@@ -14,7 +14,6 @@ import type { Scope } from '@deepseek-ai/dsh-scope'
import type {
AgentMessage,
Agent,
AliasSendOptions,
CancelOptions,
AgentInterruptReason,
AgentOptions,
@@ -27,9 +26,7 @@ import type {
import {
BlockAssembler, LlmError, assertNever, deepFreeze, errorChain, isHarnessError, llmFailureOf, markAgentLoopRequest,
} from '@deepseek-ai/dsh-llm'
import type {
ContentBlock, GenerateOptions, LlmCallConfig, LlmFailure, Message,
} from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, LlmCallConfig, LlmFailure, Message } from '@deepseek-ai/dsh-llm'
import { canonicalHeader, headerEquals } from '@deepseek-ai/dsh-session'
import type { Session, SessionId, TurnEndReason, TurnTrigger, UserMessageData } from '@deepseek-ai/dsh-session'
import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
@@ -89,10 +86,11 @@ export class ReactLoopAgent implements Agent {
/** Accept and route one unified send item. */
send(
content: ContentBlock[],
input: UserMessageData,
options: SendOptions,
): AgentMessageId {
const { target, wakeup, source } = options
const { content, source } = input
const { target, wakeup } = options
const id = AgentMessageId(randomUUID())
if (target === 'next-step' && !wakeup) {
if (this.turnOpen) {
@@ -120,29 +118,26 @@ export class ReactLoopAgent implements Agent {
}
/** Queue one ordinary prompt turn and wake the driver. */
followup(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId {
return this.send(content, {
followup(input: UserMessageData): AgentMessageId {
return this.send(input, {
target: 'next-turn',
wakeup: true,
source: options?.source ?? { kind: 'user' },
})
}
/** Steer the open turn, falling back to a waking prompt while idle. */
steer(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId {
return this.send(content, {
steer(input: UserMessageData): AgentMessageId {
return this.send(input, {
target: 'next-step',
wakeup: true,
source: options?.source ?? { kind: 'user' },
})
}
/** Append model-facing context without waking the driver. */
inject(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId {
return this.send(content, {
inject(input: UserMessageData): AgentMessageId {
return this.send(input, {
target: 'next-step',
wakeup: false,
source: options?.source ?? { kind: 'plugin', plugin: '' },
})
}

View File

@@ -11,8 +11,7 @@
import type { Context } from 'cordis'
import { assertNever, type ToolCallBlock } from '@deepseek-ai/dsh-llm'
import type { AdditionalContext } from '@deepseek-ai/dsh-agent'
import type { Session } from '@deepseek-ai/dsh-session'
import type { Session, UserMessageData } from '@deepseek-ai/dsh-session'
import { TOOL_ABORTED_BEFORE_DISPATCH, TOOL_REGISTRY_SCHEDULER, type ToolExecutionInput, type ToolExecutionMode, type ToolExecutionResult, type ToolRunContext } from '@deepseek-ai/dsh-tools'
/** One tool call after argument parsing, ready to schedule. */
@@ -58,7 +57,7 @@ export async function executeToolCalls(
step: number,
toolCalls: ToolCallBlock[],
signal: AbortSignal,
acceptContext: (context: AdditionalContext) => void,
acceptContext: (context: UserMessageData) => void,
): Promise<{ concluded: boolean }> {
const agent = ctx.agents.requireInitiator()
const { session } = agent
@@ -120,7 +119,7 @@ async function runGroup(
group: PlannedCall[],
mode: ToolExecutionMode['kind'],
signal: AbortSignal,
acceptContext: (context: AdditionalContext) => void,
acceptContext: (context: UserMessageData) => void,
): Promise<GroupOutcome> {
const { session } = ctx.agents.requireInitiator()
const { maxParallelToolCalls } = ctx.agentLoop.config

View File

@@ -41,7 +41,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
}
function send(agent: Agent, text: string): void {
agent.followup([{ type: 'text', text }])
agent.followup({ content: [{ type: 'text', text }], source: { kind: 'user' } })
}
/** Adapter that holds both drivers at the same awaited continuation. */

View File

@@ -21,7 +21,7 @@ async function harness(adapter: MockAdapter): Promise<Context> {
}
function send(agent: Agent, text: string): void {
agent.followup([{ type: 'text', text }])
agent.followup({ content: [{ type: 'text', text }], source: { kind: 'user' } })
}
describe('Agent', () => {
@@ -32,7 +32,7 @@ describe('Agent', () => {
let flushes = 0
ctx.on('session/flush', () => { flushes += 1 })
agent.inject([{ type: 'text', text: 'context' }], { source: { kind: 'plugin', plugin: 'p' } })
agent.inject({ content: [{ type: 'text', text: 'context' }], source: { kind: 'plugin', plugin: 'p' } })
expect(agent.session.events.map(event => event.type)).toEqual(['user/message'])
expect(agent.status).toBe('idle')
@@ -41,11 +41,11 @@ describe('Agent', () => {
expect(flushes).toBe(0)
})
it('inject() defaults its source to an empty plugin, never user', async () => {
it('inject() preserves an explicitly empty plugin source', async () => {
const ctx = await harness(new MockAdapter([textResponse('ok')]))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.inject([{ type: 'text', text: 'no explicit source' }])
agent.inject({ content: [{ type: 'text', text: 'empty plugin source' }], source: { kind: 'plugin', plugin: '' } })
const injected = agent.session.events.at(-1)
expect(injected?.type === 'user/message' && injected.data.source)
@@ -57,10 +57,7 @@ describe('Agent', () => {
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
expect(() => {
agent.inject(
[{ type: 'text', text: 'x', bad: 1n } as never],
{ source: { kind: 'plugin', plugin: 'p' } },
)
agent.inject({ content: [{ type: 'text', text: 'x', bad: 1n } as never], source: { kind: 'plugin', plugin: 'p' } })
}).toThrow(/non-JSON-serializable/)
expect(agent.session.events).toHaveLength(0)
})
@@ -70,10 +67,7 @@ describe('Agent', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.steer(
[{ type: 'text', text: 'steer idle' }],
{ source: { kind: 'plugin', plugin: 'test' } },
)
agent.steer({ content: [{ type: 'text', text: 'steer idle' }], source: { kind: 'plugin', plugin: 'test' } })
await agent.whenIdle()
expect(agent.session.events.some(event => event.type === 'user/message')).toBe(true)

View File

@@ -33,7 +33,7 @@ async function harness(adapter: MockAdapter) {
}
function send(agent: Agent, text: string) {
agent.followup([{ type: 'text', text }])
agent.followup({ content: [{ type: 'text', text }], source: { kind: 'user' } })
}
/** Resolve on the agent's next idle transition (event-based, not status poll). */
@@ -63,7 +63,7 @@ describe('Agent.cancel()', () => {
ctx.on('agent/cancel-requested', (subject, cause) => {
if (subject !== agent) return
seen.push(`first:${cause.kind}`)
subject.followup([{ type: 'text', text: 'queued by cancel observer' }])
subject.followup({ content: [{ type: 'text', text: 'queued by cancel observer' }], source: { kind: 'user' } })
throw new Error('observer failed')
})
ctx.on('agent/cancel-requested', (subject, cause) => {
@@ -106,11 +106,7 @@ describe('Agent.cancel()', () => {
ctx.on('agent/inbox/discard', (subject, items) => { if (subject === agent) discards.push(items) })
// Queue a turn WITHOUT waking the driver, so it sits in the inbox.
agent.send([{ type: 'text', text: 'preserved' }], {
target: 'next-turn',
wakeup: false,
source: { kind: 'user' },
})
agent.send({ content: [{ type: 'text', text: 'preserved' }], source: { kind: 'user' } }, { target: 'next-turn', wakeup: false })
// keepInbox cancel: no active turn, work preserved, no discard event.
agent.cancel({ kind: 'user' }, { keepInbox: true })
expect(discards).toEqual([])
@@ -128,11 +124,7 @@ describe('Agent.cancel()', () => {
// A quiet item alone must NOT wake the driver: no turn runs and whenIdle
// resolves (the agent is quiescent), leaving the item queued.
agent.send([{ type: 'text', text: 'quiet' }], {
target: 'next-turn',
wakeup: false,
source: { kind: 'user' },
})
agent.send({ content: [{ type: 'text', text: 'quiet' }], source: { kind: 'user' } }, { target: 'next-turn', wakeup: false })
await agent.whenIdle()
expect(agent.status).toBe('idle')
expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(false)
@@ -148,11 +140,7 @@ describe('Agent.cancel()', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'quiet' }], {
target: 'next-turn',
wakeup: false,
source: { kind: 'user' },
})
agent.send({ content: [{ type: 'text', text: 'quiet' }], source: { kind: 'user' } }, { target: 'next-turn', wakeup: false })
const idle = agent.whenIdle()
// Cancel reaches quiescence with no status transition and no waking send;
// whenIdle must still resolve (previously it hung until the next send).
@@ -587,7 +575,7 @@ describe('Agent.cancel()', () => {
expect(agent.status).toBe('running')
// Steer (joins the running turn's steering FIFO), then cancel: the steering
// must be dropped, NOT re-enqueued as a new queued turn.
agent.steer([{ type: 'text', text: 'steer text' }])
agent.steer({ content: [{ type: 'text', text: 'steer text' }], source: { kind: 'user' } })
agent.cancel({ kind: 'user' })
await waitForIdle(ctx, agent)

View File

@@ -98,7 +98,7 @@ describe('config-driven session id', () => {
first = ctx.agents.get(SessionId('config-exact-reload'))
}
expect(first).toBeDefined()
first!.followup([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } })
first!.followup({ content: [{ type: 'text', text: 'remember me' }], source: { kind: 'user' } })
await waitForIdle(ctx, first!)
await firstLoop.dispose()
@@ -110,7 +110,7 @@ describe('config-driven session id', () => {
}
expect(second).toBeDefined()
expect(JSON.stringify(second!.session.deriveMessages())).toContain('remember me')
second!.followup([{ type: 'text', text: 'continue' }], { source: { kind: 'user' } })
second!.followup({ content: [{ type: 'text', text: 'continue' }], source: { kind: 'user' } })
await waitForIdle(ctx, second!)
await ctx.sessions.flush(second!.session)
const loaded = await ctx.sessionPersistence.load(SessionId('config-exact-reload'))
@@ -137,9 +137,7 @@ describe('config-driven session id', () => {
cleanupStarted.resolve(undefined)
await cleanupGate.promise
})
first.inject([{ type: 'text', text: 'persist before replacement' }], {
source: { kind: 'plugin', plugin: 'test' },
})
first.inject({ content: [{ type: 'text', text: 'persist before replacement' }], source: { kind: 'plugin', plugin: 'test' } })
await ctx.sessions.flush(first.session)
expect(JSON.stringify((await ctx.sessionPersistence.inspect(sessionId)).events))
.toContain('persist before replacement')
@@ -183,9 +181,7 @@ describe('config-driven session id', () => {
cleanupStarted.resolve(undefined)
await cleanupGate.promise
})
first.inject([{ type: 'text', text: 'persist before cancellation' }], {
source: { kind: 'plugin', plugin: 'test' },
})
first.inject({ content: [{ type: 'text', text: 'persist before cancellation' }], source: { kind: 'plugin', plugin: 'test' } })
await ctx.sessions.flush(first.session)
expect(JSON.stringify((await ctx.sessionPersistence.inspect(sessionId)).events))
.toContain('persist before cancellation')
@@ -349,7 +345,7 @@ describe('config-driven session id', () => {
expect(a1.id).toBe(a1.session.id)
expect(a1.session.id).toMatch(idPattern)
expect(ctx1.agents.get(SessionId('cfg'))).toBeUndefined()
a1.followup([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
a1.followup({ content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } })
await waitForIdle(ctx1, a1)
await ctx1.fiber.dispose()
@@ -368,7 +364,7 @@ describe('config-driven session id', () => {
expect(a2.id).toBe(a2.session.id)
expect(a2.session.id).toMatch(idPattern)
expect(a2.session.id).not.toBe(a1.session.id)
a2.followup([{ type: 'text', text: 'q2' }], { source: { kind: 'user' } })
a2.followup({ content: [{ type: 'text', text: 'q2' }], source: { kind: 'user' } })
await waitForIdle(ctx2, a2)
await ctx2.fiber.dispose()
})
@@ -389,7 +385,7 @@ describe('config-driven session id', () => {
await ctx1.plugin(SessionPersistenceJsonl, { root })
ctx1.llm.registerAdapter(['mock'], new MockAdapter([textResponse('first')]))
const a1 = (await ctx1.agents.create({ sessionId: SessionId('sticky-1') })).agent
a1.followup([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } })
a1.followup({ content: [{ type: 'text', text: 'remember me' }], source: { kind: 'user' } })
await waitForIdle(ctx1, a1)
await ctx1.fiber.dispose()

View File

@@ -50,7 +50,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
}
function send(agent: Agent, text: string) {
agent.followup([{ type: 'text', text }])
agent.followup({ content: [{ type: 'text', text }], source: { kind: 'user' } })
}
describe('assistant replay provenance', () => {
@@ -85,7 +85,7 @@ describe('abort during tool execution ends the turn', () => {
description: '',
parameters: {},
async execute() {
agent.inject([{ type: 'text', text: 'accepted before abort' }], { source: { kind: 'plugin', plugin: 'test' } })
agent.inject({ content: [{ type: 'text', text: 'accepted before abort' }], source: { kind: 'plugin', plugin: 'test' } })
agent.cancel({ kind: 'user' })
return [{ type: 'text', text: 'done' }]
},
@@ -185,7 +185,7 @@ describe('abort during tool execution ends the turn', () => {
description: '',
parameters: {},
async execute(_args, exec) {
agent.inject([{ type: 'text', text: 'accepted before disposal' }], { source: { kind: 'plugin', plugin: 'test' } })
agent.inject({ 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')
@@ -254,7 +254,7 @@ describe('abort during tool execution ends the turn', () => {
await waitForIdle(ctx, agent)
ctx.on('agent/step', (subject, turn) => {
if (subject === agent && turn === 2) {
agent.inject([{ type: 'text', text: 'new turn context' }], { source: { kind: 'plugin', plugin: 'test' } })
agent.inject({ content: [{ type: 'text', text: 'new turn context' }], source: { kind: 'plugin', plugin: 'test' } })
}
})
send(agent, 'start a text-only turn')
@@ -282,7 +282,7 @@ describe('steering from late extension points is never stranded', () => {
ctx.on('agent/stopping', () => {
if (!steeredOnce) {
steeredOnce = true
agent.steer([{ type: 'text', text: 'one more thing' }])
agent.steer({ content: [{ type: 'text', text: 'one more thing' }], source: { kind: 'user' } })
}
})
@@ -307,7 +307,7 @@ describe('steering from late extension points is never stranded', () => {
ctx.on('session/event', (subject, event) => {
if (subject !== agent.session || event.type !== 'step/end' || steeredOnce) return
steeredOnce = true
agent.steer([{ type: 'text', text: 'goal reminder from step/end' }])
agent.steer({ content: [{ type: 'text', text: 'goal reminder from step/end' }], source: { kind: 'user' } })
})
send(agent, 'go')
@@ -338,7 +338,7 @@ describe('steering from late extension points is never stranded', () => {
if (event.type === 'turn/start') turns.push(event.data.turn)
if (event.type === 'turn/end' && !steeredOnce) {
steeredOnce = true
agent.steer([{ type: 'text', text: 'too late for this turn' }])
agent.steer({ content: [{ type: 'text', text: 'too late for this turn' }], source: { kind: 'user' } })
}
})
@@ -493,7 +493,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => {
description: '',
parameters: {},
async execute() {
agent.steer([{ type: 'text', text: 's' }], { source: { kind: 'plugin', plugin: 'goal' } })
agent.steer({ content: [{ type: 'text', text: 's' }], source: { kind: 'plugin', plugin: 'goal' } })
return []
},
}))
@@ -550,7 +550,7 @@ describe('turn numbering continues across seeded sessions', () => {
const turns: number[] = []
ctx2.on('session/event', (_s, event) => { if (event.type === 'turn/start') turns.push(event.data.turn) })
forked.followup([{ type: 'text', text: 'continue' }])
forked.followup({ 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()

View File

@@ -38,7 +38,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
}
function send(agent: Agent, text: string) {
agent.followup([{ type: 'text', text }])
agent.followup({ content: [{ type: 'text', text }], source: { kind: 'user' } })
}
describe('tool JSON parse', () => {

View File

@@ -42,7 +42,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
}
function send(agent: Agent, text: string) {
agent.followup([{ type: 'text', text }])
agent.followup({ content: [{ type: 'text', text }], source: { kind: 'user' } })
}
function events(agent: Agent): SessionEvent[] {
@@ -151,7 +151,7 @@ describe('agent/prompt-submit', () => {
const reasons: TurnEndReason[] = []
ctx.on('session/event', (_s, event: SessionEvent) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
agent.followup([{ type: 'text', text: 'do something' }])
agent.followup({ content: [{ type: 'text', text: 'do something' }], source: { kind: 'user' } })
await agent.whenIdle()
// the model was never called
@@ -252,7 +252,7 @@ describe('agent/session-start', () => {
const ctx = await harness(adapter)
ctx.on('agent/session-start', (agent) => {
agent.inject([{ type: 'text', text: 'session preamble' }], { source: { kind: 'plugin', plugin: 'test' } })
agent.inject({ content: [{ type: 'text', text: 'session preamble' }], source: { kind: 'plugin', plugin: 'test' } })
})
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
@@ -396,10 +396,7 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se
apply(ctx: Context) {
// 1. SessionStart: seed a standing instruction.
ctx.on('agent/session-start', (agent, source) => {
agent.inject(
[{ type: 'text', text: `policy active (started: ${source})` }],
{ source: { kind: 'plugin', plugin: 'native-guard' } },
)
agent.inject({ content: [{ type: 'text', text: `policy active (started: ${source})` }], source: { kind: 'plugin', plugin: 'native-guard' } })
})
// 2. PromptSubmit: block a forbidden prompt, annotate the rest.
ctx.on('agent/prompt-submit', async (_agent, content, _source, _signal, next): Promise<PromptDecision> => {

View File

@@ -42,7 +42,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
}
function send(agent: Agent, text: string) {
agent.followup([{ type: 'text', text }])
agent.followup({ content: [{ type: 'text', text }], source: { kind: 'user' } })
}
describe('agent loop', () => {
@@ -271,7 +271,7 @@ describe('agent loop', () => {
parameters: {},
async execute() {
// steer while the turn is running (during tool execution)
agent.steer([{ type: 'text', text: 'change of plans' }])
agent.steer({ content: [{ type: 'text', text: 'change of plans' }], source: { kind: 'user' } })
return [{ type: 'text', text: 'tool done' }]
},
}))
@@ -299,8 +299,8 @@ describe('agent loop', () => {
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const idle = waitForIdle(ctx, agent)
agent.steer([{ type: 'text', text: 'first idle steer' }])
agent.steer([{ type: 'text', text: 'second idle steer' }])
agent.steer({ content: [{ type: 'text', text: 'first idle steer' }], source: { kind: 'user' } })
agent.steer({ content: [{ type: 'text', text: 'second idle steer' }], source: { kind: 'user' } })
await idle
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2)
@@ -321,7 +321,7 @@ describe('agent loop', () => {
ctx.on('agent/step', (subject) => {
if (subject !== agent || !fail) return
fail = false
subject.steer([{ type: 'text', text: 'pending steering' }])
subject.steer({ content: [{ type: 'text', text: 'pending steering' }], source: { kind: 'user' } })
throw new Error('step failed')
})
@@ -347,7 +347,7 @@ describe('agent loop', () => {
const ctx = await harness(adapter)
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' } })
agent.inject({ content: [{ type: 'text', text: 'file changed: a.ts' }], source: { kind: 'plugin', plugin: 'watcher' } })
expect(agent.status).toBe('idle')
expect(adapter.requests).toHaveLength(0)
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(0)
@@ -369,9 +369,7 @@ describe('agent loop', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('raw-context'), { provider: 'mock', model: 'mock' })
const text = '<system-reminder>Additional instructions from: pkg/AGENTS.md</system-reminder>'
agent.inject([{ type: 'text', text }], {
source: { kind: 'plugin', plugin: 'workspace-context' },
})
agent.inject({ content: [{ type: 'text', text }], source: { kind: 'plugin', plugin: 'workspace-context' } })
send(agent, 'go')
await waitForIdle(ctx, agent)
@@ -398,11 +396,9 @@ describe('agent loop', () => {
async execute() {
await Promise.resolve()
const first = { type: 'text' as const, text: 'mid-turn notice' }
agent.inject([first], {
source: { kind: 'plugin', plugin: 'x' },
})
agent.inject({ content: [first], source: { kind: 'plugin', plugin: 'x' } })
first.text = 'mutated after inject'
agent.inject([{ type: 'text', text: 'second notice' }], { source: { kind: 'plugin', plugin: 'x' } })
agent.inject({ content: [{ type: 'text', text: 'second notice' }], source: { kind: 'plugin', plugin: 'x' } })
visibleDuringTool = agent.session.events.some(e => e.type === 'user/message' && e.data.source.kind === 'plugin')
return [{ type: 'text', text: 'ok' }]
},
@@ -455,9 +451,7 @@ describe('agent loop', () => {
parameters: {},
async execute() {
expect(() => {
agent.inject([{ type: 'text', text: 'invalid' }], {
source: { kind: 'plugin', plugin: 'test', bigint: 1n } as never,
})
agent.inject({ content: [{ type: 'text', text: 'invalid' }], source: { kind: 'plugin', plugin: 'test', bigint: 1n } as never })
}).toThrow('agent context must be losslessly JSON-serializable')
return [{ type: 'text', text: 'rejected invalid context' }]
},
@@ -482,9 +476,7 @@ describe('agent loop', () => {
ctx.on('session/event', (_session, event) => { if (event.type === 'step/end') steps++ })
ctx.on('agent/stopping', (subject) => {
if (steps < 3) {
subject.steer([{ type: 'text', text: 'continue' }], {
source: { kind: 'plugin', plugin: 'loop-test' },
})
subject.steer({ content: [{ type: 'text', text: 'continue' }], source: { kind: 'plugin', plugin: 'loop-test' } })
}
})
@@ -692,9 +684,7 @@ describe('agent loop', () => {
// (step 2 is a plain stop with no tool calls → stops).
ctx.on('agent/stopping', (subject) => {
if (steps < 2) {
subject.steer([{ type: 'text', text: 'continue after truncation' }], {
source: { kind: 'plugin', plugin: 'max-tokens-test' },
})
subject.steer({ content: [{ type: 'text', text: 'continue after truncation' }], source: { kind: 'plugin', plugin: 'max-tokens-test' } })
}
})
@@ -920,12 +910,9 @@ describe('agent loop', () => {
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const idle = waitForIdle(ctx, agent)
agent.followup([{ type: 'text', text: 'user message' }])
agent.followup({ content: [{ type: 'text', text: 'user message' }], source: { kind: 'user' } })
await Promise.resolve()
agent.followup(
[{ type: 'text', text: 'plugin message' }],
{ source: { kind: 'plugin', plugin: 'test' } },
)
agent.followup({ content: [{ type: 'text', text: 'plugin message' }], source: { kind: 'plugin', plugin: 'test' } })
await idle
const triggers = agent.session.events

View File

@@ -115,7 +115,7 @@ describe('agent loop scheduling properties', () => {
const { seen: trace } = recordStatus(ctx, agent)
const idle = nextIdle(ctx, agent)
// Send all in one synchronous tick: they queue before the loop wakes.
for (const text of texts) agent.followup([{ type: 'text', text }])
for (const text of texts) agent.followup({ content: [{ type: 'text', text }], source: { kind: 'user' } })
await idle
// No message lost: every send appears as a user/message, in order.
@@ -142,7 +142,7 @@ describe('agent loop scheduling properties', () => {
const agent = ctx.agentLoop.create(SessionId('a'), { provider: 'mock', model: 'mock' })
for (const text of texts) {
const idle = nextIdle(ctx, agent)
agent.followup([{ type: 'text', text }])
agent.followup({ content: [{ type: 'text', text }], source: { kind: 'user' } })
await idle
}
// Each send was drained at a separate turn start: N turns, 1..N.
@@ -171,7 +171,7 @@ describe('agent loop scheduling properties', () => {
for (const step of steps) {
const idle = nextIdle(ctx, agent)
lastIdle = idle
agent.followup([{ type: 'text', text: step.text }])
agent.followup({ content: [{ type: 'text', text: step.text }], source: { kind: 'user' } })
if (step.settle) await idle
}
await lastIdle

View File

@@ -73,10 +73,10 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('log-derived request cache hits (
const agent = ctx.agentLoop.create(SessionId('cache-e2e'), { provider: 'deepseek', model: 'deepseek-v4-flash' })
// Turn 1: forces a tool call → at least two steps (two model requests).
agent.followup([{ type: 'text', text: 'Look up the key "deploy-color" with the lookup tool and tell me the value.' }])
agent.followup({ content: [{ type: 'text', text: 'Look up the key "deploy-color" with the lookup tool and tell me the value.' }], source: { kind: 'user' } })
await waitForIdle(ctx, agent)
// Turn 2: a follow-up over the same (longer) prefix.
agent.followup([{ type: 'text', text: 'Thanks. Repeat that value one more time.' }])
agent.followup({ content: [{ type: 'text', text: 'Thanks. Repeat that value one more time.' }], source: { kind: 'user' } })
await waitForIdle(ctx, agent)
const usages = [...agent.session.events]

View File

@@ -40,7 +40,7 @@ describe('agent/request-error', () => {
recoveries += 1
})
agent.followup([{ type: 'text', text: 'go' }])
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
await agent.whenIdle()
expect(recoveries).toBe(0)
@@ -75,7 +75,7 @@ describe('agent/request-error', () => {
subject.retry()
})
agent.followup([{ type: 'text', text: 'go' }])
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
await agent.whenIdle()
expect(seen.map(item => ({
@@ -113,7 +113,7 @@ describe('agent/request-error', () => {
subject.cancel({ kind: 'user' })
})
agent.followup([{ type: 'text', text: 'go' }])
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
await agent.whenIdle()
expect(adapter.requests).toHaveLength(1)
@@ -136,7 +136,7 @@ describe('agent/request-error', () => {
throw new Error('recovery failed')
})
agent.followup([{ type: 'text', text: 'go' }])
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
await agent.whenIdle()
expect(adapter.requests).toHaveLength(1)

View File

@@ -41,7 +41,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
}
function send(agent: Agent, text: string) {
agent.followup([{ type: 'text', text }])
agent.followup({ content: [{ type: 'text', text }], source: { kind: 'user' } })
}
/** Assert `previous` is a strict value-prefix of `current`. */
@@ -170,7 +170,7 @@ describe('request stability across the loop', () => {
ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => {
if (!injected) {
injected = true
agent.inject([{ type: 'text', text: '[late context]' }], { source: { kind: 'plugin', plugin: 'test' } })
agent.inject({ content: [{ type: 'text', text: '[late context]' }], source: { kind: 'plugin', plugin: 'test' } })
}
return next()
})

View File

@@ -146,7 +146,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
const adapter1 = new MockAdapter([textResponse('a')])
const { ctx: ctx1, root } = await persistentHarness(adapter1)
const a1 = (await ctx1.agents.create({ sessionId: SessionId('nocwd-sess') })).agent
a1.followup([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
a1.followup({ content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } })
await waitForIdle(ctx1, a1)
await ctx1.fiber.dispose()
@@ -174,7 +174,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
ctx1.on('agent/session-start', (_agent, source) => void sources1.push(source))
const a1 = (await ctx1.agents.create({ sessionId: SessionId('start-sess') })).agent
expect(sources1).toEqual(['startup'])
a1.followup([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
a1.followup({ content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } })
await waitForIdle(ctx1, a1)
await ctx1.fiber.dispose()
@@ -475,9 +475,9 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
const adapter1 = new MockAdapter([textResponse('answer')])
const { ctx: ctx1, root } = await persistentHarness(adapter1)
const a1 = (await ctx1.agents.create({ sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent
a1.followup([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
a1.followup({ content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } })
await waitForIdle(ctx1, a1)
a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } })
a1.inject({ content: [{ type: 'text', text: 'background task 42 finished' }], source: { kind: 'plugin', plugin: 'tool-bash' } })
await a1.whenIdle()
await ctx1.fiber.dispose()
@@ -503,7 +503,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
const adapter1 = new MockAdapter([textResponse('first answer')])
const { ctx: ctx1, root } = await persistentHarness(adapter1)
const a1 = (await ctx1.agents.create({ sessionId: SessionId('sess-resume'), meta: { cwd: '/w' } })).agent
a1.followup([{ type: 'text', text: 'first question' }], { source: { kind: 'user' } })
a1.followup({ content: [{ type: 'text', text: 'first question' }], source: { kind: 'user' } })
await waitForIdle(ctx1, a1)
const events1 = [...a1.session.events]
const seqs1 = events1.map(e => e.seq)
@@ -530,7 +530,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
expect(a2.session.deriveMessages()).toEqual(replay.deriveMessages())
// …and a new turn continues numbering (turn 2) with contiguous seqs.
a2.followup([{ type: 'text', text: 'second question' }], { source: { kind: 'user' } })
a2.followup({ content: [{ type: 'text', text: 'second question' }], source: { kind: 'user' } })
await waitForIdle(ctx2, a2)
const allSeqs = a2.session.events.map(e => e.seq)
expect(allSeqs).toEqual(allSeqs.map((_, i) => i)) // 0..N contiguous, no duplicates

View File

@@ -203,11 +203,11 @@ describe('agent scope lifecycle', () => {
if (event.type === 'user/message') heard.push('a-sees:user-message')
})
b.followup(text('for b'))
b.followup({ content: text('for b'), source: { kind: 'user' } })
await waitForIdle(ctx, b)
expect(heard).toEqual([]) // nothing of b's leaked into a's scope
a.followup(text('for a'))
a.followup({ content: text('for a'), source: { kind: 'user' } })
await waitForIdle(ctx, a)
expect(heard).toContain('a-sees:a:running')
expect(heard).toContain('a-sees:user-message')
@@ -934,7 +934,7 @@ describe('agent scope lifecycle', () => {
if (event.type === 'turn/start') { off(); resolve() }
})
})
agent.followup(text('work'))
agent.followup({ content: text('work'), source: { kind: 'user' } })
await turnOpen
await owner.dispose()
expect(order).toEqual([

View File

@@ -105,7 +105,7 @@ describe('tool-call scheduler: grouping and barriers', () => {
ctx.tools.register(gated.tool)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.followup([{ type: 'text', text: 'go' }])
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
await until(() => gated.started.length === 3)
expect(gated.started).toEqual(['1', '2', '3'])
gated.release('1'); gated.release('2'); gated.release('3')
@@ -133,7 +133,7 @@ describe('tool-call scheduler: grouping and barriers', () => {
async execute(args) { order.push(`w-${args.id}`); return [{ type: 'text', text: 'w' }] },
}))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.followup([{ type: 'text', text: 'go' }])
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
await waitForIdle(ctx, agent)
expect(order).toEqual(['r-start-A1', 'r-end-A1', 'w-A2', 'r-start-A3', 'r-end-A3'])
@@ -169,7 +169,7 @@ describe('tool-call scheduler: grouping and barriers', () => {
}))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.followup([{ type: 'text', text: 'go' }])
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
await until(() => replacement.started.length === 1)
await new Promise(r => setTimeout(r, 5))
expect(replacement.started).toEqual(['1'])
@@ -200,7 +200,7 @@ describe('tool-call scheduler: grouping and barriers', () => {
})
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.followup([{ type: 'text', text: 'go' }])
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
await until(() => initial.started.length === 2)
initial.release('1')
await until(() => events(agent).some(event =>
@@ -226,7 +226,7 @@ describe('tool-call scheduler: model-order results despite out-of-order settleme
ctx.tools.register(gated.tool)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.followup([{ type: 'text', text: 'go' }])
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
await until(() => gated.started.length === 2)
gated.release('2')
await new Promise(r => setTimeout(r, 5))
@@ -248,7 +248,7 @@ describe('tool-call scheduler: model-order results despite out-of-order settleme
const gated = gatedParallelTool('p')
ctx.tools.register(gated.tool)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.followup([{ type: 'text', text: 'go' }])
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
await until(() => gated.started.length === 2)
gated.release('2'); gated.release('1')
await waitForIdle(ctx, agent)
@@ -295,7 +295,7 @@ describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () =>
ctx.tools.register(gated.tool)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.followup([{ type: 'text', text: 'go' }])
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
await until(() => gated.started.length === 2)
await new Promise(r => setTimeout(r, 5))
expect(gated.started).toEqual(['1', '2'])
@@ -324,7 +324,7 @@ describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () =>
const gated = gatedParallelTool('p')
ctx.tools.register(gated.tool)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.followup([{ type: 'text', text: 'go' }])
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
await until(() => gated.started.length === 1)
await new Promise(r => setTimeout(r, 5))
expect(gated.started).toEqual(['1'])
@@ -350,7 +350,7 @@ describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () =>
const gated = gatedParallelTool('p')
ctx.tools.register(gated.tool)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.followup([{ type: 'text', text: 'go' }])
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
await until(() => gated.started.length === 1)
await new Promise(r => setTimeout(r, 5))
expect(gated.started).toEqual(['1'])
@@ -377,7 +377,7 @@ describe('tool-call scheduler: ordered middleware and additional contexts', () =
ctx.on('tools/post-execute', async (exec, _result, next): Promise<PostToolDecision> => { post.push(String(exec.callId)); return next() })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.followup([{ type: 'text', text: 'go' }])
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
await until(() => gated.started.length === 3)
gated.release('3'); gated.release('2'); gated.release('1')
await waitForIdle(ctx, agent)
@@ -398,7 +398,7 @@ describe('tool-call scheduler: ordered middleware and additional contexts', () =
({ kind: 'accept', additionalContexts: [{ content: [{ type: 'text', text: `ctx-${exec.callId}` }], source: { kind: 'plugin', plugin: 'p' } }] }))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.followup([{ type: 'text', text: 'go' }])
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
await until(() => gated.started.length === 2)
gated.release('2'); gated.release('1')
await waitForIdle(ctx, agent)
@@ -436,7 +436,7 @@ describe('tool-call scheduler: ordered middleware and additional contexts', () =
})
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.followup([{ type: 'text', text: 'go' }])
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
await until(() => gated.started.length === 1)
gated.release('1')
await waitForIdle(ctx, agent)
@@ -466,7 +466,7 @@ describe('tool-call scheduler: abort handling', () => {
}
})
agent.followup([{ type: 'text', text: 'go' }])
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
await waitForIdle(ctx, agent)
expect(gated.started).toEqual([])
@@ -498,7 +498,7 @@ describe('tool-call scheduler: abort handling', () => {
return next()
})
agent.followup([{ type: 'text', text: 'go' }])
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
await waitForIdle(ctx, agent)
expect(gated.started).toEqual([])
@@ -528,7 +528,7 @@ describe('tool-call scheduler: abort handling', () => {
}))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.followup([{ type: 'text', text: 'go' }])
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
await until(() => gated.started.length === 2)
agent.cancel({ kind: 'user' })
gated.release('1')
@@ -575,7 +575,7 @@ describe('tool-call scheduler: abort handling', () => {
}))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.followup([{ type: 'text', text: 'go' }])
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
await until(() => gated.started.length === 2)
agent.cancel({ kind: 'user' })
gated.release('1')

View File

@@ -58,7 +58,7 @@ async function runTurn(registrationOrder: string[], toolOrder?: SystemPromptConf
const ctx = await harness(adapter, toolOrder)
for (const name of registrationOrder) registerNamed(ctx, name)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.followup([{ type: 'text', text: 'go' }])
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
await waitForIdle(ctx, agent)
return { ctx, agent, adapter }
}
@@ -102,7 +102,7 @@ describe('loop-level canonical tool order', () => {
if (error instanceof Error) errors.push(error)
})
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.followup([{ type: 'text', text: 'go' }])
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(0)
expect(errors.map(e => e.message)).toEqual(['toolOrder lists unregistered tool "ghost"; known tools: alpha'])