Merge origin/master into worktree/llm-reasoning-effort
This commit is contained in:
@@ -50,9 +50,9 @@ Configured agents start automatically. A model call requires both `provider` and
|
||||
|
||||
### Internal concrete driver
|
||||
|
||||
The concrete `Agent` class, its `Inbox`, `runLoop`, and instance-bound publication/start controls are package-internal. The package root exports only the plugin/service/config contract, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than naming, constructing, or starting driver internals. One prepared session can be claimed by only one concrete driver, and everything observable happens through session events and the `agent/*` event taxonomy.
|
||||
The concrete `ReactLoopAgent` adapter, its `Inbox`, `runLoop`, and instance-bound publication/start controls are package-internal. The package root exports only the plugin/service/config contract, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than naming, constructing, or starting driver internals. One prepared session can be claimed by only one concrete driver, and everything observable happens through session events and the `agent/*` event taxonomy.
|
||||
|
||||
Each concrete `send()` materializes content, resolved source, and attached contexts once as a detached, deeply frozen lossless-JSON FIFO item. If claimed, it is the sole ordinary message in its turn; its contexts are the prompt waterfall's default additional contexts and therefore materialize only after admission. Absent or `separate` placement appends an independent `context/message`; `prompt-prefix` placement bakes the context, the stable `## My request:` delimiter, and effective request into one `user/message`, whose model-hidden envelope retains display content and context descriptors. The waterfall's returned allow is authoritative, so a listener wrapping `next()` preserves downstream `content` and `additionalContexts` unless it intentionally replaces them. A successor waits for the preceding ordinary turn's checkpoint to settle, while cancellation, disposal, a prompt block, or a pre-start failure may drop its contexts with the message. Running `steer()` enters the same record shape in the steering FIFO without dispatching `agent/prompt-submit`; its next checkpoint applies the same separate-or-prefix placement to `steering/message`, while policy can still stop before another step. Steering left after turn close and its checkpoint becomes later queued input with contexts intact unless terminal turn policy, cancellation, or disposal discards it. Open-turn `inject()` uses the same accepted-value boundary but defers in a FIFO while the current step executes assistant tool calls; successful batches place it after all results, and interrupted batches drain it before turn close. Malformed data throws before enqueue or append.
|
||||
`ReactLoopAgent.send()` implements the public fully resolved acceptance path. The `followup()`/`queue()`/`steer()`/`inject()` helpers resolve every optional field before delegating to it; direct callers provide mandatory content, source, contexts, metadata, target, and wakeup facts through `ResolvedAgentInput`. `followup()` and `queue()` join the ordinary FIFO, respectively waking or leaving an idle driver parked. If claimed, an ordinary item is the sole message in its turn, and its contexts are the prompt waterfall's default additional contexts that materialize only after admission. Absent or `separate` placement appends an independent injected `user/message`; `prompt-prefix` placement bakes the context, the stable `## My request:` delimiter, and effective request into one `user/message`, whose model-hidden envelope retains display content and context descriptors. The waterfall's returned allow is authoritative, so a listener wrapping `next()` preserves downstream `content` and `additionalContexts` unless it intentionally replaces them. A successor waits for the preceding ordinary turn's checkpoint to settle, while cancellation, disposal, a prompt block, or a pre-start failure may drop its contexts with the message. Running `steer()` or equivalent `send()` routing enters the same record shape in the steering FIFO without dispatching `agent/prompt-submit`; its next checkpoint applies the same separate-or-prefix placement to `steering/message`, while policy can still stop before another step. Steering left after turn close and its checkpoint becomes later queued input with contexts intact unless terminal turn policy, cancellation, or disposal discards it. `inject()` and non-waking next-step acceptance require an empty context tuple, bypass both FIFOs, and append durable context directly: an open-turn injection defers in a FIFO while the current step executes assistant tool calls (successful batches place it after all results, interrupted batches drain it before turn close), and an idle injection wraps a one-shot `injection` turn. Every FIFO enqueue publishes `agent/inbox/enqueue`; the driver's claims publish `agent/inbox/dequeue`, and `cancel()` without `keepInbox` publishes `agent/inbox/discard`. Malformed data throws before enqueue or append.
|
||||
|
||||
### Loop lifecycle (`loop.ts`)
|
||||
|
||||
|
||||
@@ -6,15 +6,25 @@
|
||||
* @module dsh-agent-loop/agent
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { Context } from 'cordis'
|
||||
import { agentEvents } from '@deepseek-ai/dsh-agent'
|
||||
import type { AgentCancelCause, AgentOptions, AgentStatus, HookContext, InjectOptions, SendOptions } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { agentEvents, AgentMessageId } from '@deepseek-ai/dsh-agent'
|
||||
import type {
|
||||
Agent,
|
||||
AgentCancelCause,
|
||||
AgentOptions,
|
||||
AgentStatus,
|
||||
CancelOptions,
|
||||
HookContext,
|
||||
InjectOptions,
|
||||
ResolvedAgentInput,
|
||||
SendOptions,
|
||||
} from '@deepseek-ai/dsh-agent'
|
||||
import { deepFreeze, errorChain } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import { snapshotJsonValue, type Session, type SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { DISPOSED_INTERRUPT_REASON, TurnCancellation } from './cancellation.ts'
|
||||
import { Inbox, type InboxMessage } from './inbox.ts'
|
||||
import { Inbox, agentMessage, type InboxMessage } from './inbox.ts'
|
||||
import { isTurnOpen, lastTurnNumber, runLoop } from './loop.ts'
|
||||
|
||||
/** Sessions already claimed by a concrete driver construction. */
|
||||
@@ -190,19 +200,17 @@ export class ReactLoopAgent implements Agent {
|
||||
for (const resolve of waiters) resolve()
|
||||
}
|
||||
|
||||
private resolveSource(options?: SendOptions): MessageSource {
|
||||
return options?.source ?? { kind: 'user' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept one public message payload as a detached record. Lossless-JSON
|
||||
* materialization reads every nested field once; deep freeze prevents later
|
||||
* caller mutation before an inbox or deferred-injection queue drains it.
|
||||
*/
|
||||
private acceptMessage(content: ContentBlock[], options?: SendOptions): InboxMessage {
|
||||
const source = this.resolveSource(options)
|
||||
const contexts = options?.contexts ?? []
|
||||
const accepted = snapshotJsonValue({ content, source, contexts })
|
||||
private snapshotMessage(id: AgentMessageId, input: ResolvedAgentInput): InboxMessage {
|
||||
const { content, source, contexts, wakeup, meta } = input
|
||||
const accepted = snapshotJsonValue({
|
||||
id, content, source, contexts, wakeup,
|
||||
...meta !== undefined ? { meta } : {},
|
||||
})
|
||||
if (accepted === undefined) {
|
||||
throw new TypeError('agent message content, source, and contexts must be losslessly JSON-serializable')
|
||||
}
|
||||
@@ -223,33 +231,81 @@ export class ReactLoopAgent implements Agent {
|
||||
if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`)
|
||||
}
|
||||
|
||||
send(content: ContentBlock[], options?: SendOptions): void {
|
||||
/** Accept one fully resolved agent input through the concrete driver's routing matrix. */
|
||||
send(input: ResolvedAgentInput): AgentMessageId {
|
||||
this.assertNotDisposed()
|
||||
const accepted = this.acceptMessage(content, options)
|
||||
this.#inbox.enqueue(accepted)
|
||||
const info = { source: accepted.source, contexts: accepted.contexts, steering: false } as const
|
||||
agentEvents(this.loopCtx, this).emit('agent/queued', accepted.content, info)
|
||||
const id = AgentMessageId(randomUUID())
|
||||
const { target, wakeup } = input
|
||||
// next-step/no-wakeup is injection: durable context without running the model.
|
||||
if (target === 'next-step' && !wakeup) { this.injectContext(input); return id }
|
||||
// next-step/wakeup is steering into the running turn; idle falls back to a
|
||||
// waking ordinary turn (there is no active turn to attach to).
|
||||
const steering = target === 'next-step' && this._status === 'running'
|
||||
const accepted = this.snapshotMessage(id, input)
|
||||
if (steering) {
|
||||
this.#inbox.steer(accepted)
|
||||
} else {
|
||||
this.#inbox.enqueue(accepted, wakeup)
|
||||
}
|
||||
agentEvents(this.loopCtx, this).emit('agent/inbox/enqueue', agentMessage(accepted, steering))
|
||||
return id
|
||||
}
|
||||
|
||||
steer(content: ContentBlock[], options?: SendOptions): void {
|
||||
this.assertNotDisposed()
|
||||
if (this._status !== 'running') { this.send(content, options); return }
|
||||
const accepted = this.acceptMessage(content, options)
|
||||
this.#inbox.steer(accepted)
|
||||
const info = { source: accepted.source, contexts: accepted.contexts, steering: true } as const
|
||||
agentEvents(this.loopCtx, this).emit('agent/queued', accepted.content, info)
|
||||
followup(content: ContentBlock[], options?: SendOptions): AgentMessageId {
|
||||
return this.send({
|
||||
content,
|
||||
target: 'next-turn',
|
||||
wakeup: true,
|
||||
source: options?.source ?? { kind: 'user' },
|
||||
contexts: options?.contexts ?? [],
|
||||
meta: options?.meta,
|
||||
})
|
||||
}
|
||||
|
||||
inject(content: ContentBlock[], options?: InjectOptions): void {
|
||||
this.assertNotDisposed()
|
||||
const source = this.resolveSource(options)
|
||||
const context = {
|
||||
queue(content: ContentBlock[], options?: SendOptions): AgentMessageId {
|
||||
return this.send({
|
||||
content,
|
||||
target: 'next-turn',
|
||||
wakeup: false,
|
||||
source: options?.source ?? { kind: 'user' },
|
||||
contexts: options?.contexts ?? [],
|
||||
meta: options?.meta,
|
||||
})
|
||||
}
|
||||
|
||||
steer(content: ContentBlock[], options?: SendOptions): AgentMessageId {
|
||||
return this.send({
|
||||
content,
|
||||
target: 'next-step',
|
||||
wakeup: true,
|
||||
source: options?.source ?? { kind: 'user' },
|
||||
contexts: options?.contexts ?? [],
|
||||
meta: options?.meta,
|
||||
})
|
||||
}
|
||||
|
||||
inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId {
|
||||
return this.send({
|
||||
content,
|
||||
target: 'next-step',
|
||||
wakeup: false,
|
||||
source: options?.source ?? { kind: 'plugin', plugin: '' },
|
||||
contexts: [],
|
||||
meta: options?.meta,
|
||||
})
|
||||
}
|
||||
|
||||
/** The `next-step`/no-wakeup injection path: durable context, no FIFO, no run. */
|
||||
private injectContext(input: Extract<ResolvedAgentInput, { target: 'next-step'; wakeup: false }>): void {
|
||||
const { content, source, meta } = input
|
||||
// Detach and validate the payload before any append, so malformed input
|
||||
// cannot open a one-shot turn or otherwise mutate the session.
|
||||
const accepted = this.acceptContext({
|
||||
content,
|
||||
source,
|
||||
...options?.meta !== undefined ? { meta: options.meta } : {},
|
||||
}
|
||||
...meta !== undefined ? { meta } : {},
|
||||
})
|
||||
if (isTurnOpen(this.session)) {
|
||||
const accepted = this.acceptContext(context)
|
||||
// Provider protocols require every assistant tool-call batch to be
|
||||
// followed only by its tool results. Historical interrupted batches do
|
||||
// not own new context; only the currently executing batch may defer it.
|
||||
@@ -257,27 +313,29 @@ export class ReactLoopAgent implements Agent {
|
||||
this.deferredInjections.push(accepted)
|
||||
return
|
||||
}
|
||||
this.session.append('context/message', accepted, { surfaceOp: 'append' })
|
||||
this.session.append('user/message', accepted, { surfaceOp: 'append' })
|
||||
return
|
||||
}
|
||||
// No turn open: wrap the injection in a one-shot turn so every event stays
|
||||
// turn-enclosed (the durability/replay boundary is the turn).
|
||||
// turn-enclosed (the durability/replay boundary is the turn). The payload is
|
||||
// validated above, but `Session.append` can still reject a turn/start
|
||||
// pre-commit (append re-entrancy from a session/event listener, or an
|
||||
// internal-dispatch veto), so the finally owes a turn/end only when
|
||||
// turn/start actually committed.
|
||||
const turn = lastTurnNumber(this.session) + 1
|
||||
// Once turn/start enters the log, a turn/end is owed even if the message
|
||||
// append fails acceptance or pre-commit validation. The finally re-checks
|
||||
// the log and closes only a turn that actually opened; post-commit observers
|
||||
// are contained by Session and cannot create a false append failure.
|
||||
try {
|
||||
this.session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
|
||||
this.session.append('context/message', context, { surfaceOp: 'append' })
|
||||
this.session.append('user/message', accepted, { surfaceOp: 'append' })
|
||||
} finally {
|
||||
// Close the turn if turn/start made it into the log. A pre-commit veto
|
||||
// must escape rather than being mistaken for a committed turn/end.
|
||||
if (isTurnOpen(this.session)) {
|
||||
this.session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
}
|
||||
// Decide the durability checkpoint from the log: an accepted one-shot
|
||||
// turn must be flushed even when its message append was the failing step.
|
||||
// Checkpoint only an accepted one-shot turn: a turn/start rejected
|
||||
// pre-commit recorded nothing, so it owes no flush (and a spurious flush
|
||||
// would emit a phantom-turn agent/error). The payload is validated up
|
||||
// front, so a committed turn/start is always followed by its user/message.
|
||||
const turnRecorded = this.session.events.some(e => e.type === 'turn/start' && e.data.turn === turn)
|
||||
// Keep inject() synchronous: report checkpoint failures live instead of
|
||||
// rejecting the caller, and track the task so disposal still drains it.
|
||||
@@ -301,7 +359,7 @@ export class ReactLoopAgent implements Agent {
|
||||
private drainDeferredInjections(): void {
|
||||
const pending = this.deferredInjections.splice(0)
|
||||
for (const accepted of pending) {
|
||||
this.session.append('context/message', accepted, { surfaceOp: 'append' })
|
||||
this.session.append('user/message', accepted, { surfaceOp: 'append' })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -325,10 +383,14 @@ export class ReactLoopAgent implements Agent {
|
||||
}
|
||||
}
|
||||
|
||||
cancel(cause?: AgentCancelCause): void {
|
||||
cancel(cause?: AgentCancelCause, options?: CancelOptions): void {
|
||||
const resolvedCause = cause ?? { kind: 'user' }
|
||||
const keepInbox = options?.keepInbox ?? false
|
||||
const cancellation = this.turnCancellation
|
||||
const preRun = cancellation === undefined && (this.#inbox.hasQueued || this.#inbox.hasSteering)
|
||||
// keepInbox preserves pending work, so un-started items must not arm the
|
||||
// pre-run cancel path that would otherwise drop the next queued turn.
|
||||
const preRun = !keepInbox && cancellation === undefined
|
||||
&& (this.#inbox.hasQueued || this.#inbox.hasSteering)
|
||||
if (cancellation !== undefined || preRun) {
|
||||
if (preRun) this.preRunCancelled = true
|
||||
// Coordination consumers must update their own state before this call
|
||||
@@ -336,9 +398,24 @@ export class ReactLoopAgent implements Agent {
|
||||
// contained by the fused dispatcher and cannot veto cancellation.
|
||||
agentEvents(this.loopCtx, this).emit('agent/cancel-requested', resolvedCause)
|
||||
}
|
||||
// Clear work already present before abort observers run. A replacement
|
||||
// synchronously enqueued by an observer belongs to the next turn.
|
||||
this.#inbox.clear()
|
||||
if (!keepInbox) {
|
||||
// Snapshot before clearing so the discard notification carries the exact
|
||||
// dropped items; a replacement synchronously enqueued by an
|
||||
// `agent/cancel-requested` observer belongs to the next turn, not here.
|
||||
const discarded = this.#inbox.pending()
|
||||
// Clear work already present before abort observers run.
|
||||
this.#inbox.clear()
|
||||
if (discarded.length > 0) {
|
||||
const items = discarded.map(({ message, steering }) => agentMessage(message, steering))
|
||||
agentEvents(this.loopCtx, this).emit('agent/inbox/discard', items)
|
||||
}
|
||||
// No idle-waiter settle here: a `whenIdle` waiter exists only while the
|
||||
// agent is `running` or a waking item is queued, and neither is left
|
||||
// quiescent by clearing the inbox — a lone quiet item takes `whenIdle`'s
|
||||
// fast path (no waiter), a waking item keeps the woken driver running,
|
||||
// and a running agent owns its own idle transition (including the
|
||||
// post-turn flush window).
|
||||
}
|
||||
cancellation?.request(resolvedCause)
|
||||
}
|
||||
|
||||
@@ -349,7 +426,9 @@ export class ReactLoopAgent implements Agent {
|
||||
*/
|
||||
whenIdle(): Promise<void> {
|
||||
if (this._status === 'disposed') return this.done
|
||||
if (this._status !== 'running' && !this.#inbox.hasQueued) return Promise.resolve()
|
||||
// A lone quiet (`wakeup:false`) queued item leaves the agent quiescent — the
|
||||
// driver stays parked — so gate on hasWakingQueued, not hasQueued.
|
||||
if (this._status !== 'running' && !this.#inbox.hasWakingQueued) return Promise.resolve()
|
||||
// Agent-owned waiters survive concurrent fiber disposal.
|
||||
return new Promise<void>((resolve) => {
|
||||
this.idleWaiters.push(() => {
|
||||
@@ -407,8 +486,21 @@ export class ReactLoopAgent implements Agent {
|
||||
*/
|
||||
private [stopDriver](): Promise<void> | void {
|
||||
if (this._status !== 'disposed') {
|
||||
// Snapshot any still-pending inbox items, then CLEAR and mark disposed
|
||||
// BEFORE emitting the discard — mirroring cancel()'s snapshot→clear→emit
|
||||
// order so a re-entrant followup()/cancel() from a discard listener throws
|
||||
// `disposed` (or finds an empty inbox) instead of leaking or double-
|
||||
// discarding an id. `followup()` emits enqueue unconditionally, so the discard
|
||||
// is unconditional too (even on an unpublished rollback) to keep every
|
||||
// enqueued id matched.
|
||||
const discarded = this.#inbox.pending()
|
||||
this.#inbox.clear()
|
||||
this._status = 'disposed'
|
||||
this.resolveDisposed()
|
||||
if (discarded.length > 0) {
|
||||
const items = discarded.map(({ message, steering }) => agentMessage(message, steering))
|
||||
agentEvents(this.loopCtx, this).emit('agent/inbox/discard', items)
|
||||
}
|
||||
// Release whenIdle waiters BEFORE the (guarded) event emit — they are
|
||||
// internal state that must settle even if a listener throws below. Each
|
||||
// waiter chains `done`, so it resolves only once the loop actually exits.
|
||||
|
||||
@@ -1,54 +1,91 @@
|
||||
/**
|
||||
* Per-agent message inbox: queued and steering FIFOs. Purely an in-memory
|
||||
* mechanism of the loop driver — the public surface is `Agent.send()` and
|
||||
* `Agent.steer()`.
|
||||
* mechanism of the loop driver — callers use `Agent`'s intent-named delivery
|
||||
* methods instead.
|
||||
*
|
||||
* @module dsh-agent-loop/inbox
|
||||
*/
|
||||
|
||||
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import type { HookContext } from '@deepseek-ai/dsh-agent'
|
||||
import type { JsonValue } from '@deepseek-ai/dsh-session'
|
||||
import type { AgentMessage, AgentMessageId, HookContext } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
/** One message waiting in an agent's inbox. */
|
||||
/** One message waiting in an agent's inbox; `id` is the value its accepting delivery method returned. */
|
||||
export interface InboxMessage {
|
||||
id: AgentMessageId
|
||||
content: ContentBlock[]
|
||||
source: MessageSource
|
||||
contexts: HookContext[]
|
||||
/** Whether the item is marked to wake the driver or force a continuation. */
|
||||
wakeup: boolean
|
||||
/** Opaque durable JSON state retained on the durable message but hidden from the model. */
|
||||
meta?: JsonValue
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `agent/inbox/*` event payload for one inbox item.
|
||||
* @param message - the accepted inbox record.
|
||||
* @param steering - whether the item is in the steering FIFO (`next-step`).
|
||||
* @returns the live-event message for enqueue/dequeue/discard.
|
||||
*/
|
||||
export function agentMessage(message: InboxMessage, steering: boolean): AgentMessage {
|
||||
// Frozen: the fused emitter passes this exact object to every listener in
|
||||
// turn, so one listener must not be able to mutate a field (`id`, `steering`,
|
||||
// `content`, …) a later listener then observes. `message` is already a frozen
|
||||
// inbox record, so its nested fields need no re-clone.
|
||||
return Object.freeze({
|
||||
id: message.id, content: message.content, source: message.source,
|
||||
contexts: message.contexts, steering, wakeup: message.wakeup,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-agent inbox: a queued FIFO (dequeued once per turn start) and a steering FIFO
|
||||
* (drained between steps of a running turn). Purely an in-memory mechanism of
|
||||
* the loop — the public surface is `Agent.send()` / `Agent.steer()`.
|
||||
* the loop — the public surface is `Agent`'s intent-named delivery methods.
|
||||
*/
|
||||
export class Inbox {
|
||||
private queuedMessages: InboxMessage[] = []
|
||||
private steeringMessages: InboxMessage[] = []
|
||||
private wakeup: (() => void) | undefined
|
||||
|
||||
/** True while queued messages are pending — read by the idle wait's fast path and the loop's turn-start checks. */
|
||||
/** True while any queued message is pending — read by cancellation's discard snapshot and the turn-start dequeue guard. */
|
||||
get hasQueued(): boolean {
|
||||
return this.queuedMessages.length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* True while a queued message wants to wake the driver — the "should the loop
|
||||
* run" signal read by the idle wait's fast path, the loop's idle-publish
|
||||
* check, and `whenIdle`. A `wakeup:false` (quiet) item alone leaves this
|
||||
* false, so the driver stays parked until a waking follow-up (or a waking item
|
||||
* ahead of it in FIFO order) drives the loop; the quiet item then rides along.
|
||||
*/
|
||||
get hasWakingQueued(): boolean {
|
||||
return this.queuedMessages.some(message => message.wakeup)
|
||||
}
|
||||
|
||||
/** True while steering messages are pending — read by cancellation and the loop's stop-override check. */
|
||||
get hasSteering(): boolean {
|
||||
return this.steeringMessages.length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a message to the queued FIFO and wake a parked {@link waitForQueued}.
|
||||
* Add a message to the queued FIFO, waking a parked {@link waitForQueued}
|
||||
* unless the item opted out. A non-waking item still runs once any woken
|
||||
* item or later wakeup drives the parked loop.
|
||||
* @param message - the message to queue for the next turn start.
|
||||
* @param wake - whether to wake a parked idle wait (default true).
|
||||
*/
|
||||
enqueue(message: InboxMessage): void {
|
||||
enqueue(message: InboxMessage, wake = true): void {
|
||||
this.queuedMessages.push(message)
|
||||
this.wakeup?.()
|
||||
if (wake) this.wakeup?.()
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a message to the steering FIFO. Deliberately no wakeup: steering is
|
||||
* drained between steps of a running turn, never by the idle wait —
|
||||
* `Agent.steer()` on an idle agent falls back to `send()` instead.
|
||||
* `Agent.steer()` on an idle agent falls back to a waking ordinary turn instead.
|
||||
* @param message - the message to inject between steps of the running turn.
|
||||
*/
|
||||
steer(message: InboxMessage): void {
|
||||
@@ -71,6 +108,18 @@ export class Inbox {
|
||||
return this.steeringMessages.splice(0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot the pending items (queued then steering, FIFO order) without
|
||||
* removing them — the discard notification's payload source.
|
||||
* @returns the pending items paired with whether each is steering.
|
||||
*/
|
||||
pending(): { message: InboxMessage; steering: boolean }[] {
|
||||
return [
|
||||
...this.queuedMessages.map(message => ({ message, steering: false })),
|
||||
...this.steeringMessages.map(message => ({ message, steering: true })),
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Discard all pending messages (queued + steering) without delivering them —
|
||||
* used by `cancel()`, which drops un-started work rather than draining it into
|
||||
@@ -88,7 +137,7 @@ export class Inbox {
|
||||
* loop can exit).
|
||||
*/
|
||||
waitForQueued(cancel: Promise<void>): Promise<void> {
|
||||
if (this.hasQueued) return Promise.resolve()
|
||||
if (this.hasWakingQueued) return Promise.resolve()
|
||||
const { promise, resolve } = Promise.withResolvers<void>()
|
||||
this.wakeup = resolve
|
||||
void cancel.then(resolve)
|
||||
|
||||
@@ -5,11 +5,12 @@
|
||||
* @module dsh-agent-loop/loop
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { Context } from 'cordis'
|
||||
import type { ContentBlock, FinishReason, GenerateOptions, LlmCallConfig, LlmFailure, Message } from '@deepseek-ai/dsh-llm'
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
import { BlockAssembler, HarnessError, LlmError, assertNever, deepFreeze, errorChain, llmFailureOf, markAgentLoopRequest } from '@deepseek-ai/dsh-llm'
|
||||
import { agentEvents, agentInterruptReasonOf, assembleContextFor } from '@deepseek-ai/dsh-agent'
|
||||
import { agentEvents, agentInterruptReasonOf, assembleContextFor, AgentMessageId } from '@deepseek-ai/dsh-agent'
|
||||
import type { AgentEventDispatch, ContinuationDecision, HookContext, PromptDecision, RequestError, RequestErrorDecision } from '@deepseek-ai/dsh-agent'
|
||||
import { canonicalHeader } from '@deepseek-ai/dsh-session'
|
||||
import type { PromptMessageData, Session, TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session'
|
||||
@@ -19,7 +20,7 @@ import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
import { executeToolCalls } from './tool-calls.ts'
|
||||
import type { Inbox } from './inbox.ts'
|
||||
import { agentMessage, type Inbox, type InboxMessage } from './inbox.ts'
|
||||
import type { TurnCancellation } from './cancellation.ts'
|
||||
|
||||
/** Normalize thrown values while preserving an existing error code. */
|
||||
@@ -201,9 +202,11 @@ export async function runLoop(ctx: Context, handle: LoopHandle): Promise<void> {
|
||||
while (!handle.isDisposed()) {
|
||||
// An idle listener can enqueue and cancel replacement work before the next
|
||||
// wait is installed. Consume that empty marker before parking the driver.
|
||||
// A quiet (`wakeup:false`) item alone must not un-park the loop, so gate on
|
||||
// hasWakingQueued, not hasQueued.
|
||||
if (handle.isPreRunCancelled()) {
|
||||
handle.clearPreRunCancel()
|
||||
if (!handle.inbox.hasQueued) {
|
||||
if (!handle.inbox.hasWakingQueued) {
|
||||
handle.settleIdle()
|
||||
handle.setStatus('idle')
|
||||
continue
|
||||
@@ -217,7 +220,7 @@ export async function runLoop(ctx: Context, handle: LoopHandle): Promise<void> {
|
||||
// a replacement prompt still runs before the eventual idle transition.
|
||||
if (handle.isPreRunCancelled()) {
|
||||
handle.clearPreRunCancel()
|
||||
if (!handle.inbox.hasQueued) {
|
||||
if (!handle.inbox.hasWakingQueued) {
|
||||
// Settle before publishing idle: the already-idle path has no status
|
||||
// transition, while an idle listener can register waiters for new work.
|
||||
handle.settleIdle()
|
||||
@@ -234,10 +237,11 @@ export async function runLoop(ctx: Context, handle: LoopHandle): Promise<void> {
|
||||
}
|
||||
|
||||
// A synchronous `running` listener can cancel before `runTurn`; balance the
|
||||
// status only when no replacement prompt was queued by that listener.
|
||||
// status only when no waking replacement prompt was queued by that listener
|
||||
// (a lone quiet item parks at idle rather than driving a turn).
|
||||
if (cancellation.signal.aborted) {
|
||||
handle.clearTurnCancellation(cancellation)
|
||||
if (!handle.inbox.hasQueued) {
|
||||
if (!handle.inbox.hasWakingQueued) {
|
||||
handle.setStatus('idle')
|
||||
continue
|
||||
}
|
||||
@@ -260,12 +264,22 @@ export async function runLoop(ctx: Context, handle: LoopHandle): Promise<void> {
|
||||
handle.clearTurnCancellation(cancellation)
|
||||
}
|
||||
|
||||
// Late steering becomes queued input unless terminal policy stopped the turn.
|
||||
for (const message of handle.inbox.drainSteering()) {
|
||||
if (!terminalStopped) handle.inbox.enqueue(message)
|
||||
// Late steering (arriving after runTurn returns, e.g. during the post-turn
|
||||
// flush) becomes queued input — unless terminal policy stopped the turn, in
|
||||
// which case it is dropped and must publish a discard so its enqueue is
|
||||
// still matched (the invariant only catches a NEGATIVE count, not a leak).
|
||||
const lateSteering = handle.inbox.drainSteering()
|
||||
if (terminalStopped) {
|
||||
if (lateSteering.length > 0) {
|
||||
events.emit('agent/inbox/discard', lateSteering.map(message => agentMessage(message, true)))
|
||||
}
|
||||
} else {
|
||||
for (const message of lateSteering) handle.inbox.enqueue(message)
|
||||
}
|
||||
|
||||
if (!handle.inbox.hasQueued) handle.setStatus('idle')
|
||||
// Park at idle unless a waking item still wants the model to run; a lone
|
||||
// quiet (`wakeup:false`) item stays queued but does not keep the loop busy.
|
||||
if (!handle.inbox.hasWakingQueued) handle.setStatus('idle')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,10 +293,14 @@ async function runTurn(
|
||||
const drainSteering = (): boolean => {
|
||||
const messages = handle.inbox.drainSteering()
|
||||
for (const message of messages) {
|
||||
events.emit('agent/inbox/dequeue', agentMessage(message, true))
|
||||
const prepared = preparePromptMessage(message.content, message.source, message.contexts)
|
||||
session.append('steering/message', { turn, ...prepared.data }, { surfaceOp: 'append' })
|
||||
session.append('steering/message', {
|
||||
turn, ...prepared.data,
|
||||
...message.meta === undefined ? {} : { meta: message.meta },
|
||||
}, { surfaceOp: 'append' })
|
||||
for (const context of prepared.separateContexts) {
|
||||
session.append('context/message', {
|
||||
session.append('user/message', {
|
||||
content: context.content,
|
||||
source: context.source,
|
||||
...context.meta === undefined ? {} : { meta: context.meta },
|
||||
@@ -296,6 +314,7 @@ async function runTurn(
|
||||
const message = handle.inbox.dequeueQueued()
|
||||
/* v8 ignore next 3 -- invariant guard: runLoop only calls runTurn when hasQueued */
|
||||
if (!message) throw new Error('runTurn invariant violated: no queued message at turn start')
|
||||
events.emit('agent/inbox/dequeue', agentMessage(message, false))
|
||||
const trigger: TurnTrigger = { kind: 'message', source: message.source }
|
||||
|
||||
let reason: TurnEndReason = { kind: 'completed' }
|
||||
@@ -361,7 +380,10 @@ async function runTurn(
|
||||
// `allow.content` REPLACES the prompt bytes (a rewrite); absent keeps them.
|
||||
const content = promptDecision.content ?? message.content
|
||||
const prepared = preparePromptMessage(content, message.source, promptDecision.additionalContexts ?? [])
|
||||
session.append('user/message', prepared.data, { surfaceOp: 'append' })
|
||||
session.append('user/message', {
|
||||
...prepared.data,
|
||||
...message.meta === undefined ? {} : { meta: message.meta },
|
||||
}, { surfaceOp: 'append' })
|
||||
// Separate contexts still enter THIS turn through inject(). Prefix
|
||||
// contexts are already baked into the user/message with their durable
|
||||
// display envelope, so appending them again would duplicate model input.
|
||||
@@ -536,9 +558,21 @@ async function runTurn(
|
||||
break
|
||||
}
|
||||
|
||||
// A continuation reason becomes next-step steering.
|
||||
// A continuation reason becomes next-step steering. Publish the same
|
||||
// enqueue event a public steer would, so the inbox ledger stays balanced
|
||||
// (every FIFO entry has a matching enqueue before its dequeue/discard).
|
||||
if (decision.action === 'continue' && decision.reason) {
|
||||
handle.inbox.steer({ content: decision.reason.content, source: decision.reason.source, contexts: [] })
|
||||
// Detach and freeze the listener-owned reason like a public steer, so an
|
||||
// enqueue listener or the producer cannot mutate the durable/model-visible
|
||||
// steering message before it drains.
|
||||
const item: InboxMessage = deepFreeze({
|
||||
id: AgentMessageId(randomUUID()),
|
||||
content: structuredClone(decision.reason.content),
|
||||
source: structuredClone(decision.reason.source),
|
||||
contexts: [], wakeup: true,
|
||||
})
|
||||
handle.inbox.steer(item)
|
||||
events.emit('agent/inbox/enqueue', agentMessage(item, true))
|
||||
}
|
||||
let shouldContinue = decision.action === 'continue'
|
||||
|
||||
@@ -562,7 +596,13 @@ async function runTurn(
|
||||
if (terminalStop) {
|
||||
terminalStopped = true
|
||||
// Terminal stop discards steering but preserves ordinary queued prompts.
|
||||
handle.inbox.drainSteering()
|
||||
// Publish a discard for every dropped steering item so the enqueue ⇒
|
||||
// dequeue-or-discard ledger stays balanced (the outstanding-count
|
||||
// invariant and correlation consumers must not be left with dangling ids).
|
||||
const dropped = handle.inbox.drainSteering()
|
||||
if (dropped.length > 0) {
|
||||
events.emit('agent/inbox/discard', dropped.map(item => agentMessage(item, true)))
|
||||
}
|
||||
shouldContinue = false
|
||||
}
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
}
|
||||
|
||||
function send(agent: Agent, text: string): void {
|
||||
agent.send([{ type: 'text', text }])
|
||||
agent.followup([{ type: 'text', text }])
|
||||
}
|
||||
|
||||
/** Adapter that holds both drivers at the same awaited continuation. */
|
||||
|
||||
@@ -48,7 +48,7 @@ function waitForStatus(ctx: Context, agent: Agent, expected: Agent['status']): P
|
||||
}
|
||||
|
||||
function send(agent: Agent, text: string) {
|
||||
agent.send([{ type: 'text', text }])
|
||||
agent.followup([{ type: 'text', text }])
|
||||
}
|
||||
|
||||
describe('Agent', () => {
|
||||
@@ -83,7 +83,41 @@ describe('Agent', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('send() throws after disposal', async () => {
|
||||
it('send exposes the fully resolved delivery path without applying helper defaults', async () => {
|
||||
const adapter = new MockAdapter([textResponse('accepted')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const enqueued = Promise.withResolvers<{ id: string; source: unknown; wakeup: boolean }>()
|
||||
ctx.on('agent/inbox/enqueue', (subject, message) => {
|
||||
if (subject === agent) enqueued.resolve(message)
|
||||
})
|
||||
|
||||
const id = agent.send({
|
||||
content: [{ type: 'text', text: 'advanced input' }],
|
||||
source: { kind: 'plugin', plugin: 'advanced-caller' },
|
||||
contexts: [],
|
||||
meta: { caller: 'advanced' },
|
||||
target: 'next-turn',
|
||||
wakeup: true,
|
||||
})
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(await enqueued.promise).toMatchObject({
|
||||
id,
|
||||
source: { kind: 'plugin', plugin: 'advanced-caller' },
|
||||
wakeup: true,
|
||||
})
|
||||
expect(agent.session.events.find(event => event.type === 'user/message'))
|
||||
.toMatchObject({
|
||||
data: {
|
||||
source: { kind: 'plugin', plugin: 'advanced-caller' },
|
||||
meta: { caller: 'advanced' },
|
||||
},
|
||||
})
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('followup() throws after disposal', async () => {
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
const ctx = await harness(adapter)
|
||||
let agent!: Agent
|
||||
@@ -95,7 +129,28 @@ describe('Agent', () => {
|
||||
await fiber.dispose()
|
||||
await driverDone(agent)
|
||||
|
||||
expect(() => { agent.send([{ type: 'text', text: 'too late' }]) }).toThrow('disposed')
|
||||
expect(() => { agent.followup([{ type: 'text', text: 'too late' }]) }).toThrow('disposed')
|
||||
})
|
||||
|
||||
it('disposal discards still-pending inbox items so every id gets a terminal event', async () => {
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
const ctx = await harness(adapter)
|
||||
let agent!: Agent
|
||||
const discarded: string[] = []
|
||||
ctx.on('agent/inbox/discard', (subject, messages) => {
|
||||
if (subject === agent) discarded.push(...messages.map(m => m.id))
|
||||
})
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' })
|
||||
}, { inject: ['agentLoop'] }))
|
||||
|
||||
// A quiet (non-waking) item stays parked in the inbox; disposal must drop it
|
||||
// WITH a discard so its enqueued id is not left dangling forever.
|
||||
const id = agent.queue([{ type: 'text', text: 'never runs' }])
|
||||
await fiber.dispose()
|
||||
await driverDone(agent)
|
||||
|
||||
expect(discarded).toEqual([id])
|
||||
})
|
||||
|
||||
it('steer() throws after disposal', async () => {
|
||||
@@ -139,7 +194,7 @@ describe('Agent', () => {
|
||||
agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
agent.inject([{ type: 'text', text: 'mid' }], { source: { kind: 'plugin', plugin: 'p' } })
|
||||
expect(agent.session.events.filter(e => e.type === 'turn/start')).toHaveLength(1)
|
||||
expect(agent.session.events.at(-1)!.type).toBe('context/message')
|
||||
expect(agent.session.events.at(-1)!.type).toBe('user/message')
|
||||
|
||||
// Close the turn; now inject must wrap its own one-shot injection turn.
|
||||
agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
@@ -151,6 +206,16 @@ describe('Agent', () => {
|
||||
expect(agent.session.events.at(-1)!.type).toBe('turn/end') // turn-enclosed
|
||||
})
|
||||
|
||||
it('inject() defaults its source to an empty plugin, never user', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
agent.inject([{ type: 'text', text: 'no explicit source' }])
|
||||
const injected = agent.session.events.at(-1)!
|
||||
expect(injected.type === 'user/message' && injected.data.source).toEqual({ kind: 'plugin', plugin: '' })
|
||||
})
|
||||
|
||||
it('idle inject() contains a failing flush (logs, does not throw into the caller)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
@@ -167,24 +232,54 @@ describe('Agent', () => {
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('idle inject() closes its one-shot turn AND still checkpoints even if the append throws', async () => {
|
||||
it('idle inject() validates its payload BEFORE opening a turn, so invalid input appends nothing', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
let flushes = 0
|
||||
ctx.on('session/flush', () => { flushes += 1 })
|
||||
|
||||
// Non-serializable injected content makes Session.append throw AFTER
|
||||
// turn/start was recorded. The turn/end must still be appended (finally),
|
||||
// AND the durability checkpoint must still fire — the balanced turn is in
|
||||
// memory and a crash before the next turn/dispose would otherwise lose it.
|
||||
// Non-serializable injected content is rejected by the up-front snapshot
|
||||
// BEFORE any append (the unified send contract: invalid input throws before
|
||||
// mutating the log). No one-shot turn opens and no durability checkpoint fires.
|
||||
expect(() => {
|
||||
agent.inject([{ type: 'text', text: 'x', bad: 1n } as never], { source: { kind: 'plugin', plugin: 'p' } })
|
||||
}).toThrow(/non-JSON-serializable/)
|
||||
const types = agent.session.events.map(e => e.type)
|
||||
expect(types).toEqual(['turn/start', 'turn/end']) // balanced, no open turn
|
||||
await new Promise(r => setTimeout(r, 10)) // let the fire-and-forget flush run
|
||||
expect(flushes).toBe(1) // checkpoint fired despite the throw
|
||||
}).toThrow(/losslessly JSON-serializable/)
|
||||
expect(agent.session.events).toHaveLength(0)
|
||||
await new Promise(r => setTimeout(r, 10)) // give any (erroneous) flush a chance
|
||||
expect(flushes).toBe(0) // nothing was appended, so no checkpoint
|
||||
})
|
||||
|
||||
it('idle inject() re-entered from a session/event listener is rejected pre-commit and opens no turn', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
let flushes = 0
|
||||
ctx.on('session/flush', () => { flushes += 1 })
|
||||
// Injecting from inside a session/event listener re-enters Session.append,
|
||||
// which rejects pre-commit — so turn/start never commits. The finally sees
|
||||
// no open turn (closes nothing) and no recorded turn (no checkpoint), and
|
||||
// the reentrant throw is contained by Session's post-commit dispatch.
|
||||
// Fire on turn/end: at that instant the outer one-shot turn is closed (no
|
||||
// turn open), so the reentrant inject takes the idle one-shot-turn path and
|
||||
// its turn/start append re-enters Session and is rejected pre-commit.
|
||||
let reentered = false
|
||||
ctx.on('session/event', (_s, event) => {
|
||||
if (!reentered && event.type === 'turn/end') {
|
||||
reentered = true
|
||||
agent.inject([{ type: 'text', text: 'reentrant' }], { source: { kind: 'plugin', plugin: 'p' } })
|
||||
}
|
||||
})
|
||||
|
||||
agent.inject([{ type: 'text', text: 'outer' }], { source: { kind: 'plugin', plugin: 'p' } })
|
||||
// The outer injection's own one-shot turn is balanced; the reentrant one
|
||||
// opened no turn (its turn/start was rejected pre-commit).
|
||||
const turnStarts = agent.session.events.filter(e => e.type === 'turn/start')
|
||||
expect(turnStarts).toHaveLength(1)
|
||||
const injected = agent.session.events.filter(e => e.type === 'user/message')
|
||||
expect(injected).toHaveLength(1) // the reentrant user/message never committed
|
||||
await new Promise(r => setTimeout(r, 10))
|
||||
expect(flushes).toBe(1) // only the outer accepted turn checkpointed
|
||||
})
|
||||
|
||||
it('idle inject() still checkpoints when a listener throws on the synthetic turn/end', async () => {
|
||||
@@ -202,7 +297,7 @@ describe('Agent', () => {
|
||||
|
||||
expect(() => { agent.inject([{ type: 'text', text: 'notice' }], { source: { kind: 'plugin', plugin: 'p' } }) }).not.toThrow()
|
||||
const types = agent.session.events.map(e => e.type)
|
||||
expect(types).toEqual(['turn/start', 'context/message', 'turn/end']) // balanced
|
||||
expect(types).toEqual(['turn/start', 'user/message', 'turn/end']) // balanced
|
||||
await new Promise(r => setTimeout(r, 10))
|
||||
expect(flushes).toBe(1) // checkpoint fired despite the throwing turn/end listener
|
||||
})
|
||||
@@ -234,13 +329,11 @@ describe('Agent', () => {
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
// A non-serializable source makes the turn/start append throw BEFORE the
|
||||
// event is pushed (Session.append validates before push), so NO turn opens.
|
||||
// The finally's isTurnOpen() guard sees no open turn and appends nothing —
|
||||
// the log stays empty, not left with a dangling turn/start.
|
||||
// A non-serializable source is rejected by the up-front snapshot BEFORE any
|
||||
// append, so NO turn opens and the log stays empty.
|
||||
expect(() => {
|
||||
agent.inject([{ type: 'text', text: 'x' }], { source: { kind: 'plugin', plugin: 'p', bad: 1n } as never })
|
||||
}).toThrow(/non-JSON-serializable/)
|
||||
}).toThrow(/losslessly JSON-serializable/)
|
||||
expect(agent.session.events).toHaveLength(0)
|
||||
})
|
||||
|
||||
@@ -397,7 +490,7 @@ describe('Agent', () => {
|
||||
const { agent } = prepared
|
||||
prepared.markPublished()
|
||||
const dispose = prepared.startDriver()
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
expect(agent.status).toBe('running')
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ async function harness(adapter: MockAdapter) {
|
||||
}
|
||||
|
||||
function send(agent: Agent, text: string) {
|
||||
agent.send([{ type: 'text', text }])
|
||||
agent.followup([{ type: 'text', text }])
|
||||
}
|
||||
|
||||
/** 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.send([{ type: 'text', text: 'queued by cancel observer' }])
|
||||
subject.followup([{ type: 'text', text: 'queued by cancel observer' }])
|
||||
throw new Error('observer failed')
|
||||
})
|
||||
ctx.on('agent/cancel-requested', (subject, cause) => {
|
||||
@@ -98,6 +98,57 @@ describe('Agent.cancel()', () => {
|
||||
expect(agent.session.events.some(e => e.type === 'turn/end')).toBe(true)
|
||||
})
|
||||
|
||||
it('cancel({ keepInbox: true }) preserves queued work and emits no discard', async () => {
|
||||
const adapter = new MockAdapter([textResponse('reply')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const discards: unknown[] = []
|
||||
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.queue([{ type: 'text', text: 'preserved' }])
|
||||
// keepInbox cancel: no active turn, work preserved, no discard event.
|
||||
agent.cancel({ kind: 'user' }, { keepInbox: true })
|
||||
expect(discards).toEqual([])
|
||||
|
||||
// The preserved item still runs once the driver is woken by a later send.
|
||||
send(agent, 'wake it')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(userTexts(agent)).toEqual(['preserved', 'wake it'])
|
||||
})
|
||||
|
||||
it('a lone queued message leaves the agent parked at idle', async () => {
|
||||
const adapter = new MockAdapter([textResponse('reply')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
// A quiet item alone must NOT wake the driver: no turn runs and whenIdle
|
||||
// resolves (the agent is quiescent), leaving the item queued.
|
||||
agent.queue([{ type: 'text', text: 'quiet' }])
|
||||
await agent.whenIdle()
|
||||
expect(agent.status).toBe('idle')
|
||||
expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(false)
|
||||
|
||||
// A later waking send drives the loop, and the quiet item rides along first.
|
||||
send(agent, 'wake')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(userTexts(agent)).toEqual(['quiet', 'wake'])
|
||||
})
|
||||
|
||||
it('cancelling a parked quiet item settles a pending whenIdle() without a later send', async () => {
|
||||
const adapter = new MockAdapter([textResponse('reply')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
agent.queue([{ type: 'text', text: 'quiet' }])
|
||||
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).
|
||||
agent.cancel({ kind: 'user' })
|
||||
await idle
|
||||
expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(false)
|
||||
})
|
||||
|
||||
it('pre-step cancel drops the about-to-start turn (no turn is opened)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('should not run')])
|
||||
const ctx = await harness(adapter)
|
||||
|
||||
@@ -98,7 +98,7 @@ describe('config-driven session id', () => {
|
||||
first = ctx.agents.get(SessionId('config-exact-reload'))
|
||||
}
|
||||
expect(first).toBeDefined()
|
||||
first!.send([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } })
|
||||
first!.followup([{ 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!.send([{ type: 'text', text: 'continue' }], { source: { kind: 'user' } })
|
||||
second!.followup([{ 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'))
|
||||
@@ -335,7 +335,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.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
|
||||
a1.followup([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
|
||||
await waitForIdle(ctx1, a1)
|
||||
await ctx1.fiber.dispose()
|
||||
|
||||
@@ -354,7 +354,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.send([{ type: 'text', text: 'q2' }], { source: { kind: 'user' } })
|
||||
a2.followup([{ type: 'text', text: 'q2' }], { source: { kind: 'user' } })
|
||||
await waitForIdle(ctx2, a2)
|
||||
await ctx2.fiber.dispose()
|
||||
})
|
||||
@@ -375,7 +375,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.send([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } })
|
||||
a1.followup([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } })
|
||||
await waitForIdle(ctx1, a1)
|
||||
await ctx1.fiber.dispose()
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
}
|
||||
|
||||
function send(agent: Agent, text: string) {
|
||||
agent.send([{ type: 'text', text }])
|
||||
agent.followup([{ type: 'text', text }])
|
||||
}
|
||||
|
||||
describe('session log records what agent/step-result actually produced', () => {
|
||||
@@ -275,7 +275,9 @@ describe('abort during tool execution ends the turn', () => {
|
||||
order.push(`tool/result:${event.data.callId}:${outcome}`)
|
||||
break
|
||||
}
|
||||
case 'context/message': order.push('context/message'); break
|
||||
// Injected context is a plugin-sourced user/message; the direct human
|
||||
// prompt (user source) is not tracked in this ordering.
|
||||
case 'user/message': if (event.data.source.kind !== 'user') order.push('context/message'); break
|
||||
case 'steering/message': order.push('steering/message'); break
|
||||
case 'step/end': order.push('step/end'); break
|
||||
case 'turn/end': {
|
||||
@@ -354,13 +356,14 @@ describe('abort during tool execution ends the turn', () => {
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const events = [...agent.session.events]
|
||||
const isInjected = (e: SessionEvent): e is SessionEvent<'user/message'> => e.type === 'user/message' && e.data.source.kind !== 'user'
|
||||
expect(events
|
||||
.filter(event => event.type === 'tool/result' || event.type === 'context/message'
|
||||
.filter(event => event.type === 'tool/result' || isInjected(event)
|
||||
|| event.type === 'step/end' || event.type === 'turn/end')
|
||||
.map(event => event.type))
|
||||
.map(event => isInjected(event) ? 'context/message' : event.type))
|
||||
.toEqual(['tool/result', 'context/message', 'context/message', 'step/end', 'turn/end'])
|
||||
expect(events
|
||||
.filter(event => event.type === 'context/message')
|
||||
.filter(isInjected)
|
||||
.map(event => event.data.content))
|
||||
.toEqual([
|
||||
[{ type: 'text', text: 'accepted before abort' }],
|
||||
@@ -410,12 +413,13 @@ describe('abort during tool execution ends the turn', () => {
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const events = [...agent.session.events]
|
||||
const isInjected = (e: SessionEvent): e is SessionEvent<'user/message'> => e.type === 'user/message' && e.data.source.kind !== 'user'
|
||||
expect(events
|
||||
.filter(event => event.type === 'tool/result' || event.type === 'context/message'
|
||||
.filter(event => event.type === 'tool/result' || isInjected(event)
|
||||
|| event.type === 'step/end' || event.type === 'turn/end')
|
||||
.map(event => event.type))
|
||||
.map(event => isInjected(event) ? 'context/message' : event.type))
|
||||
.toEqual(['tool/result', 'tool/result', 'context/message', 'step/end', 'turn/end'])
|
||||
expect(events.find(event => event.type === 'context/message')?.data.content)
|
||||
expect(events.find(isInjected)?.data.content)
|
||||
.toEqual([{ type: 'text', text: 'accepted after first result' }])
|
||||
})
|
||||
|
||||
@@ -456,7 +460,7 @@ describe('abort during tool execution ends the turn', () => {
|
||||
await fiber.dispose()
|
||||
|
||||
expect(agent.session.events
|
||||
.filter(event => event.type === 'context/message')
|
||||
.filter((event): event is SessionEvent<'user/message'> => event.type === 'user/message' && event.data.source.kind !== 'user')
|
||||
.map(event => event.data.content))
|
||||
.toEqual([
|
||||
[{ type: 'text', text: 'accepted before disposal' }],
|
||||
@@ -507,7 +511,7 @@ describe('abort during tool execution ends the turn', () => {
|
||||
send(agent, 'start a text-only turn')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(agent.session.events.find(event => event.type === 'context/message')?.data.content)
|
||||
expect(agent.session.events.find((event): event is SessionEvent<'user/message'> => event.type === 'user/message' && event.data.source.kind !== 'user')?.data.content)
|
||||
.toEqual([{ type: 'text', text: 'new turn context' }])
|
||||
expect(JSON.stringify(adapter.requests[1]?.messages)).toContain('new turn context')
|
||||
})
|
||||
@@ -763,7 +767,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => {
|
||||
expect(agent.session.deriveMessages().at(-1)?.content).toEqual([{ type: 'text', text: 'routed' }])
|
||||
})
|
||||
|
||||
it('agent/queued carries the resolved source; steering/message records its source', async () => {
|
||||
it('agent/inbox/enqueue carries the resolved source; steering/message records 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' })
|
||||
@@ -778,7 +782,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => {
|
||||
}))
|
||||
|
||||
const queuedSources: { source: MessageSource; contexts: HookContext[]; steering: boolean }[] = []
|
||||
ctx.on('agent/queued', (_agent, _content, info) => void queuedSources.push(info))
|
||||
ctx.on('agent/inbox/enqueue', (_agent, info) => void queuedSources.push({ source: info.source, contexts: info.contexts, steering: info.steering }))
|
||||
|
||||
send(agent, 'go') // no explicit source → default {kind:'user'} must be visible
|
||||
await waitForIdle(ctx, agent)
|
||||
@@ -800,11 +804,11 @@ describe('adapter registration, routing, and accepted-input ownership', () => {
|
||||
let notifiedContent: ContentBlock[] | undefined
|
||||
let notifiedSource: MessageSource | undefined
|
||||
let notifiedContexts: HookContext[] | undefined
|
||||
ctx.on('agent/queued', (subject, acceptedContent, info) => {
|
||||
ctx.on('agent/inbox/enqueue', (subject, info) => {
|
||||
if (subject !== agent || info.steering) return
|
||||
// Retain the exact notification references: cloning here would test the
|
||||
// listener's copy rather than the event/inbox ownership boundary.
|
||||
notifiedContent = acceptedContent
|
||||
notifiedContent = info.content
|
||||
notifiedSource = info.source
|
||||
notifiedContexts = info.contexts
|
||||
})
|
||||
@@ -814,7 +818,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => {
|
||||
source: { kind: 'plugin', plugin: 'context-source' },
|
||||
meta: { version: 1 },
|
||||
}]
|
||||
agent.send(content, { source, contexts })
|
||||
agent.followup(content, { source, contexts })
|
||||
content[0]!.text = 'caller-mutated-send'
|
||||
source.plugin = 'caller-mutated-source'
|
||||
contexts[0]!.content[0] = { type: 'text', text: 'caller-mutated-context' }
|
||||
@@ -863,14 +867,14 @@ describe('adapter registration, routing, and accepted-input ownership', () => {
|
||||
let notifiedContent: ContentBlock[] | undefined
|
||||
let notifiedSource: MessageSource | undefined
|
||||
let notifiedContexts: HookContext[] | undefined
|
||||
ctx.on('agent/queued', (subject, acceptedContent, info) => {
|
||||
ctx.on('agent/inbox/enqueue', (subject, info) => {
|
||||
if (subject !== agent || !info.steering) return
|
||||
notifiedContent = acceptedContent
|
||||
notifiedContent = info.content
|
||||
notifiedSource = info.source
|
||||
notifiedContexts = info.contexts
|
||||
})
|
||||
|
||||
agent.send([{ type: 'text', text: 'start' }])
|
||||
agent.followup([{ type: 'text', text: 'start' }])
|
||||
await entered.promise
|
||||
expect(agent.status).toBe('running')
|
||||
const content = [{ type: 'text' as const, text: 'accepted-steer' }]
|
||||
@@ -951,7 +955,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => {
|
||||
expect(request).not.toContain('caller-mutated-steering-context-without-meta')
|
||||
|
||||
const steeringIndex = agent.session.events.findIndex(event => event.type === 'steering/message')
|
||||
const contextIndex = agent.session.events.findIndex(event => event.type === 'context/message'
|
||||
const contextIndex = agent.session.events.findIndex(event => event.type === 'user/message'
|
||||
&& event.data.source.kind === 'plugin' && event.data.source.plugin === 'steering-context')
|
||||
expect(steeringIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(contextIndex).toBe(steeringIndex + 1)
|
||||
@@ -987,7 +991,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.send([{ type: 'text', text: 'continue' }])
|
||||
forked.followup([{ type: 'text', text: 'continue' }])
|
||||
await new Promise<void>((resolve) => {
|
||||
ctx2.on('agent/status', (subject, status) => {
|
||||
if (subject === forked && status === 'idle') resolve()
|
||||
|
||||
@@ -38,7 +38,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
}
|
||||
|
||||
function send(agent: Agent, text: string) {
|
||||
agent.send([{ type: 'text', text }])
|
||||
agent.followup([{ type: 'text', text }])
|
||||
}
|
||||
|
||||
describe('inbox acceptance', () => {
|
||||
@@ -47,13 +47,13 @@ describe('inbox acceptance', () => {
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
let queued = 0
|
||||
ctx.on('agent/queued', () => { queued += 1 })
|
||||
ctx.on('agent/inbox/enqueue', () => { queued += 1 })
|
||||
|
||||
expect(() => {
|
||||
agent.send([{ type: 'text', text: 'first', bad: 1n } as never])
|
||||
agent.followup([{ type: 'text', text: 'first', bad: 1n } as never])
|
||||
}).toThrow(/losslessly JSON-serializable/)
|
||||
expect(() => {
|
||||
agent.send([{ type: 'text', text: 'first' }], { source: { kind: 'plugin', plugin: 'p', bad: 1n } as never })
|
||||
agent.followup([{ type: 'text', text: 'first' }], { source: { kind: 'plugin', plugin: 'p', bad: 1n } as never })
|
||||
}).toThrow(/losslessly JSON-serializable/)
|
||||
expect(queued).toBe(0)
|
||||
expect(agent.session.events).toHaveLength(0)
|
||||
|
||||
155
packages/core/agent-loop/tests/inbox-invariant.spec.ts
Normal file
155
packages/core/agent-loop/tests/inbox-invariant.spec.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Regression: the dsh-agent FIFO-conservation invariant must stay balanced on
|
||||
* the loop-authored continuation-reason steering path. A continue-with-reason
|
||||
* decision enters the steering FIFO and later drains (or is discarded by
|
||||
* cancel); both must be matched by an enqueue event so the invariant's
|
||||
* outstanding count never goes negative.
|
||||
* @module dsh-agent-loop/tests/inbox-invariant
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { MockAdapter, textResponse } from './mock-adapter.ts'
|
||||
|
||||
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(InvariantService)
|
||||
await ctx.plugin(AgentInvariant)
|
||||
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() }
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
describe('inbox FIFO-conservation invariant', () => {
|
||||
it('stays balanced when a continuation reason enters and drains the steering FIFO', async () => {
|
||||
const adapter = new MockAdapter([textResponse('step 1'), textResponse('step 2')])
|
||||
const ctx = await harness(adapter)
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
let forced = false
|
||||
ctx.on('agent/turn-continuation', async (_agent, _turn, _default, _signal, next) => {
|
||||
if (forced) return next()
|
||||
forced = true
|
||||
return { action: 'continue' as const, reason: { content: [{ type: 'text', text: 'keep going' }], source: { kind: 'plugin', plugin: 'loop' } } }
|
||||
})
|
||||
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
// The continuation reason drained as a steering/message on the second step.
|
||||
expect(agent.session.events.some(e => e.type === 'steering/message')).toBe(true)
|
||||
// No invariant violation was logged.
|
||||
expect(warn.mock.calls.flat().some(arg => String(arg).includes('agent/inbox'))).toBe(false)
|
||||
expect(warn.mock.calls.flat().some(arg => String(arg).includes('INVARIANT'))).toBe(false)
|
||||
})
|
||||
|
||||
it('stays balanced when cancel discards a pending continuation reason', async () => {
|
||||
const adapter = new MockAdapter([textResponse('only step')])
|
||||
const ctx = await harness(adapter)
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
// Force a continuation reason, then cancel from the same checkpoint so the
|
||||
// reason sits in the steering FIFO when the inbox is discarded.
|
||||
ctx.on('agent/turn-continuation', async (subject, _turn, _default, _signal, next) => {
|
||||
if (subject !== agent) return next()
|
||||
queueMicrotask(() => { agent.cancel({ kind: 'user' }) })
|
||||
return { action: 'continue' as const, reason: { content: [{ type: 'text', text: 'keep going' }], source: { kind: 'plugin', plugin: 'loop' } } }
|
||||
})
|
||||
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(warn.mock.calls.flat().some(arg => String(arg).includes('agent/inbox'))).toBe(false)
|
||||
expect(warn.mock.calls.flat().some(arg => String(arg).includes('INVARIANT'))).toBe(false)
|
||||
})
|
||||
|
||||
it('stays balanced when a terminal stop discards pending steering', async () => {
|
||||
const adapter = new MockAdapter([textResponse('done')])
|
||||
const ctx = await harness(adapter)
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
const discards: number[] = []
|
||||
ctx.on('agent/inbox/discard', (subject, messages) => { if (subject === agent) discards.push(messages.length) })
|
||||
|
||||
// A continuation reason enqueues a steering item; a terminal stop then drops
|
||||
// it. The drop must emit a discard so the enqueue ⇒ dequeue-or-discard
|
||||
// ledger stays balanced (no dangling outstanding id).
|
||||
ctx.on('agent/turn-continuation', async (subject, _turn, _default, _signal, next) => {
|
||||
if (subject !== agent) return next()
|
||||
return { action: 'continue' as const, reason: { content: [{ type: 'text', text: 'keep going' }], source: { kind: 'plugin', plugin: 'loop' } } }
|
||||
})
|
||||
let stopped = false
|
||||
ctx.on('agent/turn-stop', (subject) => {
|
||||
if (subject !== agent || stopped) return undefined
|
||||
stopped = true
|
||||
return { action: 'stop' as const }
|
||||
})
|
||||
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(discards).toEqual([1]) // the dropped steering item was reported
|
||||
expect(warn.mock.calls.flat().some(arg => String(arg).includes('agent/inbox'))).toBe(false)
|
||||
expect(warn.mock.calls.flat().some(arg => String(arg).includes('INVARIANT'))).toBe(false)
|
||||
})
|
||||
|
||||
it('stays balanced when late steering lands after a terminal stop (post-turn flush window)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('done')])
|
||||
const ctx = await harness(adapter)
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
let enqueues = 0
|
||||
const discards: number[] = []
|
||||
ctx.on('agent/inbox/enqueue', (subject) => { if (subject === agent) enqueues += 1 })
|
||||
ctx.on('agent/inbox/discard', (subject, messages) => { if (subject === agent) discards.push(messages.length) })
|
||||
|
||||
// Terminal-stop the turn, then steer during the post-turn flush window
|
||||
// (status is still running). That late steer is drained by runLoop and
|
||||
// dropped because the turn terminally stopped; it must still be discarded so
|
||||
// its enqueue is matched (the drain sits on a different code path than the
|
||||
// in-turn terminal-stop drop).
|
||||
ctx.on('agent/turn-stop', subject => (subject === agent ? { action: 'stop' as const } : undefined))
|
||||
let steered = false
|
||||
ctx.on('session/flush', (session) => {
|
||||
if (session !== agent.session || steered) return
|
||||
steered = true
|
||||
agent.steer([{ type: 'text', text: 'late' }], { source: { kind: 'plugin', plugin: 'late' } })
|
||||
})
|
||||
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// The prompt plus the late steer both enqueued; both are matched (the prompt
|
||||
// dequeued, the late steer discarded) so no id is left outstanding.
|
||||
expect(enqueues).toBe(2)
|
||||
expect(discards).toEqual([1])
|
||||
expect(warn.mock.calls.flat().some(arg => String(arg).includes('agent/inbox'))).toBe(false)
|
||||
expect(warn.mock.calls.flat().some(arg => String(arg).includes('INVARIANT'))).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Inbox } from '../src/inbox.ts'
|
||||
import { AgentMessageId } from '@deepseek-ai/dsh-agent'
|
||||
import { Inbox, agentMessage } from '../src/inbox.ts'
|
||||
|
||||
function message(text: string) {
|
||||
return { content: [{ type: 'text' as const, text }], source: { kind: 'user' as const }, contexts: [] }
|
||||
return { id: AgentMessageId(text), content: [{ type: 'text' as const, text }], source: { kind: 'user' as const }, contexts: [], wakeup: true }
|
||||
}
|
||||
|
||||
describe('agentMessage', () => {
|
||||
it('returns a frozen payload so a listener cannot mutate it for later listeners', () => {
|
||||
const payload = agentMessage(message('m'), false)
|
||||
expect(Object.isFrozen(payload)).toBe(true)
|
||||
expect(() => { (payload as { id: string }).id = 'mutated' }).toThrow()
|
||||
expect(payload.id).toBe(AgentMessageId('m'))
|
||||
})
|
||||
})
|
||||
|
||||
function resolverPair() {
|
||||
let r!: () => void
|
||||
const p = new Promise<void>((resolve) => { r = resolve })
|
||||
@@ -25,6 +35,32 @@ describe('Inbox', () => {
|
||||
expect(inbox.dequeueQueued()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('enqueue(msg, false) queues without waking a parked waiter', async () => {
|
||||
const inbox = new Inbox()
|
||||
let woke = false
|
||||
const waiter = inbox.waitForQueued(new Promise(() => {})).then(() => { woke = true })
|
||||
inbox.enqueue(message('quiet'), false)
|
||||
// The item is queued, but the parked waiter was not resolved by it.
|
||||
expect(inbox.hasQueued).toBe(true)
|
||||
await Promise.resolve()
|
||||
expect(woke).toBe(false)
|
||||
// A later waking enqueue resolves the same waiter.
|
||||
inbox.enqueue(message('loud'))
|
||||
await waiter
|
||||
expect(woke).toBe(true)
|
||||
})
|
||||
|
||||
it('pending() snapshots queued then steering without removing them', () => {
|
||||
const inbox = new Inbox()
|
||||
inbox.enqueue(message('q'))
|
||||
inbox.steer(message('s'))
|
||||
const pending = inbox.pending()
|
||||
expect(pending.map(p => p.steering)).toEqual([false, true])
|
||||
// Snapshot does not drain the FIFOs.
|
||||
expect(inbox.hasQueued).toBe(true)
|
||||
expect(inbox.hasSteering).toBe(true)
|
||||
})
|
||||
|
||||
it('pushes and drains steering messages separately from queued', () => {
|
||||
const inbox = new Inbox()
|
||||
inbox.steer(message('steer'))
|
||||
|
||||
@@ -42,7 +42,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
}
|
||||
|
||||
function send(agent: Agent, text: string) {
|
||||
agent.send([{ type: 'text', text }])
|
||||
agent.followup([{ type: 'text', text }])
|
||||
}
|
||||
|
||||
function events(agent: Agent): SessionEvent[] {
|
||||
@@ -87,7 +87,7 @@ describe('agent/prompt-submit', () => {
|
||||
expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('original')
|
||||
})
|
||||
|
||||
it('allow with additionalContexts injects separate context/message events into the turn', async () => {
|
||||
it('allow with additionalContexts injects separate injected-context user messages into the turn', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
@@ -107,12 +107,12 @@ describe('agent/prompt-submit', () => {
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const log = events(agent)
|
||||
const userMsg = log.find(e => e.type === 'user/message')
|
||||
const ctxMsg = log.find(e => e.type === 'context/message')
|
||||
const userMsg = log.find(e => e.type === 'user/message' && e.data.source.kind === 'user')
|
||||
const ctxMsg = log.find(e => e.type === 'user/message' && e.data.source.kind === 'plugin')
|
||||
expect(userMsg).toBeDefined()
|
||||
expect(ctxMsg?.type === 'context/message' && ctxMsg.data.content).toEqual([{ type: 'text', text: '<system-reminder>extra ctx</system-reminder>' }])
|
||||
expect(ctxMsg?.type === 'context/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'test' })
|
||||
expect(ctxMsg?.type === 'context/message' && ctxMsg.data.meta).toEqual(meta)
|
||||
expect(ctxMsg?.type === 'user/message' && ctxMsg.data.content).toEqual([{ type: 'text', text: '<system-reminder>extra ctx</system-reminder>' }])
|
||||
expect(ctxMsg?.type === 'user/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'test' })
|
||||
expect(ctxMsg?.type === 'user/message' && ctxMsg.data.meta).toEqual(meta)
|
||||
const sent = JSON.stringify(adapter.requests[0]!.messages)
|
||||
expect(sent).toContain('extra ctx')
|
||||
})
|
||||
@@ -128,7 +128,7 @@ describe('agent/prompt-submit', () => {
|
||||
? downstream
|
||||
: { ...downstream, content: [{ type: 'text', text: 'rewritten request' }] }
|
||||
})
|
||||
agent.send([{ type: 'text', text: 'original request' }], {
|
||||
agent.followup([{ type: 'text', text: 'original request' }], {
|
||||
contexts: [{
|
||||
content: [{ type: 'text', text: 'untrusted prefix' }],
|
||||
source: { kind: 'plugin', plugin: 'prefix' },
|
||||
@@ -155,7 +155,7 @@ describe('agent/prompt-submit', () => {
|
||||
}],
|
||||
},
|
||||
})
|
||||
expect(log.some(event => event.type === 'context/message')).toBe(false)
|
||||
expect(log.some(event => event.type === 'user/message' && event.data.source.kind === 'plugin')).toBe(false)
|
||||
expect(adapter.requests[0]?.messages.at(-1)).toEqual({
|
||||
role: 'user',
|
||||
content: [
|
||||
@@ -203,7 +203,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.send([{ type: 'text', text: 'do something' }], {
|
||||
agent.followup([{ type: 'text', text: 'do something' }], {
|
||||
contexts: [{ content: [{ type: 'text', text: 'must be dropped' }], source: { kind: 'plugin', plugin: 'test' } }],
|
||||
})
|
||||
await waitForIdle(ctx, agent)
|
||||
@@ -215,7 +215,6 @@ describe('agent/prompt-submit', () => {
|
||||
expect(log.some(e => e.type === 'turn/start')).toBe(true)
|
||||
expect(log.some(e => e.type === 'turn/end')).toBe(true)
|
||||
expect(log.some(e => e.type === 'user/message')).toBe(false)
|
||||
expect(log.some(e => e.type === 'context/message')).toBe(false)
|
||||
expect(log.some(e => e.type === 'step/start')).toBe(false)
|
||||
// the veto is recorded durably as a prompt/blocked in the open turn
|
||||
const blocked = log.find(e => e.type === 'prompt/blocked')
|
||||
@@ -340,8 +339,8 @@ describe('agent/session-start', () => {
|
||||
// the injected context reached the model on the first (only) request
|
||||
expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('session preamble')
|
||||
// and is recorded with the plugin source, never mislabeled as a user prompt
|
||||
const ctxMsg = events(agent).find(e => e.type === 'context/message')
|
||||
expect(ctxMsg?.type === 'context/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'test' })
|
||||
const ctxMsg = events(agent).find(e => e.type === 'user/message' && e.data.source.kind === 'plugin')
|
||||
expect(ctxMsg?.type === 'user/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'test' })
|
||||
})
|
||||
|
||||
it('a throwing session-start listener does not abort agent construction', async () => {
|
||||
@@ -624,23 +623,22 @@ describe('tool additionalContexts buffering across a step', () => {
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// Event order in the log: both tool/results, THEN both context/messages —
|
||||
// Event order in the log: both tool/results, THEN both injected contexts —
|
||||
// never interleaved (which would break tool-call/result adjacency).
|
||||
const types = events(agent).map(e => e.type)
|
||||
const firstResult = types.indexOf('tool/result')
|
||||
const lastResult = types.lastIndexOf('tool/result')
|
||||
const firstCtx = types.indexOf('context/message')
|
||||
const injected = events(agent).filter(e => e.type === 'user/message' && e.data.source.kind === 'plugin')
|
||||
const seqs = events(agent)
|
||||
const firstResult = seqs.findIndex(e => e.type === 'tool/result')
|
||||
const lastResult = seqs.map(e => e.type).lastIndexOf('tool/result')
|
||||
const firstCtx = seqs.findIndex(e => e === injected[0])
|
||||
expect(firstResult).toBeGreaterThanOrEqual(0)
|
||||
expect(lastResult).toBeGreaterThan(firstResult) // two results
|
||||
expect(firstCtx).toBeGreaterThan(lastResult) // context only after ALL results
|
||||
// both contexts present
|
||||
const ctxTexts = events(agent)
|
||||
.filter(e => e.type === 'context/message')
|
||||
.flatMap(e => (e.type === 'context/message' ? e.data.content : []))
|
||||
const ctxTexts = injected
|
||||
.flatMap(e => (e.type === 'user/message' ? e.data.content : []))
|
||||
.map(b => (b.type === 'text' ? b.text : ''))
|
||||
expect(ctxTexts).toEqual(['ctx-c1', 'ctx-c2'])
|
||||
const contextEvents = events(agent).filter(e => e.type === 'context/message')
|
||||
expect(contextEvents.map(e => e.type === 'context/message' && e.data.meta)).toEqual([{ callId: 'c1' }, { callId: 'c2' }])
|
||||
expect(injected.map(e => e.type === 'user/message' && e.data.meta)).toEqual([{ callId: 'c1' }, { callId: 'c2' }])
|
||||
})
|
||||
|
||||
it('appends multiple contexts deferred by one composite tool after its outer result', async () => {
|
||||
@@ -661,14 +659,14 @@ describe('tool additionalContexts buffering across a step', () => {
|
||||
|
||||
const log = events(agent)
|
||||
const resultIndex = log.findIndex(event => event.type === 'tool/result')
|
||||
const contextEvents = log.filter(event => event.type === 'context/message')
|
||||
const contextEvents = log.filter(event => event.type === 'user/message' && event.data.source.kind === 'plugin')
|
||||
expect(resultIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(log.findIndex(event => event === contextEvents[0])).toBeGreaterThan(resultIndex)
|
||||
expect(contextEvents.map(event => event.type === 'context/message' && event.data.source)).toEqual([
|
||||
expect(contextEvents.map(event => event.type === 'user/message' && event.data.source)).toEqual([
|
||||
{ kind: 'plugin', plugin: 'a' },
|
||||
{ kind: 'plugin', plugin: 'b' },
|
||||
])
|
||||
expect(contextEvents.map(event => event.type === 'context/message' && event.data.meta)).toEqual([{ order: 1 }, { order: 2 }])
|
||||
expect(contextEvents.map(event => event.type === 'user/message' && event.data.meta)).toEqual([{ order: 1 }, { order: 2 }])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -750,13 +748,13 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se
|
||||
|
||||
const log = events(agent)
|
||||
// session-start preamble injected
|
||||
expect(log.some(e => e.type === 'context/message'
|
||||
expect(log.some(e => e.type === 'user/message' && e.data.source.kind === 'plugin'
|
||||
&& e.data.content.some(b => b.type === 'text' && b.text.includes('policy active (started: startup)')))).toBe(true)
|
||||
// prompt allowed → user/message recorded
|
||||
expect(log.some(e => e.type === 'user/message')).toBe(true)
|
||||
// prompt allowed → user-sourced user/message recorded
|
||||
expect(log.some(e => e.type === 'user/message' && e.data.source.kind === 'user')).toBe(true)
|
||||
// tool ran (echo allowed) and post-execute attached "audited" context
|
||||
expect(log.some(e => e.type === 'tool/result' && !e.data.isError)).toBe(true)
|
||||
expect(log.some(e => e.type === 'context/message'
|
||||
expect(log.some(e => e.type === 'user/message' && e.data.source.kind === 'plugin'
|
||||
&& e.data.content.some(b => b.type === 'text' && b.text === 'audited'))).toBe(true)
|
||||
// NO hook/* events — a native plugin needs none
|
||||
expect(log.some(e => e.type.startsWith('hook/'))).toBe(false)
|
||||
|
||||
@@ -42,7 +42,7 @@ describe('request-reconstruction invariant', () => {
|
||||
|
||||
it('uses the step boundary rather than content appended afterward', async () => {
|
||||
const { ctx, session, boundary } = await requestSetup()
|
||||
session.append('context/message', { content: [{ type: 'text', text: '[late]' }], source: { kind: 'plugin', plugin: 'x' } }, { surfaceOp: 'append' })
|
||||
session.append('user/message', { content: [{ type: 'text', text: '[late]' }], source: { kind: 'plugin', plugin: 'x' } }, { surfaceOp: 'append' })
|
||||
const options = loopRequest({ model: 'm', messages: Object.freeze(boundary), sessionId: session.id })
|
||||
expect(() => { dispatch(ctx, options) }).not.toThrow()
|
||||
})
|
||||
|
||||
@@ -42,7 +42,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
}
|
||||
|
||||
function send(agent: Agent, text: string) {
|
||||
agent.send([{ type: 'text', text }])
|
||||
agent.followup([{ type: 'text', text }])
|
||||
}
|
||||
|
||||
describe('agent loop', () => {
|
||||
@@ -391,7 +391,7 @@ describe('agent loop', () => {
|
||||
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
|
||||
// The idle inject records a self-contained turn (turn/start → user/message
|
||||
// → turn/end) so the event stays turn-enclosed, but does NOT run the model.
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
expect(agent.status).toBe('idle')
|
||||
@@ -427,8 +427,8 @@ describe('agent loop', () => {
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const contextEvent = agent.session.events.find(event => event.type === 'context/message')
|
||||
expect(contextEvent?.type === 'context/message' && contextEvent.data).toMatchObject({ meta })
|
||||
const contextEvent = agent.session.events.find(event => event.type === 'user/message' && event.data.source.kind === 'plugin')
|
||||
expect(contextEvent?.type === 'user/message' && contextEvent.data).toMatchObject({ meta })
|
||||
const requestText = JSON.stringify(adapter.requests[0]!.messages)
|
||||
expect(requestText).toContain('Additional instructions from: pkg/AGENTS.md')
|
||||
expect(requestText).not.toContain('<context source=')
|
||||
@@ -456,7 +456,7 @@ describe('agent loop', () => {
|
||||
})
|
||||
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')
|
||||
visibleDuringTool = agent.session.events.some(e => e.type === 'user/message' && e.data.source.kind === 'plugin')
|
||||
return [{ type: 'text', text: 'ok' }]
|
||||
},
|
||||
}))
|
||||
@@ -473,13 +473,13 @@ describe('agent loop', () => {
|
||||
const ts0 = turnStarts[0]!
|
||||
expect(ts0.type === 'turn/start' && ts0.data.trigger.kind).toBe('message')
|
||||
const result = agent.session.events.find(e => e.type === 'tool/result')!
|
||||
const contexts = agent.session.events.filter(e => e.type === 'context/message')
|
||||
const contexts = agent.session.events.filter(e => e.type === 'user/message' && e.data.source.kind === 'plugin')
|
||||
expect(contexts).toHaveLength(2)
|
||||
expect(result.seq).toBeLessThan(contexts[0]!.seq)
|
||||
expect(contexts[0]?.type === 'context/message' && contexts[0].data).toMatchObject({
|
||||
expect(contexts[0]?.type === 'user/message' && contexts[0].data).toMatchObject({
|
||||
meta,
|
||||
})
|
||||
expect(contexts.flatMap(event => event.type === 'context/message' ? event.data.content : []))
|
||||
expect(contexts.flatMap(event => event.type === 'user/message' ? event.data.content : []))
|
||||
.toEqual([
|
||||
{ type: 'text', text: 'mid-turn notice' },
|
||||
{ type: 'text', text: 'second notice' },
|
||||
@@ -523,7 +523,29 @@ describe('agent loop', () => {
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(agent.session.events.some(event => event.type === 'context/message')).toBe(false)
|
||||
expect(agent.session.events.some(event => event.type === 'user/message' && event.data.source.kind === 'plugin')).toBe(false)
|
||||
})
|
||||
|
||||
it('preserves SendOptions.meta on the durable user/message and steering/message', async () => {
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'noop', {}), textResponse('done')])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'noop', description: '', parameters: {},
|
||||
async execute() {
|
||||
// Running steer carries its own meta onto the durable steering/message.
|
||||
agent.steer([{ type: 'text', text: 's' }], { source: { kind: 'plugin', plugin: 'p' }, meta: { steer: 1 } })
|
||||
return []
|
||||
},
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
agent.followup([{ type: 'text', text: 'go' }], { meta: { prompt: 1 } })
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const user = agent.session.events.find(e => e.type === 'user/message')
|
||||
expect(user?.type === 'user/message' && user.data.meta).toEqual({ prompt: 1 })
|
||||
const steering = agent.session.events.find(e => e.type === 'steering/message')
|
||||
expect(steering?.type === 'steering/message' && steering.data.meta).toEqual({ steer: 1 })
|
||||
})
|
||||
|
||||
it('agent/turn-continuation can force-continue (/loop pattern) and force-stop', async () => {
|
||||
@@ -632,7 +654,7 @@ describe('agent loop', () => {
|
||||
ctx.on('agent/pre-step', (subject) => {
|
||||
if (subject === agent && !injected) {
|
||||
injected = true
|
||||
subject.session.append('context/message', {
|
||||
subject.session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'INJECTED-IN-PRE-STEP' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
}, { surfaceOp: 'append' })
|
||||
@@ -650,7 +672,7 @@ describe('agent loop', () => {
|
||||
// And the injected event sits BEFORE the first step/start in the log —
|
||||
// the seam fired outside the step.
|
||||
const events = agent.session.events
|
||||
const injectedSeq = events.find(e => e.type === 'context/message')!.seq
|
||||
const injectedSeq = events.find(e => e.type === 'user/message' && e.data.source.kind === 'plugin')!.seq
|
||||
const firstStepStartSeq = events.find(e => e.type === 'step/start')!.seq
|
||||
expect(injectedSeq).toBeLessThan(firstStepStartSeq)
|
||||
})
|
||||
@@ -1028,13 +1050,13 @@ describe('agent loop', () => {
|
||||
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('turn-end listener message')
|
||||
})
|
||||
|
||||
it('keeps a reentrant agent/queued send as the next independent turn', async () => {
|
||||
it('keeps a reentrant agent/inbox/enqueue 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(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
let nested = false
|
||||
ctx.on('agent/queued', (subject) => {
|
||||
ctx.on('agent/inbox/enqueue', (subject) => {
|
||||
if (subject !== agent || nested) return
|
||||
nested = true
|
||||
send(agent, 'queued listener message')
|
||||
@@ -1061,9 +1083,9 @@ describe('agent loop', () => {
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
agent.send([{ type: 'text', text: 'user message' }])
|
||||
agent.followup([{ type: 'text', text: 'user message' }])
|
||||
await Promise.resolve()
|
||||
agent.send(
|
||||
agent.followup(
|
||||
[{ type: 'text', text: 'plugin message' }],
|
||||
{ source: { kind: 'plugin', plugin: 'test' } },
|
||||
)
|
||||
|
||||
@@ -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.send([{ type: 'text', text }])
|
||||
for (const text of texts) agent.followup([{ type: 'text', text }])
|
||||
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.send([{ type: 'text', text }])
|
||||
agent.followup([{ type: 'text', text }])
|
||||
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.send([{ type: 'text', text: step.text }])
|
||||
agent.followup([{ type: 'text', text: step.text }])
|
||||
if (step.settle) await idle
|
||||
}
|
||||
await lastIdle
|
||||
|
||||
@@ -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.send([{ type: 'text', text: 'Look up the key "deploy-color" with the lookup tool and tell me the value.' }])
|
||||
agent.followup([{ type: 'text', text: 'Look up the key "deploy-color" with the lookup tool and tell me the value.' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
// Turn 2: a follow-up over the same (longer) prefix.
|
||||
agent.send([{ type: 'text', text: 'Thanks. Repeat that value one more time.' }])
|
||||
agent.followup([{ type: 'text', text: 'Thanks. Repeat that value one more time.' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const usages = [...agent.session.events]
|
||||
|
||||
@@ -41,7 +41,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
}
|
||||
|
||||
function send(agent: Agent, text: string) {
|
||||
agent.send([{ type: 'text', text }])
|
||||
agent.followup([{ type: 'text', text }])
|
||||
}
|
||||
|
||||
/** Assert `previous` is a strict value-prefix of `current`. */
|
||||
@@ -193,7 +193,7 @@ describe('request stability across the loop', () => {
|
||||
preStep()
|
||||
const session = agent.session
|
||||
const nodes = session.surface.nodes
|
||||
session.append('context/message', {
|
||||
session.append('user/message', {
|
||||
content: [{ type: 'text', text: '[summary of turn 1]' }],
|
||||
source: { kind: 'plugin', plugin: 'test-compact' },
|
||||
}, {
|
||||
@@ -255,7 +255,7 @@ describe('request stability across the loop', () => {
|
||||
const first = adapter.requests[0]!
|
||||
// The inject landed in the log after the boundary: not in THIS request…
|
||||
expect(first.messages.some(m => m.content.some(b => b.type === 'text' && b.text.includes('[late context]')))).toBe(false)
|
||||
expect(agent.session.events.some(e => e.type === 'context/message')).toBe(true)
|
||||
expect(agent.session.events.some(e => e.type === 'user/message' && e.data.source.kind === 'plugin')).toBe(true)
|
||||
|
||||
send(agent, 'second')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
@@ -114,7 +114,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
}
|
||||
|
||||
function send(agent: Agent): void {
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
}
|
||||
|
||||
function contextError(message = 'context too large'): LlmError {
|
||||
@@ -154,12 +154,15 @@ describe('agent post-step and request-error lifecycle', () => {
|
||||
const agent = ctx.agentLoop.create(SessionId('post-step-order'), { provider: 'mock', model: 'mock' })
|
||||
const order: string[] = []
|
||||
ctx.on('session/event', (_session, event) => {
|
||||
// Injected context is a plugin-sourced user/message; the direct human
|
||||
// prompt (user source) stays untracked as before.
|
||||
const isInjected = event.type === 'user/message' && event.data.source.kind !== 'user'
|
||||
if (
|
||||
event.type === 'assistant/message' || event.type === 'tool/call'
|
||||
|| event.type === 'tool/result' || event.type === 'context/message'
|
||||
|| event.type === 'tool/result' || isInjected
|
||||
|| event.type === 'steering/message' || event.type === 'step/end'
|
||||
) {
|
||||
if (!('step' in event.data) || event.data.step === 1) order.push(event.type)
|
||||
if (!('step' in event.data) || event.data.step === 1) order.push(isInjected ? 'context/message' : event.type)
|
||||
}
|
||||
})
|
||||
ctx.on('agent/post-step', (subject, turn, step, signal) => {
|
||||
@@ -271,7 +274,7 @@ describe('agent post-step and request-error lifecycle', () => {
|
||||
expect({ turn, step, code: error.code }).toEqual({ turn: 1, step: 1, code: CONTEXT_WINDOW_EXCEEDED_CODE })
|
||||
expect(facts.code).toBe(CONTEXT_WINDOW_EXCEEDED_CODE)
|
||||
attempts.push(history.length)
|
||||
subject.session.append('context/message', {
|
||||
subject.session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'RECOVERY SURFACE MUTATION' }],
|
||||
source: { kind: 'plugin', plugin: 'test-recovery' },
|
||||
}, { surfaceOp: 'append' })
|
||||
@@ -288,7 +291,7 @@ describe('agent post-step and request-error lifecycle', () => {
|
||||
const ends = agent.session.events.filter(event => event.type === 'step/end')
|
||||
expect(starts.map(event => event.data.step)).toEqual([1, 2])
|
||||
expect(ends.map(event => event.data.step)).toEqual([1, 2])
|
||||
const recovery = agent.session.events.find(event => event.type === 'context/message')!
|
||||
const recovery = agent.session.events.find(event => event.type === 'user/message' && event.data.source.kind === 'plugin')!
|
||||
expect(ends[0]!.seq).toBeLessThan(recovery.seq)
|
||||
expect(recovery.seq).toBeLessThan(starts[1]!.seq)
|
||||
})
|
||||
|
||||
@@ -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.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
|
||||
a1.followup([{ 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.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
|
||||
a1.followup([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
|
||||
await waitForIdle(ctx1, a1)
|
||||
await ctx1.fiber.dispose()
|
||||
|
||||
@@ -480,7 +480,7 @@ 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.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
|
||||
a1.followup([{ 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' } })
|
||||
// Let inject()'s fire-and-forget flush settle (NO explicit flush/dispose).
|
||||
@@ -503,7 +503,7 @@ 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.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
|
||||
a1.followup([{ 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' } })
|
||||
await ctx1.sessions.flush(a1.session)
|
||||
@@ -531,7 +531,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.send([{ type: 'text', text: 'first question' }], { source: { kind: 'user' } })
|
||||
a1.followup([{ type: 'text', text: 'first question' }], { source: { kind: 'user' } })
|
||||
await waitForIdle(ctx1, a1)
|
||||
const events1 = [...a1.session.events]
|
||||
const seqs1 = events1.map(e => e.seq)
|
||||
@@ -558,7 +558,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.send([{ type: 'text', text: 'second question' }], { source: { kind: 'user' } })
|
||||
a2.followup([{ 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
|
||||
|
||||
@@ -203,11 +203,11 @@ describe('agent scope lifecycle', () => {
|
||||
if (event.type === 'user/message') heard.push('a-sees:user-message')
|
||||
})
|
||||
|
||||
b.send(text('for b'))
|
||||
b.followup(text('for b'))
|
||||
await waitForIdle(ctx, b)
|
||||
expect(heard).toEqual([]) // nothing of b's leaked into a's scope
|
||||
|
||||
a.send(text('for a'))
|
||||
a.followup(text('for a'))
|
||||
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.send(text('work'))
|
||||
agent.followup(text('work'))
|
||||
await turnOpen
|
||||
await owner.dispose()
|
||||
expect(order).toEqual(['turn-end', 'disposed(listed=false)', 'session-still-stored=true'])
|
||||
|
||||
@@ -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.send([{ type: 'text', text: 'go' }])
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
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.send([{ type: 'text', text: 'go' }])
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
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.send([{ type: 'text', text: 'go' }])
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
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.send([{ type: 'text', text: 'go' }])
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
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.send([{ type: 'text', text: 'go' }])
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
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.send([{ type: 'text', text: 'go' }])
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
await until(() => gated.started.length === 2)
|
||||
gated.release('2'); gated.release('1')
|
||||
await waitForIdle(ctx, agent)
|
||||
@@ -294,7 +294,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.send([{ type: 'text', text: 'go' }])
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
await until(() => gated.started.length === 2)
|
||||
await new Promise(r => setTimeout(r, 5))
|
||||
expect(gated.started).toEqual(['1', '2'])
|
||||
@@ -323,7 +323,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.send([{ type: 'text', text: 'go' }])
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
await until(() => gated.started.length === 1)
|
||||
await new Promise(r => setTimeout(r, 5))
|
||||
expect(gated.started).toEqual(['1'])
|
||||
@@ -349,7 +349,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.send([{ type: 'text', text: 'go' }])
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
await until(() => gated.started.length === 1)
|
||||
await new Promise(r => setTimeout(r, 5))
|
||||
expect(gated.started).toEqual(['1'])
|
||||
@@ -376,7 +376,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.send([{ type: 'text', text: 'go' }])
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
await until(() => gated.started.length === 3)
|
||||
gated.release('3'); gated.release('2'); gated.release('1')
|
||||
await waitForIdle(ctx, agent)
|
||||
@@ -397,17 +397,17 @@ 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.send([{ type: 'text', text: 'go' }])
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
await until(() => gated.started.length === 2)
|
||||
gated.release('2'); gated.release('1')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const log = events(agent)
|
||||
const contextTexts = log.filter(e => e.type === 'context/message')
|
||||
.map(e => (e.data.content[0] as { text: string }).text)
|
||||
const contextTexts = log.filter(e => e.type === 'user/message' && e.data.source.kind === 'plugin')
|
||||
.map(e => ((e.data as { content: { text: string }[] }).content[0]!).text)
|
||||
expect(contextTexts).toEqual(['ctx-c1', 'ctx-c2'])
|
||||
const lastResult = log.findLastIndex(e => e.type === 'tool/result')
|
||||
const firstContext = log.findIndex(e => e.type === 'context/message')
|
||||
const firstContext = log.findIndex(e => e.type === 'user/message' && e.data.source.kind === 'plugin')
|
||||
expect(lastResult).toBeLessThan(firstContext)
|
||||
})
|
||||
|
||||
@@ -435,7 +435,7 @@ describe('tool-call scheduler: ordered middleware and additional contexts', () =
|
||||
})
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
await until(() => gated.started.length === 1)
|
||||
gated.release('1')
|
||||
await waitForIdle(ctx, agent)
|
||||
@@ -465,7 +465,7 @@ describe('tool-call scheduler: abort handling', () => {
|
||||
}
|
||||
})
|
||||
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(gated.started).toEqual([])
|
||||
@@ -497,7 +497,7 @@ describe('tool-call scheduler: abort handling', () => {
|
||||
return next()
|
||||
})
|
||||
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(gated.started).toEqual([])
|
||||
@@ -527,7 +527,7 @@ describe('tool-call scheduler: abort handling', () => {
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
await until(() => gated.started.length === 2)
|
||||
agent.cancel({ kind: 'user' })
|
||||
gated.release('1')
|
||||
@@ -548,10 +548,11 @@ describe('tool-call scheduler: abort handling', () => {
|
||||
{ callId: CallId('c3'), isError: true, errorInfo: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } },
|
||||
{ callId: CallId('c4'), isError: true, errorInfo: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } },
|
||||
])
|
||||
const settled = events(agent).filter(e => e.type === 'tool/result' || e.type === 'context/message')
|
||||
const settled = events(agent).filter(e => e.type === 'tool/result'
|
||||
|| (e.type === 'user/message' && e.data.source.kind === 'plugin'))
|
||||
expect(settled.map(e => e.type))
|
||||
.toEqual(['tool/result', 'tool/result', 'tool/result', 'tool/result', 'context/message', 'context/message'])
|
||||
expect(settled.filter(e => e.type === 'context/message')
|
||||
.toEqual(['tool/result', 'tool/result', 'tool/result', 'tool/result', 'user/message', 'user/message'])
|
||||
expect(settled.filter(e => e.type === 'user/message')
|
||||
.map(e => (e.data.content[0] as { text: string }).text))
|
||||
.toEqual(['ctx-c1', 'ctx-c2'])
|
||||
})
|
||||
@@ -577,7 +578,7 @@ describe('tool-call scheduler: abort handling', () => {
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
await until(() => gated.started.length === 2)
|
||||
agent.cancel({ kind: 'user' })
|
||||
gated.release('1')
|
||||
|
||||
@@ -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.send([{ type: 'text', text: 'go' }])
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
return { ctx, agent, adapter }
|
||||
}
|
||||
@@ -100,7 +100,7 @@ describe('loop-level canonical tool order', () => {
|
||||
const errors: Error[] = []
|
||||
ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
expect(errors.map(e => e.message)).toEqual(['toolOrder lists unregistered tool "ghost"; known tools: alpha'])
|
||||
|
||||
@@ -34,7 +34,7 @@ async function harness(adapter: MockAdapter): Promise<Context> {
|
||||
}
|
||||
|
||||
function send(agent: Agent, text = 'go'): Promise<void> {
|
||||
agent.send([{ type: 'text', text }])
|
||||
agent.followup([{ type: 'text', text }])
|
||||
return agent.whenIdle()
|
||||
}
|
||||
|
||||
@@ -116,7 +116,7 @@ describe('agent/turn-stop', () => {
|
||||
ctx.on('session/flush', (session) => {
|
||||
if (session !== agent.session || queued) return
|
||||
queued = true
|
||||
agent.send([{ type: 'text', text: 'ordinary queued follow-up' }])
|
||||
agent.followup([{ type: 'text', text: 'ordinary queued follow-up' }])
|
||||
})
|
||||
|
||||
await send(agent)
|
||||
|
||||
Reference in New Issue
Block a user