refactor(agent-loop): simplify observable state machine

This commit is contained in:
_Kerman
2026-07-24 21:18:48 +08:00
parent fb0ef82aa6
commit b73eb7663c
131 changed files with 2011 additions and 4292 deletions

View File

@@ -114,7 +114,7 @@ describe('bash tool through the agent loop', () => {
const result = findEvent(events(agent), 'tool/result')
expect(resultText(result)).toBe(`${dshHome}\n1\nsession-env-id\n${location?.path}\nunset\nabsent\n`)
expect(existsSync(location!.path)).toBe(true)
await expect.poll(() => existsSync(location!.path)).toBe(true)
const header = JSON.parse(readFileSync(location!.path, 'utf8').split('\n')[0]!) as { type: string; id: string }
expect(header).toMatchObject({ type: 'session', id: 'session-env-id' })
await handle.dispose()

View File

@@ -8,16 +8,16 @@ This is the implementation tier of the compaction capability — see the [interf
This backend owns the compaction policy:
- **Measurement** — the singleton `ctx.tokenMeter` prices the latest canonical logged envelope and current surface at one consumed-log revision. Post-step pressure therefore includes the actual system prompt, tools, prefix, routing, assistant completion, tool results, buffered context, and steering.
- **Measurement** — the singleton `ctx.tokenMeter` prices the latest canonical logged envelope and current surface at one consumed-log revision. Step-boundary pressure therefore includes the actual system prompt, tools, routing, assistant completion, tool results, buffered context, and steering.
- **Routed policy** — proactive pressure resolves capacity from the adapter that owns the latest durable provider/model route, then scales the default policy plus an optional exact-target override into concrete token budgets. Model discovery remains advisory and is not consulted.
- **Model-free pruning** — after pressure or canonical overflow qualifies, the optional [`ctx.toolResultPrune`](../compact-tool-result-prune/README.md) service rewrites oversized tool results before range selection. Compact-basic remeasures through `ctx.tokenMeter`, skips summarization when pressure becomes safe, and otherwise summarizes the pruned surface. Below-pressure post-step checks never prune.
- **Model-free pruning** — after pressure or canonical overflow qualifies, the optional [`ctx.toolResultPrune`](../compact-tool-result-prune/README.md) service rewrites oversized tool results before range selection. Compact-basic remeasures through `ctx.tokenMeter`, skips summarization when pressure becomes safe, and otherwise summarizes the pruned surface. Below-pressure step checks never prune.
- **Retention** — compact the oldest whole surface units while preserving a recent tail and balanced tool-call/result cuts through the [`dsh-compact` boundary helpers](../compact/README.md#tool-pairing-boundaries). Turn boundaries do not protect old steps inside a runaway turn. An open indivisible tail declines until it closes. The optional pruner can repair an oversized closed tool unit when its text-bearing result is the removable bulk; indivisible non-tool units and non-prunable tool remainders remain out of scope.
- **Convergence** — retry head-checkpoint compaction up to `compactionRetries`; reject a summary that does not shrink its source, and throw if retries cannot return below threshold.
- **Summarization** — a direct `llm/stream` call uses the configured provider/model pair and cap, falling back to the latest logged request target and then the agent target, without running the loop-only `agent/request` seam. The call replays the conversation's own system prompt, tools, and shadowed-region messages verbatim and appends the compaction instruction as the final user message, so it reuses the provider's warm prefix cache instead of invalidating it. It sets `GenerateOptions.purpose` to `compaction`, which adapters may forward as request attribution (the DeepSeek adapter sends `x-deepseek-harness-compact: 1`) without touching the model-visible body. Only returned text enters the checkpoint, excluding reasoning and tool calls that would leak private reasoning or create an orphaned call.
- **Framing** — the replacement user message marks established checkpoint context with `<compacted-summary>` tags. The raw summary remains on the provenance event, and later automatic cycles merge the prior checkpoint.
- **Lifecycle** — `compactRegion()` mutates `agent.session` and records its start, summary, replacement, and end. After asynchronous summarization it rejects a changed surface-node snapshot, while unrelated log-only events may append without invalidating the selected span. The serial `agent/post-step` listener checks pressure after successful output and tool work are durable but before `step/end`. Canonical provider overflow is handled through `agent/request-error` after the failed step closes.
- **Lifecycle** — `compactRegion()` mutates `agent.session` and records its start, summary, replacement, and end. After asynchronous summarization it rejects a changed surface-node snapshot, while unrelated log-only events may append without invalidating the selected span. The serial `agent/step` listener checks pressure before request derivation. A canonical provider overflow is offered through `agent/request-error` after the failed step; the plugin compacts there and calls `agent.retry()` only after durable surface progress.
- **Overflow recovery** — provider-confirmed overflow needs no capacity metadata: it bypasses normal pressure and retention, prunes, then attempts one maximal balanced head reduction while leaving the newest indivisible unit. Retry is authorized whenever `surface.replaceGeneration` advances, including when pruning lands before later summary work throws. No replacement, an exhausted target-specific cap, cancellation, or an unknown/noncanonical error preserves the original provider failure.
- **Failure handling** — an unmatched `compact/start` is an inert crash marker because no summary replacement landed. A region failure records an error end; the surface remains unchanged unless pruning already landed. Operational post-step failures warn and continue, while overflow-recovery failure preserves the original provider error only when no earlier replacement advanced the surface. Cancellation remains authoritative after any progress.
- **Failure handling** — an unmatched `compact/start` is an inert crash marker because no summary replacement landed. A region failure records an error end; the surface remains unchanged unless pruning already landed. Operational pressure failures warn and continue, while overflow-recovery failure preserves the original provider error only when no earlier replacement advanced the surface. Cancellation remains authoritative after any progress.
The protected `summarize()` method is the sole subclass hook. A template- or remote-summarizer subclass can override it while pressure, retention, provenance, shrink validation, and shadowed-token accounting stay on `ctx.tokenMeter`. The hook returns the summary blocks together with the call envelope it used (`{ summary, provider, model, maxTokens? }`), which is logged on `compact/summary`.
@@ -36,7 +36,7 @@ Every setting is optional. Top-level policy fields are defaults for every routed
| `compactionRetries` | no (default `1`) | Extra attempts after the first when pressure remains above threshold. |
| `maxOverflowRetries` | no (default `1`) | Maximum retries after canonical context-window overflow; `0` disables recovery only. |
| `modelPolicies` | no (default `[]`) | Exact `{ provider, model, ...partialPolicy }` overrides; matching uses both fields and does not depend on `listModels()`. |
| `auto` | no (default `true`) | Register post-step pressure and overflow-recovery listeners. Set `false` for manual-only. |
| `auto` | no (default `true`) | Register step-boundary pressure and overflow-recovery listeners. Set `false` for manual-only. |
Every `modelPolicies` entry accepts the policy fields above except `auto` and `modelPolicies` itself. If an entry supplies either retention field, it replaces the default policy's retention choice; otherwise retention is inherited. Summarization provider/model remain a pair inside each entry.

View File

@@ -111,6 +111,7 @@ export class BasicCompactService extends CompactService {
readonly config: ResolvedConfig
private readonly warnedPressureConfigTargets = new Set<string>()
private readonly overflowRetries = new WeakMap<Agent, number>()
constructor(ctx: Context, config: BasicCompactConfig = {}) {
super(ctx)
@@ -119,8 +120,8 @@ export class BasicCompactService extends CompactService {
}
/**
* Register the automatic post-step pressure and context-overflow recovery
* listeners. `compactIfNeeded` stays dynamically dispatched so subclass
* Register automatic between-step pressure and model-request overflow
* recovery. `compactIfNeeded` stays dynamically dispatched so subclass
* overrides are honored at event time.
*/
private _registerAutomaticCompaction(): void {
@@ -133,7 +134,7 @@ export class BasicCompactService extends CompactService {
)
}
ctx.on('agent/post-step', async (
ctx.on('agent/step', async (
agent: Agent,
_turn: number,
_step: number,
@@ -142,35 +143,36 @@ export class BasicCompactService extends CompactService {
if (signal.aborted) return
try {
const result = await this.compactIfNeeded(agent, 'pressure', signal)
if (result !== null) logResult(result, 'post-step pressure')
if (result !== null) logResult(result, 'step pressure')
} catch (error: unknown) {
if (error instanceof TargetPressureConfigError) {
if (this.warnedPressureConfigTargets.has(error.targetKey)) return
this.warnedPressureConfigTargets.add(error.targetKey)
}
const message = error instanceof Error ? error.message : String(error)
ctx.logger.warn(`post-step compaction failed: ${message}; continuing the turn`)
ctx.logger.warn(`step compaction failed: ${message}; continuing the turn`)
}
})
ctx.on('agent/idle', (agent) => {
this.overflowRetries.delete(agent)
})
ctx.on('agent/request-error', async (
agent,
_turn,
_step,
_error,
failure,
priorFailures,
signal,
next,
) => {
const priorOverflowFailures = priorFailures.filter(
item => item.code === CONTEXT_WINDOW_EXCEEDED_CODE,
).length
if (failure.code !== CONTEXT_WINDOW_EXCEEDED_CODE || signal.aborted) return next()
const target = routedTarget(agent.session)
if (target === undefined) return next()
const policy = resolveTargetPolicy(this.config, target)
if (priorOverflowFailures >= policy.maxOverflowRetries) return next()
const retries = this.overflowRetries.get(agent) ?? 0
if (retries >= policy.maxOverflowRetries) return next()
const generation = agent.session.surface.replaceGeneration
let result: CompactionResult | null
@@ -181,27 +183,30 @@ export class BasicCompactService extends CompactService {
// A model-free prune can land before later summary work fails. That
// durable reduction is sufficient retry proof; do not discard it just
// because the optional second phase threw. Cancellation still wins.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- signal can abort while recovery is awaited.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- the signal can abort while recovery is awaited.
if (!signal.aborted && agent.session.surface.replaceGeneration > generation) {
ctx.logger.warn(
`context-overflow compaction failed after durable surface progress: ${message}; `
+ 'retrying from the replacement surface',
)
return { action: 'retry' }
this.overflowRetries.set(agent, retries + 1)
agent.retry()
return
}
ctx.logger.warn(
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- signal can abort while recovery is awaited.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- the signal can abort while recovery is awaited.
`context-overflow compaction failed: ${message}; ${signal.aborted
? 'cancellation prevents retry'
: 'preserving the original request error'}`,
)
return next()
}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- signal can abort while compaction is awaited.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- the signal can abort while compaction is awaited.
if (signal.aborted
|| agent.session.surface.replaceGeneration <= generation) return next()
if (result !== null) logResult(result, 'context overflow recovery')
return { action: 'retry' }
this.overflowRetries.set(agent, retries + 1)
agent.retry()
})
}
@@ -228,12 +233,12 @@ export class BasicCompactService extends CompactService {
}
/**
* Compact for replayed post-step pressure or one provider-confirmed context
* Compact for replayed step-boundary pressure or one provider-confirmed context
* overflow. Both triggers price the latest durable routed request envelope;
* overflow bypasses the normal threshold and retained-tail policy so it can
* force one useful balanced reduction.
* @param agent - agent whose latest durable routed request is measured.
* @param trigger - normal post-step pressure or context-overflow recovery.
* @param trigger - normal step-boundary pressure or context-overflow recovery.
* @param signal - live turn cancellation signal forwarded to summarization.
* @returns the latest summary compaction result, or `null` when no summary ran.
*/

View File

@@ -38,7 +38,7 @@ export interface ModelCompactPolicyConfig extends CompactPolicyConfig {
export interface BasicCompactConfig extends CompactPolicyConfig {
/** Exact provider/model overrides; duplicate targets fail plugin load. */
modelPolicies?: ModelCompactPolicyConfig[]
/** Enable automatic post-step pressure and overflow-recovery listeners. Defaults to `true`. */
/** Enable automatic step-boundary pressure and overflow-recovery listeners. Defaults to `true`. */
auto?: boolean
}

View File

@@ -66,7 +66,11 @@ function createContext(contextWindow = 1_000): Context {
}
function agent(session: Session, model?: string): Agent {
return { session, options: model === undefined ? {} : { provider: model, model } } as Agent
return {
session,
options: model === undefined ? {} : { provider: model, model },
retry() {},
} as Agent
}
/** Flatten every text fragment the summarizer received, recursing tool-result blocks. */
@@ -1263,22 +1267,23 @@ describe('default one-shot summarizer', () => {
describe('automatic listener and loader composition', () => {
function postStep(ctx: Context, owner: Agent, signal = SIGNAL): Promise<unknown> {
return agentEvents(ctx, owner).serial('agent/post-step', 1, 1, signal)
return agentEvents(ctx, owner).serial('agent/step', 1, 1, signal)
}
function recover(
ctx: Context,
owner: Agent,
error: Error & { code?: string },
retryAttempt = 0,
signal = SIGNAL,
next: () => Promise<{ action: 'fail' | 'retry' }> = () => Promise.resolve({ action: 'fail' }),
): Promise<{ action: 'fail' | 'retry' }> {
next: () => Promise<void> = () => Promise.resolve(),
): Promise<boolean> {
const failure: LlmFailure = { message: error.message, code: error.code ?? 'UNKNOWN' }
const priorFailures = Object.freeze(Array.from({ length: retryAttempt }, () => failure))
const turn = owner.session.events.findLast(event => event.type === 'turn/start')?.data.turn ?? 1
let retried = false
owner.retry = () => { retried = true }
return agentEvents(ctx, owner).waterfall(
'agent/request-error', 1, 1, error, failure, priorFailures, signal, next,
)
'agent/request-error', turn, 1, error, failure, signal, next,
).then(() => retried)
}
function overflow(message = 'provider overflow'): Error & { code: string } {
@@ -1383,7 +1388,7 @@ describe('automatic listener and loader composition', () => {
expect(ctx.tokenMeter.measure(session).totalTokens).toBeLessThan(threshold)
const decision = await recover(ctx, agent(session, 'unconfigured-agent-fallback'), overflow())
expect(decision).toEqual({ action: 'retry' })
expect(decision).toBe(true)
expect(session.surface.replaceGeneration).toBe(beforeGeneration + 1)
expect(session.events.some(event => event.type === 'compact/summary')).toBe(true)
expect(session.surface.nodes).toContain(retainedSeq)
@@ -1402,7 +1407,7 @@ describe('automatic listener and loader composition', () => {
})
const session = oversizedToolResult()
expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'retry' })
expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(true)
expect(session.surface.replaceGeneration).toBe(1)
expect(session.events.some(event => event.type === 'compact/summary')).toBe(false)
expect(compact.calls).toHaveLength(0)
@@ -1421,7 +1426,7 @@ describe('automatic listener and loader composition', () => {
})
const session = toolConversation()
expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'retry' })
expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(true)
expect(session.events.some(event => event.type === 'compact/summary')).toBe(true)
expect(compact.calls).toHaveLength(1)
expect(summarizedText(compact.calls[0]!.input)).toContain('tool result middle pruned')
@@ -1443,7 +1448,7 @@ describe('automatic listener and loader composition', () => {
compact.error = new Error('summary unavailable after prune')
const session = oversizedToolResult(3_000, true)
expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'retry' })
expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(true)
expect(session.surface.replaceGeneration).toBe(1)
expect(session.events.filter(event => event.type === 'tool/result')).toHaveLength(2)
expect(session.events.findLast(event => event.type === 'compact/end')?.data)
@@ -1467,8 +1472,7 @@ describe('automatic listener and loader composition', () => {
compact.error = new Error('summary cancelled after prune')
const session = oversizedToolResult(3_000, true)
expect(await recover(ctx, agent(session, MODEL), overflow(), 0, controller.signal))
.toEqual({ action: 'fail' })
expect(await recover(ctx, agent(session, MODEL), overflow(), controller.signal)).toBe(false)
expect(session.surface.replaceGeneration).toBe(1)
})
@@ -1482,7 +1486,7 @@ describe('automatic listener and loader composition', () => {
const newestAssistant = session.surface.nodes.at(-2)!
const newestResult = session.surface.nodes.at(-1)!
expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'retry' })
expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(true)
const currentAssistant = session.surface.nodes.find(node => node === newestAssistant)
const currentResult = session.surface.nodes.find(node => node === newestResult)
expect(currentAssistant).toBeDefined()
@@ -1506,7 +1510,7 @@ describe('automatic listener and loader composition', () => {
}
vi.spyOn(compact, 'compactIfNeeded').mockResolvedValue(fakeResult)
expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'fail' })
expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(false)
expect(session.surface.replaceGeneration).toBe(0)
})
@@ -1521,7 +1525,6 @@ describe('automatic listener and loader composition', () => {
ctx,
agent(conversation(2), MODEL),
overflow(),
0,
SIGNAL,
() => {
calls += 1
@@ -1539,7 +1542,7 @@ describe('automatic listener and loader composition', () => {
compact.error = new Error('summary unavailable')
const original = overflow('original provider overflow')
expect(await recover(ctx, agent(conversation(3), MODEL), original)).toEqual({ action: 'fail' })
expect(await recover(ctx, agent(conversation(3), MODEL), original)).toBe(false)
expect(original).toMatchObject({
message: 'original provider overflow',
code: CONTEXT_WINDOW_EXCEEDED_CODE,
@@ -1558,12 +1561,12 @@ describe('automatic listener and loader composition', () => {
const original = overflow('original provider failure')
let delegations = 0
const decision = await recover(ctx, agent(session, MODEL), original, 0, SIGNAL, () => {
const decision = await recover(ctx, agent(session, MODEL), original, SIGNAL, () => {
delegations += 1
return Promise.resolve({ action: 'fail' })
return Promise.resolve()
})
expect(decision).toEqual({ action: 'fail' })
expect(decision).toBe(false)
expect(delegations).toBe(1)
expect(session.surface.replaceGeneration).toBe(generation)
expect(original).toMatchObject({
@@ -1582,7 +1585,7 @@ describe('automatic listener and loader composition', () => {
reason: 'resume',
})
expect(await recover(ctx, agent(session, MODEL), overflow('unlisted-model overflow')))
.toEqual({ action: 'retry' })
.toBe(true)
})
it('delegates canonical overflow when no durable routed target exists', async () => {
@@ -1594,21 +1597,19 @@ describe('automatic listener and loader composition', () => {
trigger: { kind: 'message', source: { kind: 'user' } },
})
await expect(recover(ctx, agent(session, MODEL), overflow())).resolves.toEqual({ action: 'fail' })
await expect(recover(ctx, agent(session, MODEL), overflow())).resolves.toBe(false)
})
it('honors retry caps, non-context failures, and cancellation', async () => {
it('honors retry caps and ignores non-context failures', async () => {
const ctx = createContext()
const compact = new TestCompactService(ctx, { maxOverflowRetries: 1 })
const compactSpy = vi.spyOn(compact, 'compactIfNeeded')
const owner = agent(conversation(3), MODEL)
expect(await recover(ctx, owner, Object.assign(new Error('rate limit'), { code: 'RATE_LIMIT' })))
.toEqual({ action: 'fail' })
expect(await recover(ctx, owner, overflow(), 1)).toEqual({ action: 'fail' })
const controller = new AbortController()
controller.abort('cancelled')
expect(await recover(ctx, owner, overflow(), 0, controller.signal)).toEqual({ action: 'fail' })
.toBe(false)
expect(await recover(ctx, owner, overflow())).toBe(true)
compactSpy.mockClear()
expect(await recover(ctx, owner, overflow())).toBe(false)
expect(compactSpy).not.toHaveBeenCalled()
})
@@ -1623,9 +1624,11 @@ describe('automatic listener and loader composition', () => {
}],
})
const compactSpy = vi.spyOn(compact, 'compactIfNeeded')
const owner = agent(conversation(3), MODEL)
expect(await recover(ctx, agent(conversation(3), MODEL), overflow(), 1))
.toEqual({ action: 'fail' })
expect(await recover(ctx, owner, overflow())).toBe(true)
compactSpy.mockClear()
expect(await recover(ctx, owner, overflow())).toBe(false)
expect(compactSpy).not.toHaveBeenCalled()
})
@@ -1637,8 +1640,7 @@ describe('automatic listener and loader composition', () => {
const session = conversation(3)
const generation = session.surface.replaceGeneration
expect(await recover(ctx, agent(session, MODEL), overflow(), 0, controller.signal))
.toEqual({ action: 'fail' })
expect(await recover(ctx, agent(session, MODEL), overflow(), controller.signal)).toBe(false)
expect(session.surface.replaceGeneration).toBe(generation + 1)
})
@@ -1653,7 +1655,7 @@ describe('automatic listener and loader composition', () => {
await postStep(ctx, agent(session, MODEL))
const summaries = session.events.filter(event => event.type === 'compact/summary').length
expect(summaries).toBe(1)
expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'fail' })
expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(false)
expect(session.events.filter(event => event.type === 'compact/summary')).toHaveLength(summaries)
})
@@ -1667,7 +1669,7 @@ describe('automatic listener and loader composition', () => {
const session = conversation(4)
await postStep(ctx, agent(session, MODEL))
expect(session.events.some(event => event.type === 'compact/start')).toBe(false)
expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'fail' })
expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(false)
})
it('loads and disposes the real zero-config service stack', async () => {
@@ -1696,6 +1698,6 @@ describe('automatic listener and loader composition', () => {
const session = conversation(4)
await postStep(ctx, agent(session, MODEL))
expect(session.events.some(event => event.type === 'compact/start')).toBe(false)
expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'fail' })
expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(false)
})
})

View File

@@ -15,7 +15,7 @@ import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic'
import TokenMeterService from '@deepseek-ai/dsh-token-meter'
import * as LlmRetry from '@deepseek-ai/dsh-llm-retry'
import { SessionId, type SurfaceEvent } from '@deepseek-ai/dsh-session'
import { Session, SessionId, type SessionEvent, type SurfaceEvent } from '@deepseek-ai/dsh-session'
/**
* CBR-001 regression through the real loop. A replacement checkpoint has a high
@@ -165,33 +165,37 @@ function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
})
}
function seedOverflowHistory(agent: Agent): void {
function overflowHistorySeed(): SessionEvent[] {
const session = new Session(SessionId('overflow-history-seed'))
for (let turn = 1; turn <= 2; turn += 1) {
const sentinel = turn === 1 ? 'OLD HISTORY SENTINEL' : 'RECENT HISTORY'
agent.session.append('turn/start', {
session.append('turn/start', {
turn,
trigger: { kind: 'message', source: { kind: 'user' } },
})
agent.session.append('user/message', {
session.append('user/message', {
content: [{ type: 'text', text: `${sentinel} ${'old context '.repeat(200)}` }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
agent.session.append('step/start', { turn, step: 1 })
agent.session.append('assistant/message', {
session.append('step/start', { turn, step: 1 })
session.append('assistant/message', {
provenance: { provider: 'mock', model: 'mock' },
turn,
step: 1,
content: [{ type: 'text', text: `historical response ${turn} ${'detail '.repeat(200)}` }],
}, { surfaceOp: 'append' })
agent.session.append('step/end', { turn, step: 1 })
agent.session.append('turn/end', { turn, reason: { kind: 'completed' } })
session.append('step/end', { turn, step: 1 })
session.append('turn/end', { turn, reason: { kind: 'completed' } })
}
return [...session.events]
}
describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', () => {
it('uses the model actually routed by agent/request for post-step pressure', async () => {
const { ctx } = await harness(8)
ctx.on('agent/request', async (_agent, _turn, _step, config) => ({ ...config, provider: 'mock', model: 'mock' }))
ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => ({
...await next(), provider: 'mock', model: 'mock',
}))
try {
const agent = ctx.agentLoop.create(SessionId('routed-pressure'), {
provider: 'unconfigured-agent-fallback',
@@ -211,7 +215,7 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', ()
}
})
it('runs automatic pressure after the current tool result and before step/end', async () => {
it('runs automatic pressure between the completed tool step and the next step', async () => {
const { ctx } = await harness(8)
try {
const agent = ctx.agentLoop.create(SessionId('post-step-order'), { provider: 'mock', model: 'mock' })
@@ -225,13 +229,19 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', ()
event.type === 'tool/result' && event.seq < compactStart!.seq,
)
if (precedingResult?.type !== 'tool/result') throw new Error('expected a durable tool result before compaction')
const stepEnd = events.find(event =>
const precedingStepEnd = events.find(event =>
event.type === 'step/end'
&& event.data.step === precedingResult.data.step
&& event.seq > precedingResult.seq,
)
const nextStepStart = events.find(event =>
event.type === 'step/start'
&& event.data.step === precedingResult.data.step + 1
&& event.seq > compactStart!.seq,
)
expect(precedingResult.seq).toBeLessThan(compactStart!.seq)
expect(compactStart!.seq).toBeLessThan(stepEnd!.seq)
expect(precedingStepEnd!.seq).toBeLessThan(compactStart!.seq)
expect(compactStart!.seq).toBeLessThan(nextStepStart!.seq)
} finally {
await ctx.fiber.dispose()
}
@@ -281,7 +291,9 @@ describe('context-overflow recovery across the real loop and compact-basic', ()
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(TokenMeterService)
ctx.llm.registerAdapter(['mock'], adapter)
ctx.on('agent/request', async (_agent, _turn, _step, config) => ({ ...config, provider: 'mock', model: 'mock' }))
ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => ({
...await next(), provider: 'mock', model: 'mock',
}))
await ctx.plugin(BasicCompactService, {
thresholdRatio: 1,
retainTokens: 100,
@@ -291,11 +303,14 @@ describe('context-overflow recovery across the real loop and compact-basic', ()
})
try {
const agent = ctx.agentLoop.create(SessionId(`overflow-${delivery}`), {
provider: 'unconfigured-agent-fallback',
model: 'unconfigured-agent-fallback',
const { agent } = await ctx.agentLoop.createAgent(ctx, {
sessionId: SessionId(`overflow-${delivery}`),
seed: overflowHistorySeed(),
agentOptions: {
provider: 'unconfigured-agent-fallback',
model: 'unconfigured-agent-fallback',
},
})
seedOverflowHistory(agent)
agent.followup([{ type: 'text', text: 'continue from history' }])
await agent.whenIdle()
@@ -308,11 +323,17 @@ describe('context-overflow recovery across the real loop and compact-basic', ()
expect(retry).not.toContain('OLD HISTORY SENTINEL')
const events = [...agent.session.events]
const failedEnd = events.find(event =>
const failedStepEnd = events.find(event =>
event.type === 'step/end' && event.data.turn === 3 && event.data.step === 1,
)!
const failedEnd = events.find(event =>
event.type === 'turn/end' && event.data.turn === 3,
)!
const retryStart = events.find(event =>
event.type === 'step/start' && event.data.turn === 3 && event.data.step === 2,
event.type === 'turn/start' && event.data.turn === 4,
)!
const retryStep = events.find(event =>
event.type === 'step/start' && event.data.turn === 4 && event.data.step === 1,
)!
const compaction = events.filter(event =>
event.type === 'compact/start'
@@ -324,7 +345,11 @@ describe('context-overflow recovery across the real loop and compact-basic', ()
'compact/summary',
'compact/end',
])
expect(compaction.every(event => event.seq > failedEnd.seq && event.seq < retryStart.seq)).toBe(true)
expect(retryStart.seq).toBeGreaterThan(failedEnd.seq)
expect(compaction.every(event =>
event.seq > failedStepEnd.seq && event.seq < failedEnd.seq,
)).toBe(true)
expect(retryStep.seq).toBeGreaterThan(retryStart.seq)
expect(events.at(-1)).toMatchObject({
type: 'turn/end',
data: { reason: { kind: 'completed' } },
@@ -358,17 +383,21 @@ describe('context-overflow recovery across the real loop and compact-basic', ()
})
try {
const agent = ctx.agentLoop.create(SessionId('alternating-recovery'), { provider: 'mock', model: 'mock' })
seedOverflowHistory(agent)
const { agent } = await ctx.agentLoop.createAgent(ctx, {
sessionId: SessionId('alternating-recovery'),
seed: overflowHistorySeed(),
agentOptions: { provider: 'mock', model: 'mock' },
})
agent.followup([{ type: 'text', text: 'continue from history' }])
await expect.poll(() => adapter.conversationRequests.length).toBe(3)
await agent.whenIdle()
expect(adapter.conversationRequests).toHaveLength(3)
expect(adapter.summaryRequests).toHaveLength(1)
expect(agent.session.events.filter(event => event.type === 'llm/retry').map(event => event.data))
.toEqual([expect.objectContaining({ step: 2, retry: 1, failure: { message: 'temporary provider outage', code: 'SERVER' } })])
expect(agent.session.events.filter(event => event.type === 'step/start').slice(-3).map(event => event.data.step))
.toEqual([1, 2, 3])
.toEqual([expect.objectContaining({ turn: 4, step: 1, retry: 1, failure: { message: 'temporary provider outage', code: 'SERVER' } })])
expect(agent.session.events.filter(event => event.type === 'turn/start').slice(-3).map(event => event.data.turn))
.toEqual([3, 4, 5])
expect(agent.session.events.at(-1)).toMatchObject({
type: 'turn/end',
data: { reason: { kind: 'completed' } },

View File

@@ -43,7 +43,6 @@ function sessionAgent(session: Session, id = 'agent'): Agent {
status: 'running',
ctx: new Context(),
followup: () => AgentMessageId('stub'),
queue: () => AgentMessageId('stub'),
steer: () => AgentMessageId('stub'),
inject(content, options) {
session.append('user/message', {
@@ -54,6 +53,7 @@ function sessionAgent(session: Session, id = 'agent'): Agent {
},
send: () => AgentMessageId('stub'),
cancel() {},
retry() {},
whenIdle: () => Promise.resolve(),
}
}
@@ -85,7 +85,7 @@ async function fire(
step: number,
signal: AbortSignal = SIGNAL,
): Promise<void> {
await agentEvents(ctx, agent).serial('agent/pre-step', turn, step, signal)
await agentEvents(ctx, agent).serial('agent/step', turn, step, signal)
}
function textResponse(text: string): StreamChunk[] {
@@ -294,7 +294,7 @@ describe('durable step context', () => {
const agent = sessionAgent(session)
openMessageTurn(session, 1)
let ordinarySawContext = false
ctx.on('agent/pre-step', (subject) => {
ctx.on('agent/step', (subject) => {
ordinarySawContext = subject.session.events.some(event => event.type === 'user/message')
})
@@ -363,11 +363,11 @@ describe('real agent-loop request history', () => {
it.each([
['throws', 'error'],
['cancels', 'aborted'],
] as const)('retains the preparation reading when a later pre-step listener %s', async (mode, reasonKind) => {
] as const)('discards the pending preparation reading when a later step listener %s', async (mode, reasonKind) => {
const adapter = new ScriptedAdapter([textResponse('unused')])
const ctx = await loopHarness(adapter)
let laterSawReading = false
ctx.on('agent/pre-step', (subject) => {
ctx.on('agent/step', (subject) => {
laterSawReading = contextTexts(subject.session).length === 1
if (mode === 'throws') throw new Error('later pre-step failure')
subject.cancel({ kind: 'user' })
@@ -377,8 +377,8 @@ describe('real agent-loop request history', () => {
agent.followup([{ type: 'text', text: 'start' }])
await agent.whenIdle()
expect(laterSawReading).toBe(true)
expect(contextTexts(agent.session)).toHaveLength(1)
expect(laterSawReading).toBe(false)
expect(contextTexts(agent.session)).toHaveLength(0)
expect(adapter.requests).toHaveLength(0)
expect(agent.session.events.some(event => event.type === 'step/start')).toBe(false)
const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end')

View File

@@ -108,7 +108,9 @@ function filePathFromExecution(exec: ToolExecution): string | undefined {
return filePath.length > 0 ? filePath : undefined
}
function isWorkspaceContextSource(source: unknown): source is WorkspaceInstructionSource {
function isWorkspaceContextSource(
source: unknown,
): source is { kind: 'workspace-instructions'; changes: unknown[] } {
return typeof source === 'object' && source !== null
&& 'kind' in source && source.kind === 'workspace-instructions'
&& 'changes' in source && Array.isArray(source.changes)

View File

@@ -7,7 +7,7 @@ import Loader from '@cordisjs/plugin-loader'
import * as workspaceContext from '@deepseek-ai/dsh-workspace-context'
import LlmService, { CallId, type Message, type StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionId, SESSION_FORMAT_VERSION, type SessionEvent } from '@deepseek-ai/dsh-session'
import AgentRegistry, { AgentMessageId, type AdditionalContext, type Agent } from '@deepseek-ai/dsh-agent'
import AgentRegistry, { agentEvents, AgentMessageId, type AdditionalContext, type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { FileSystem, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
import type {
@@ -178,7 +178,6 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent {
session,
status: 'idle',
followup: () => AgentMessageId('stub'),
queue: () => AgentMessageId('stub'),
steer: () => AgentMessageId('stub'),
inject(content, options) {
session.append('user/message', {
@@ -189,6 +188,7 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent {
},
send: () => AgentMessageId('stub'),
cancel() {},
retry() {},
whenIdle: () => Promise.resolve(),
}
}
@@ -233,11 +233,8 @@ function appendAdditionalContexts(agent: Agent, result: { additionalContexts?: A
const composedPrefixes = new WeakMap<object, Message[]>()
async function composeBaselinePrefix(ctx: Context, agent: Agent): Promise<Message[]> {
const empty: Message[] = []
const prefix = await ctx.waterfall(
'agent/session-prefix', agent, empty, AbortSignal.timeout(1000),
() => Promise.resolve(empty),
)
await agentEvents(ctx, agent).serial('agent/step', 1, 1, AbortSignal.timeout(1000))
const prefix = agent.session.deriveMessages()
composedPrefixes.set(agent, prefix)
return prefix
}
@@ -936,7 +933,7 @@ describe('workspace context request injection', () => {
}
})
it('contributes baseline instructions through the frozen session prefix instead of durable history', async () => {
it('contributes baseline instructions through durable injected history', async () => {
const root = await tempRepo()
const home = await tempRepo()
try {
@@ -948,7 +945,9 @@ describe('workspace context request injection', () => {
await composeBaselinePrefix(ctx, agent)
expect(agent.session.deriveMessages()).toEqual([])
expect(agent.session.events.filter(event =>
event.type === 'user/message' && event.data.source.kind !== 'user',
)).toHaveLength(1)
expect(composedPrefixes.get(agent)).toHaveLength(1)
expect(derivedText(agent)).toContain('<system-reminder>')
expect(derivedText(agent)).toContain('Instructions from: AGENTS.md')
@@ -961,7 +960,7 @@ describe('workspace context request injection', () => {
}
})
it('returns one baseline contribution per session-prefix composition without appending context events', async () => {
it('injects one durable baseline contribution on the first step only', async () => {
const root = await tempRepo()
const home = await tempRepo()
try {
@@ -975,7 +974,7 @@ describe('workspace context request injection', () => {
const second = await composeBaselinePrefix(ctx, agent)
expect(second).toEqual(first)
expect(agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toHaveLength(0)
expect(agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toHaveLength(1)
expect(derivedText(agent)).toContain('repo rule')
} finally {
await rm(root, { recursive: true, force: true })
@@ -1005,7 +1004,7 @@ describe('workspace context request injection', () => {
}
})
it('places workspace instructions before later session-prefix contributors such as a skills catalog', async () => {
it('places workspace instructions before later step contributors such as a skills catalog', async () => {
const root = await tempRepo()
const home = await tempRepo()
try {
@@ -1013,9 +1012,10 @@ describe('workspace context request injection', () => {
await write(join(root, 'AGENTS.md'), 'repo rule')
const ctx = new Context()
await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next) => {
const rest = await next()
return [{ role: 'user', content: [{ type: 'text', text: '<system-reminder>Available skills</system-reminder>' }] }, ...rest]
ctx.on('agent/step', (agent) => {
agent.inject([{ type: 'text', text: '<system-reminder>Available skills</system-reminder>' }], {
source: { kind: 'plugin', plugin: 'test-skills' },
})
})
const prefix = await composeBaselinePrefix(ctx, stubAgent(root))
@@ -1147,7 +1147,9 @@ describe('workspace context request injection', () => {
await composeBaselinePrefix(ctx, agent)
expect(agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toHaveLength(0)
expect(agent.session.events.filter(event =>
event.type === 'user/message' && event.data.source.kind !== 'user',
)).toHaveLength(1)
expect(derivedText(agent)).not.toContain('workspace-context:')
} finally {
await rm(root, { recursive: true, force: true })
@@ -1270,7 +1272,7 @@ describe('workspace context request injection', () => {
}
})
it('aborts an in-flight baseline stream with the session-prefix signal', async () => {
it('aborts an in-flight baseline stream with the step signal', async () => {
const root = join(await tempRepo(), 'virtual-repo')
const home = join(await tempRepo(), 'virtual-home')
const ctx = new Context()
@@ -1282,11 +1284,7 @@ describe('workspace context request injection', () => {
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
const controller = new AbortController()
const reason = new Error('cancel prefix')
const empty: Message[] = []
const pending = ctx.waterfall(
'agent/session-prefix', stubAgent(root), empty, controller.signal,
() => Promise.resolve(empty),
)
const pending = agentEvents(ctx, stubAgent(root)).serial('agent/step', 1, 1, controller.signal)
await fs.started.promise
controller.abort(reason)
@@ -1715,14 +1713,16 @@ describe('dynamic nested workspace context injection', () => {
agent.followup([{ type: 'text', text: 'read and abort' }])
await agent.whenIdle()
expect(agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toHaveLength(1)
expect(agent.session.events.filter(event =>
event.type === 'user/message' && event.data.source.kind !== 'user',
)).toHaveLength(0)
agent.followup([{ type: 'text', text: 'retry the read' }])
await agent.whenIdle()
const contexts = agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')
// The aborted batch drained its accepted context before step close, so the
// retry sees durable history without producing a duplicate instruction.
// Cancellation discards the aborted step's pending context. The next
// successful read discovers and durably injects it once.
expect(contexts).toHaveLength(1)
expect(adapter.requests).toHaveLength(3)
expect(adapter.requests[2]?.messages.map(blocks => blocksText(blocks.content)).join('\n'))

View File

@@ -861,7 +861,7 @@ export const EVENT_API: readonly EventApiEntry[] = [
name: 'agent/cancel-requested',
mode: 'emit',
signature: '\'agent/cancel-requested\'(this: Scoped<Agent>, agent: Agent, cause: AgentCancelCause): void',
jsDoc: '/**\n * Effective broad cancellation was requested, before queued/outbox work\n * is cleared or the active turn is aborted. This observe-only notification\n * cannot veto cancellation; listener failures are contained.\n * @param agent - the agent whose current work is being cancelled.\n * @param cause - resolved typed cancellation cause, including the default.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
jsDoc: '/**\n * Effective broad cancellation was requested, before queued/outbox work\n * is cleared or the active turn is aborted. This observe-only notification\n * cannot veto cancellation; listener failures are contained.\n * @param agent - the agent whose current work is being cancelled.\n * @param cause - the explicit typed cancellation cause.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
summary: 'Effective broad cancellation was requested, before queued/outbox work is cleared or the active turn is aborted.',
},
{
@@ -875,8 +875,8 @@ export const EVENT_API: readonly EventApiEntry[] = [
name: 'agent/disposed',
mode: 'emit',
signature: '\'agent/disposed\'(this: Scoped<Agent>, agent: Agent): void',
jsDoc: '/**\n * An agent left the registry; AgentLoop emits this after driver quiescence\n * but before session detachment and scoped-registration unwind. Custom\n * registry users own their driver-ordering contract.\n * @param agent - the exact agent removed from the registry.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
summary: 'An agent left the registry; AgentLoop emits this after driver quiescence but before session detachment and scoped-registration unwind.',
jsDoc: '/**\n * An agent left the registry; AgentLoop emits this after driver quiescence\n * and scoped-registration unwind, but before session detachment. Custom\n * registry users own their driver-ordering contract.\n * @param agent - the exact agent removed from the registry.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
summary: 'An agent left the registry; AgentLoop emits this after driver quiescence and scoped-registration unwind, but before session detachment.',
},
{
name: 'agent/error',
@@ -889,8 +889,8 @@ export const EVENT_API: readonly EventApiEntry[] = [
name: 'agent/idle',
mode: 'emit',
signature: '\'agent/idle\'(this: Scoped<Agent>, agent: Agent, turn: number, reason: IdleReason): void',
jsDoc: '/**\n * One turn closed: its `turn/end` and durability flush are already\n * committed. `reason` says why — recovery consumers observe an `error`\n * reason, repair (edit the log, wait, resummon), and call\n * {@link Agent.retry}; UI consumers key turn-done presentation off it.\n * Emitted per turn, including cancelled and failed ones.\n * @param agent - the agent whose turn closed.\n * @param turn - the closed turn number.\n * @param reason - why the turn ended, with live error facts when it failed.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
summary: 'One turn closed: its `turn/end` and durability flush are already committed.',
jsDoc: '/**\n * One drain chain reached its terminal turn: that turn\'s `turn/end` is\n * already committed. Automatically recovered failed turns do not emit this\n * notification. `reason` says why; model-request recovery is exhausted when\n * an error reaches it.\n * @param agent - the agent whose turn closed.\n * @param turn - the terminal turn number.\n * @param reason - why the terminal turn ended, with live error facts when it failed.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
summary: 'One drain chain reached its terminal turn: that turn\'s `turn/end` is already committed.',
},
{
name: 'agent/inbox/dequeue',
@@ -924,9 +924,16 @@ export const EVENT_API: readonly EventApiEntry[] = [
name: 'agent/request',
mode: 'waterfall',
signature: '\'agent/request\'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, signal: AbortSignal, next: () => Promise<LlmCallConfig>): Promise<LlmCallConfig>',
jsDoc: '/**\n * Replace the frozen call configuration. `await next()` yields the config\n * the machine would use (agent options on the first request, the logged\n * header afterwards); return a replacement to switch. Model-visible\n * content must use logged channels; this seam cannot mutate messages.\n * @param agent - the agent making the model call.\n * @param turn - the open turn number.\n * @param step - the step whose request this is.\n * @param signal - the current turn\'s explicit abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */',
jsDoc: '/**\n * Replace the frozen call configuration. `await next()` yields the config\n * the machine would use (agent options on the first request, the logged\n * header afterwards); return a replacement to switch. Model-visible\n * content must use logged channels; this seam cannot mutate messages.\n * @param agent - the agent making the model call.\n * @param turn - the open turn number.\n * @param step - the step whose request this is.\n * @param signal - the current turn\'s explicit abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n*/',
summary: 'Replace the frozen call configuration.',
},
{
name: 'agent/request-error',
mode: 'waterfall',
signature: '\'agent/request-error\'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, error: RequestError, failure: LlmFailure, signal: AbortSignal, next: () => Promise<void>): Promise<void>',
jsDoc: '/**\n * Handle a model-request failure after its failed step has closed but\n * before the failed turn closes. A listener calls {@link Agent.retry} to\n * schedule one retry turn, returns without `next()` when it owns the error,\n * or calls `next()` to delegate. The default leaves the failure terminal.\n * @param agent - the agent whose request failed.\n * @param turn - the open turn number.\n * @param step - the failed step number.\n * @param error - the original model-request failure.\n * @param failure - serializable facts normalized at the final adapter boundary.\n * @param signal - the turn abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */',
summary: 'Handle a model-request failure after its failed step has closed but before the failed turn closes.',
},
{
name: 'agent/session-start',
mode: 'emit',
@@ -938,8 +945,8 @@ export const EVENT_API: readonly EventApiEntry[] = [
name: 'agent/status',
mode: 'emit',
signature: '\'agent/status\'(this: Scoped<Agent>, agent: Agent, status: AgentStatus): void',
jsDoc: '/**\n * Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does\n * not enter `running` synchronously; drive lifecycle from this event.\n * @param agent - the agent whose status flipped.\n * @param status - the status just entered (the transition\'s destination).\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
summary: 'Agent status changed (`idle` ⇄ `running`, or → `disposed`).',
jsDoc: '/**\n * Agent status changed (`idle` ⇄ `running`). `send()` does not enter\n * `running` synchronously; drive lifecycle from this event.\n * @param agent - the agent whose status flipped.\n * @param status - the status just entered (the transition\'s destination).\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
summary: 'Agent status changed (`idle` ⇄ `running`).',
},
{
name: 'agent/step',

View File

@@ -12,7 +12,7 @@ Creation and resume are one rollback-covered transaction: construct a private se
The caller fiber and the AgentLoop provider are co-owners. `AgentFactory.createAgent(ownerCtx, options)` and `resume(ownerCtx, options)` receive caller ownership explicitly, while the factory keeps its own dependency context for `sessions`/`llm`/`tools`/`systemPrompt`; this lets a caller inject only `agents` without shrinking the new agent's service surface. Caller unload, handle disposal, or provider unload converge on one memoized quiescence boundary. Provider shutdown waits both resource teardown and the public create/resume wrapper that observed deactivation, so no continuation can publish after dependencies disappear.
Each agent and its session share one caller-chosen `SessionId`, assumed globally unique; accidental UUID collisions are outside the supported model. Two concurrent operations with the same id may both prepare, but the final `enter()` calls arbitrate publication and every loser rolls its private resources back. Each detach is bound to the exact entered object, so a stale disposer cannot remove a later same-id replacement. A detach requested during a synchronous creation notification waits for that dispatch to unwind, preserving created/disposed pairing. Teardown runs stop and drain → detach agent → detach session → unwind scope; the id becomes reusable at detach even if private scope cleanup is still finishing. Ordinary non-vetoing `agent/*` notifications go through `agentEvents(ctx, agent)`, per-step assembly goes through `assembleContextFor(agent)`, and turn-end durability checkpoints go through `ctx.sessions.flush(session)`.
Each agent and its session share one caller-chosen `SessionId`, assumed globally unique; accidental UUID collisions are outside the supported model. Two concurrent operations with the same id may both prepare, but the final `enter()` calls arbitrate publication and every loser rolls its private resources back. Each detach is bound to the exact entered object, so a stale disposer cannot remove a later same-id replacement. A detach requested during a synchronous creation notification waits for that dispatch to unwind, preserving created/disposed pairing. Teardown runs stop and drain → unwind scope → detach agent → detach session; the id becomes reusable after private scope cleanup. Ordinary non-vetoing `agent/*` notifications go through `agentEvents(ctx, agent)`, and per-step assembly goes through `assembleContextFor(agent)`.
- `ctx.agentLoop.create(id: SessionId, options?: AgentOptions, meta?: { cwd?: string }): Agent` — synchronous no-setup create under the exact shared agent/session id, disposed with the calling fiber. Declarative config treats `agents[].id` as a stable label and normally mints `${label}-session-<uuid>` before calling this boundary. An app may instead supply a stable exact `sessionId`: first use creates it, while a remount with persistence already present resumes its materialized history. `resumeSessionId` requires and loads an existing persisted id and is mutually exclusive with `sessionId`. This keeps default fresh restarts collision-free without retaining a second live routing identity.
@@ -50,29 +50,29 @@ 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`, its queued input, outbox, and run 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 unified `send()` primitive routes content and source by (`target` × `wakeup`); `followup`/`steer`/`inject` are its fixed-preset aliases. A `next-turn` item joins the queued FIFO, waking the driver unless `wakeup: false`; admission happens before any turn opens. An allowed prompt and the prompt waterfall's `additionalContexts` enter the outbox as separate messages, then `run()` opens the turn and drains them together. A running `next-step`/wakeup `steer()` enters the same outbox without prompt admission and normally causes another step. A `next-step`/no-wakeup `inject()` waits there only while a turn is open; while idle it appends a `user/message` immediately without opening a turn or running the model. Persistence owns the resulting eager drain. Every inbox enqueue publishes `agent/inbox/enqueue`; taking it publishes `agent/inbox/dequeue`, and `cancel()` without `keepInbox` publishes `agent/inbox/discard`.
### Loop lifecycle (`loop.ts`)
### Loop lifecycle (`agent.ts`)
The driver owns one agent for its lifetime and runs inside `ctx.agents.withInitiator(agent, ...)`. Package-private orchestration entry points recover the exact Agent, derive `agent.session` once, and let operation-local helpers capture it instead of forwarding the concrete driver or per-operation `Session` through shallow interfaces. A helper keeps an explicit `Session` when that is its actual interface, while creation, persistence load, unpublished setup, services, workers, processes, persistence, and wire protocols retain their explicit identities. The [agent service](../agent/README.md#initiating-agent-scope) owns propagation, teardown, and detached-work rules.
Every provider call that reaches a successful finish appends exactly one `assistant/message` completion anchor, including content-less calls and `max-tokens` finishes. A successful `agent/step-result` stores its transformed content; a rejected result records empty content before the original failure continues. The anchor retains exact chunk provenance (`[]` for a stream with no chunks) and usage when available, while empty content stays out of derived message history.
Every provider call that reaches a successful finish appends exactly one `assistant/message` completion anchor, including content-less calls and `max-tokens` finishes. The anchor records the assembled content as-is, retains exact chunk provenance (`[]` for a stream with no chunks), and includes usage when available; empty content stays out of derived message history.
Plugin failure ends the current turn, not the loop. Only final adapter dispatch/iteration failures and terminal in-band error or aborted finishes enter `agent/request-error`; middleware, result processing, tools, and `agent/post-step` remain ordinary turn failures. Recovery receives the exact live error, immutable provider facts, and immutable prior failures after the failed step closes. A retry rebuilds from the durable log in a new numbered step, success clears the consecutive history, and exhaustion records the structured failure once on `turn/end`. AgentLoop privately owns one cancellation holder whose explicit signal spans prompt policy, assembly, every step, model and tool work, recovery, continuation, and terminal stop; it retires the holder immediately before publishing `turn/end`, while the driver may remain `running` through the durability flush. An effective `cancel()` emits the typed runtime-only `user | parent` cause before clearing pending work and cooperatively aborting the holder; notification failures cannot veto cancellation, work queued by a notification observer is cleared, work queued by a later abort observer belongs to the next turn, and idle cancellation emits nothing. Durable `turn/end` remains coarse `aborted`; undispatched model tool calls receive synthetic `tool/call` and `ABORTED_BEFORE_DISPATCH` result pairs. Disposal wins terminal classification, and work that ignores the signal must settle before quiescence. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns the lifecycle and race contract. Terminal continuation stops remain authoritative through turn close and durability flush.
Plugin failure ends the current turn, not the loop. A model-request failure first closes its step and enters `agent/request-error` with the exact live error, normalized provider facts, and the turn signal. A handling listener calls `agent.retry()`; the loop coalesces repeated calls, closes the failed turn with its error, and opens one numbered retry turn without an intervening idle notification. An unhandled failure is terminal. Other failures close directly. AgentLoop owns one cancellation signal for the current admission or turn. An effective `cancel(cause)` clears pending work unless `keepInbox` is set and cooperatively aborts that signal; idle cancellation is a no-op. Durable `turn/end` records `aborted` for `user` and `parent`, while disposal records `disposed`; undispatched model tool calls receive synthetic `tool/call` and `ABORTED_BEFORE_DISPATCH` result pairs. The cancellation cause changes reporting, not how result context finalized after cancellation is handled. Disposal waits for signal-ignoring work before registry removal. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns the lifecycle and race contract.
Within a step, exclusive calls form barriers; parallel-safe calls use a bounded rolling pool and are reclassified before start. Only dispatch/body overlaps. Policy, durable results, and result context remain model-ordered. Abort stops new calls, drains started results, then drains accepted batch context before the turn closes through the normal abort path.
Within a step, exclusive calls form barriers; parallel-safe calls use a bounded rolling pool and are reclassified before start. Only dispatch/body overlaps. Policy, durable results, and result context remain model-ordered. Abort stops new calls, drains started results, and retains their finalized result context without distinguishing the cancellation cause.
### What belongs to plugins
Everything that goes beyond "call the model, run the tools, repeat" belongs to plugins listening on the event taxonomy:
- Hooks and policy: the relevant `agent/*` checkpoints plus the guarded `tools/pre-execute``tools/execute``tools/post-execute` → definition-owned `finalizeContent``tools/result` pipeline; exact event signatures and modes live in the [generated event catalog](../../../docs/cordis-catalog/events.md)
- Compaction: pressure on `agent/post-step`; canonical context overflow on `agent/request-error`
- Transient model recovery: `dsh-llm-retry` on `agent/request-error`, with finite code-specific budgets and non-surface `llm/retry` status events
- Compaction: pressure on `agent/step`; canonical overflow repair on `agent/request-error`
- Transient model recovery: `dsh-llm-retry` records and waits its finite backoff on `agent/request-error`, then calls `agent.retry()`
- Sandbox, permission, plan mode: `tools/pre-execute` for extensible deny/ask, `tools.guard()` for monotonic owner policy, `tools/post-execute` for result decisions, and `tools/result` for final observation
- Sub-agents: implemented outside the loop as `ctx.subagents` providers; in-process providers use `ctx.agents.create()` and owned `AgentHandle` teardown, while generic [`ctx.tasks`](../../tasks/tasks/) plus [`dsh-tool-subagent`](../../subagent/tool-subagent/) own background collection.
- Persistence: `session/event` + `session/flush`
- Persistence: eager write-behind from `session/event`; `session/flush` is an explicit observation barrier
- UI: `session/event` (assistant token stream, boundaries, tool activity) + `agent/*` control events (`agent/status`, `agent/created`/`agent/disposed`)
## Model Experience
@@ -81,15 +81,15 @@ Everything that goes beyond "call the model, run the tools, repeat" belongs to p
#### What the model sees
For each step, the loop sends the rendered per-agent system prompt, visible tool schemas, the frozen session prefix, and the session's derived messages. It supplies `model` and `cwd` variable values but no additional fixed prose.
For each step, the loop sends the rendered per-agent system prompt, visible tool schemas, and the session's derived messages. It supplies `provider`, `model`, and `cwd` variable values but no additional fixed prose.
#### Token effect
System text, schemas, and prefix are paid again on every step. Per-agent scoping chooses the initial contributions, while the authoritative assembly waterfall can alter the final request and makes its listener responsible for protocol coherence.
System text and schemas are paid again on every step. Per-agent scoping chooses the contributions, while the authoritative assembly waterfall can alter the final request and makes its listener responsible for protocol coherence.
#### KV Cache effect
Append-only only while system text, schemas, session prefix, and earlier history remain byte-identical under the same provider and model route. A token-bearing assembly rewrite or composition change may invalidate reuse from the first altered request token.
Append-only only while system text, schemas, and earlier history remain byte-identical under the same provider and model route. A token-bearing assembly rewrite or composition change may invalidate reuse from the first altered request token.
### Retained message history
@@ -99,7 +99,7 @@ Accepted user messages, assistant messages, tool calls and results, injected con
#### Token effect
Input grows with every surface message until a compaction replacement shadows older nodes; a multi-step tool turn resends the accumulated prefix and history each step.
Input grows with every surface message until a compaction replacement shadows older nodes; a multi-step tool turn resends the accumulated history each step.
#### KV Cache effect
@@ -124,4 +124,4 @@ Append-only; each synthetic result follows the reusable request prefix and does
- **Classification is unary** — calls whose safety depends on comparing siblings or resources must remain exclusive ([rationale](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md)).
- **Config labels are fresh by default** — omitting `sessionId` creates a fresh `${id}-session-<uuid>` on every startup; exact resume-or-create behavior requires an explicit stable `sessionId`, while `resumeSessionId` requires existing persisted history.
- **Config agents have no per-agent persona field or setup hook** — they use the deployment persona; scoped persona/tool composition is available only through the programmatic `ctx.agents.create()` / `resume()` factory options.
- **No built-in turn budget** — the default continuation is `continue` whenever a step had tool calls or steering; bounding a runaway turn requires an `agent/turn-continuation` force-stop plugin.
- **No built-in turn budget** — tool calls or steering continue the current turn; a policy that bounds runaway turns must cancel from an existing lifecycle seam such as `agent/stopping`.

View File

@@ -19,13 +19,14 @@ import type {
AgentStatus,
IdleReason,
PromptDecision,
RequestError,
SendOptions,
} from '@deepseek-ai/dsh-agent'
import {
BlockAssembler, LlmError, deepFreeze, errorChain, isHarnessError, llmFailureOf, markAgentLoopRequest,
} from '@deepseek-ai/dsh-llm'
import type {
ContentBlock, GenerateOptions, LlmCallConfig, Message,
ContentBlock, GenerateOptions, LlmCallConfig, LlmFailure, Message,
} from '@deepseek-ai/dsh-llm'
import { canonicalHeader, headerEquals } from '@deepseek-ai/dsh-session'
import type { Session, SessionId, TurnEndReason, TurnTrigger, UserMessageData } from '@deepseek-ai/dsh-session'
@@ -33,13 +34,15 @@ import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-tools'
import { executeToolCalls } from './tool-calls.ts'
/** One message waiting in the queued or steering inbox. */
interface PendingMessage extends AgentMessage {
wakeup: boolean
}
function withoutToolCalls(message: Message): Message {
return { ...message, content: message.content.filter(block => block.type !== 'tool-call') }
/** A final-adapter or terminal in-band failure eligible for request recovery. */
class ModelRequestFailure extends Error {
constructor(
readonly requestError: RequestError,
readonly failure: LlmFailure,
) {
super(requestError.message, { cause: requestError })
}
}
/**
@@ -48,14 +51,16 @@ function withoutToolCalls(message: Message): Message {
*/
export class ReactLoopAgent extends Agent {
/** Prompts awaiting individual turns. */
private queued: PendingMessage[] = []
private queued: { message: AgentMessage; wakeup: boolean }[] = []
/** Input taken into the session log at step boundaries. */
private outbox: (UserMessageData | PendingMessage)[] = []
private outbox: (UserMessageData | AgentMessage)[] = []
/** Whether observers see a running interval; consecutive turns share it. */
private busy = false
/** Abort owner for the current admission or turn. */
private abort: AbortController | undefined
/** Coalesced retry capability scoped to the active request-error waterfall. */
private retryWindow: { requested: boolean } | undefined
/** Resolves when the current admission and turn exit. */
done: Promise<void> = Promise.resolve()
@@ -104,16 +109,15 @@ export class ReactLoopAgent extends Agent {
}
const steering = target === 'next-step' && this.turnOpen
const message: PendingMessage = {
const message: AgentMessage = {
id,
content,
source,
wakeup,
}
if (steering) {
this.outbox.push(message)
} else {
this.queued.push(message)
this.queued.push({ message, wakeup })
}
emitAgentEvent(this.loopCtx, this, 'agent/inbox/enqueue', message)
if (!steering && wakeup) this.kick()
@@ -134,7 +138,7 @@ export class ReactLoopAgent extends Agent {
if (cause.kind !== 'disposed') emitAgentEvent(this.loopCtx, this, 'agent/cancel-requested', cause)
}
if (!options.keepInbox) {
const discarded: AgentMessage[] = [...this.queued]
const discarded = this.queued.map(item => item.message)
for (const message of this.outbox) {
if ('id' in message) discarded.push(message)
}
@@ -143,18 +147,22 @@ export class ReactLoopAgent extends Agent {
this.outbox.length = 0
if (discarded.length > 0) emitAgentEvent(this.loopCtx, this, 'agent/inbox/discard', discarded)
}
if (this.retryWindow !== undefined) this.retryWindow.requested = false
const reason = Object.freeze({ kind: cause.kind })
this.abort?.abort(reason)
}
/**
* Re-open a turn on the current session log without a new prompt — the
* recovery verb after an error idle (naive `retry()`): repair the history
* (edit the log, wait out a rate limit), then run again, right now.
* @throws while a turn is running — there is nothing to retry yet.
* recovery verb. A request-error listener schedules the retry that follows
* its failed turn; an idle caller starts one immediately.
*/
retry(): void {
if (this.abort !== undefined) throw new Error(`agent "${this.id}" cannot retry while busy`)
if (this.abort !== undefined) {
if (this.retryWindow === undefined) throw new Error(`agent "${this.id}" cannot retry while busy`)
if (!this.abort.signal.aborted) this.retryWindow.requested = true
return
}
this.done = this.loopCtx.agents.withInitiator(this, () => this.run({ kind: 'retry' }))
}
@@ -162,16 +170,17 @@ export class ReactLoopAgent extends Agent {
async whenIdle(): Promise<void> {
// `done` is replaced per activity, so re-reading it follows chained turns;
// a run failure still counts as quiescence for the waiter.
while (this.abort !== undefined || this.queued.some(message => message.wakeup)) {
while (this.abort !== undefined || this.queued.some(item => item.wakeup)) {
await this.done.catch(() => undefined)
}
}
/** Claim and admit the next queued prompt, then start its turn. */
private kick(): void {
if (this.abort !== undefined || !this.queued.some(message => message.wakeup)) return
const message = this.queued.shift()
if (message === undefined) return
if (this.abort !== undefined || !this.queued.some(item => item.wakeup)) return
const item = this.queued.shift()
if (item === undefined) return
const { message } = item
emitAgentEvent(this.loopCtx, this, 'agent/inbox/dequeue', message)
const admission = new AbortController()
@@ -210,7 +219,7 @@ export class ReactLoopAgent extends Agent {
})
}
/** Own one complete turn over input already admitted by {@link kick}, or retry history as-is. */
/** Run one turn and any request-error retry over input already admitted by {@link kick}. */
private async run(trigger: TurnTrigger): Promise<void> {
if (this.abort !== undefined) throw new Error(`agent "${this.id}" is already running`)
const controller = new AbortController()
@@ -224,6 +233,9 @@ export class ReactLoopAgent extends Agent {
let step = 0
let reason: TurnEndReason = { kind: 'completed' }
let idle: IdleReason = { kind: 'completed' }
let retry = false
const cancelRetry = (): void => { retry = false }
signal.addEventListener('abort', cancelRetry, { once: true })
try {
signal.throwIfAborted()
@@ -242,8 +254,36 @@ export class ReactLoopAgent extends Agent {
signal.throwIfAborted()
if (!this.drainOutbox(turn)) break
}
} catch (error: unknown) {
({ reason, idle } = this.settle(turn, step, error, signal))
} catch (caught: unknown) {
const requestFailure = caught instanceof ModelRequestFailure ? caught : undefined
const error = requestFailure?.requestError ?? caught
if (this.stepOpen) {
this.stepOpen = false
this.session.append('step/end', { turn, step })
}
if (requestFailure !== undefined && agentInterruptReasonOf(signal) === undefined) {
const retryWindow = { requested: false }
this.retryWindow = retryWindow
let recoveryCompleted = false
try {
await this.loopCtx.waterfall(
agentCarrier(this), 'agent/request-error', this, turn, step, requestFailure.requestError,
requestFailure.failure, signal,
() => Promise.resolve(),
)
recoveryCompleted = true
} catch (recoveryError: unknown) {
this.loopCtx.logger.warn(
`agent "${this.id}": request recovery failed at turn ${turn}, step ${step}: ${errorChain(recoveryError)}`,
)
} finally {
if (this.retryWindow === retryWindow) this.retryWindow = undefined
}
retry = recoveryCompleted
&& agentInterruptReasonOf(signal) === undefined
&& retryWindow.requested
}
({ reason, idle } = this.settle(turn, step, error, signal, requestFailure?.failure))
} finally {
try {
if (this.stepOpen) {
@@ -256,10 +296,18 @@ export class ReactLoopAgent extends Agent {
this.session.append('turn/end', { turn, reason })
}
} catch (error: unknown) {
retry = false
this.loopCtx.logger.warn(`agent "${this.id}": closing turn ${turn} failed: ${errorChain(error)}`)
emitAgentEvent(this.loopCtx, this, 'agent/error', turn, step, error)
}
this.retryWindow = undefined
if (this.abort === controller) this.abort = undefined
signal.removeEventListener('abort', cancelRetry)
}
if (retry) {
await this.run({ kind: 'retry' })
} else {
emitAgentEvent(this.loopCtx, this, 'agent/idle', turn, idle)
this.continueOrIdle()
}
@@ -311,11 +359,9 @@ export class ReactLoopAgent extends Agent {
assembler.push(chunk)
}
} catch (error: unknown) {
// Normalize a final-adapter failure into the one model-error type; the
// foreign original stays on `cause` for the rendered chain.
const facts = llmFailureOf(stream, error)
if (facts !== undefined && error instanceof Error) {
throw new LlmError(facts.message, facts.code, { ...facts, cause: error })
throw new ModelRequestFailure(error, facts)
}
throw error
}
@@ -324,20 +370,22 @@ export class ReactLoopAgent extends Agent {
// Failure finish chunks take the same path as thrown stream errors.
const finish = assembler.finish
if (finish.kind === 'error' || finish.kind === 'aborted') {
throw new LlmError(finish.failure.message, finish.failure.code, finish.failure)
const error = new LlmError(finish.failure.message, finish.failure.code, finish.failure)
throw new ModelRequestFailure(error, finish.failure)
}
// Truncated (max-tokens) output cannot owe tool calls.
const assembled = assembler.finish.kind === 'max-tokens'
? withoutToolCalls(assembler.message())
: assembler.message()
const assembled = assembler.message()
const content = finish.kind === 'max-tokens'
? assembled.content.filter(block => block.type !== 'tool-call')
: assembled.content
session.append(
'assistant/message',
{
turn,
step,
content: assembled.content,
content,
provenance: {
provider: request.provider,
model: request.model,
@@ -348,7 +396,7 @@ export class ReactLoopAgent extends Agent {
{ surfaceOp: 'append', sourceEventSeqs: chunkSeqs },
)
const toolCalls = assembled.content.filter(block => block.type === 'tool-call')
const toolCalls = content.filter(block => block.type === 'tool-call')
let concluded = false
if (toolCalls.length > 0) {
({ concluded } = await executeToolCalls(
@@ -448,20 +496,26 @@ export class ReactLoopAgent extends Agent {
* The single settlement funnel: classify one turn failure (interruption
* beats error) into the durable turn/end reason and the live idle report.
*/
private settle(turn: number, step: number, error: unknown, signal: AbortSignal): { reason: TurnEndReason; idle: IdleReason } {
private settle(
turn: number,
step: number,
error: unknown,
signal: AbortSignal,
failure?: LlmFailure,
): { reason: TurnEndReason; idle: IdleReason } {
const interrupt = agentInterruptReasonOf(signal)
if (interrupt !== undefined) {
return { reason: { kind: interrupt.kind === 'disposed' ? 'disposed' : 'aborted' }, idle: { kind: 'aborted' } }
}
if (error instanceof LlmError) {
if (failure !== undefined) {
emitAgentEvent(this.loopCtx, this, 'agent/error', turn, step, error)
// The durable record renders the full cause chain: turn/end is the one
// durable trace of the failure, so a wrapper message alone would lose
// the transport detail the log exists to keep.
const rendered = errorChain(error)
return {
reason: { kind: 'error', step, failure: { ...error.failure, ...rendered === '<unrenderable value>' ? {} : { message: rendered } } },
idle: { kind: 'error', error, failure: error.failure },
reason: { kind: 'error', step, failure: { ...failure, ...rendered === '<unrenderable value>' ? {} : { message: rendered } } },
idle: { kind: 'error', error, failure },
}
}
emitAgentEvent(this.loopCtx, this, 'agent/error', turn, step, error)
@@ -474,7 +528,7 @@ export class ReactLoopAgent extends Agent {
/** Continue with a waking prompt, or publish the idle status. */
private continueOrIdle(): void {
if (this.abort !== undefined) return
if (this.queued.some(message => message.wakeup)) {
if (this.queued.some(item => item.wakeup)) {
this.kick()
} else if (this.busy) {
this.busy = false

View File

@@ -283,11 +283,16 @@ export class AgentLoop extends Service implements AgentFactory {
): Promise<void> {
await this.waitForDrainingConfiguredIdentity(ownerCtx, sessionId)
if (!this.ownership.isActive()) return
const exists = (await persistence.list()).some(header => header.id === sessionId)
if (!this.ownership.isActive()) return
if (exists) {
try {
await this.resumeWith(ownerCtx, persistence, { resumeSessionId: sessionId, agentOptions })
return
} catch (error: unknown) {
if (!this.ownership.isActive()) return
// A load is the per-id serialization barrier for eager write-behind and
// lifecycle retirement. Only a genuinely absent artifact falls back to
// first creation; corruption and backend failures stay loud.
const exists = (await persistence.list()).some(header => header.id === sessionId)
if (exists) throw error
}
this.create(sessionId, agentOptions, meta)
}
@@ -350,10 +355,11 @@ export class AgentLoop extends Service implements AgentFactory {
let detachSession: (() => void) | undefined
let detachAgent: (() => void) | undefined
let disposing: Promise<void> | undefined
const machineReady = Promise.withResolvers<void>()
// Reverse teardown, memoized so every racing owner awaits one quiescence:
// stop the machine, leave the registries, unwind the scope, release
// bookkeeping.
const dispose = (): Promise<void> => (disposing ??= (async () => {
const dispose = (ownerTriggered = false): Promise<void> => (disposing ??= (async () => {
abort.abort(new Error(`agent "${id}" lifecycle disposed`))
callerSignal?.removeEventListener('abort', onCallerAbort)
this.ownership.signal.removeEventListener('abort', onFactoryTeardown)
@@ -361,6 +367,7 @@ export class AgentLoop extends Service implements AgentFactory {
// Disposal IS a disposed-cause cancel followed by quiescence. New work
// sent after this point is the sender's bug — the registries are about
// to drop the agent, so nothing should still hold it.
if (machine === undefined) await machineReady.promise
if (machine !== undefined) {
machine.cancel({ kind: 'disposed' })
await Promise.allSettled([machine.done])
@@ -372,7 +379,7 @@ export class AgentLoop extends Service implements AgentFactory {
detachSession?.()
} finally {
untrack()
void unfollowOwner()
if (!ownerTriggered) await unfollowOwner()
}
}
})())
@@ -380,11 +387,11 @@ export class AgentLoop extends Service implements AgentFactory {
let unfollowOwner: () => Promise<void> | void
try {
unfollowOwner = ownerCtx.effect(() => () => {
// Owner disposal starts teardown but must not await its own disposer.
if (disposing === undefined) {
abort.abort(new Error(`agent "${id}" setup aborted: owner disposed during setup`))
void dispose()
}
// Owner disposal owns the same quiescence boundary. Its teardown skips
// unregistering this already-running owner effect from inside itself.
if (disposing !== undefined) return
abort.abort(new Error(`agent "${id}" setup aborted: owner disposed during setup`))
return dispose(true)
}, `agentLoop.lifecycle(${id})`)
} catch (error: unknown) {
untrack()
@@ -399,6 +406,7 @@ export class AgentLoop extends Service implements AgentFactory {
}
try {
const agent = machine = new ReactLoopAgent(loopCtx, id, options, session)
machineReady.resolve()
assertLive()
return {
@@ -422,6 +430,7 @@ export class AgentLoop extends Service implements AgentFactory {
dispose,
}
} catch (error: unknown) {
machineReady.resolve()
void dispose()
throw error
}
@@ -497,11 +506,21 @@ export class AgentLoop extends Service implements AgentFactory {
// The load may outlive its owner: race it against caller cancellation,
// owner-fiber unload, and factory teardown so a never-settling backend
// cannot pin the identity.
const ownerAbort = new AbortController()
const unfollowOwner = ownerCtx.effect(() => () => {
ownerAbort.abort(new Error(`agent "${id}" setup aborted: owner disposed during setup`))
}, `agentLoop.resume-load(${id})`)
const fused = AbortSignal.any([
...options.signal === undefined ? [] : [options.signal],
ownerAbort.signal,
this.ownership.signal,
])
const loaded = await raceAbort(persistence.load(id), fused, id)
let loaded: Awaited<ReturnType<SessionPersistence['load']>>
try {
loaded = await raceAbort(persistence.load(id), fused, id)
} finally {
await unfollowOwner()
}
ownerCtx.fiber.assertActive()
if (!this.ownership.isActive()) throw new Error('agent loop is not active')
const session = this.runtime.ctx.sessions.prepare(id, {

View File

@@ -1,86 +0,0 @@
# Agent-loop test migration guide (naive-machine contract)
The loop was rewritten in the naive-agent shape. `packages/core/agent-loop/src/agent.ts`
is the single source of truth — read it before migrating a spec. Key changes:
## Event seams (old → new)
| Old seam | Replacement |
|---|---|
| `agent/pre-step` (serial, before step/start) | `agent/step` (serial, before EVERY request derives; same position) |
| `agent/post-step` (serial, after tools, before step/end) | REMOVED — use `agent/step` of the next step, or `agent/idle` after the turn |
| `agent/session-prefix` (waterfall, request-only prefix) | REMOVED — requests carry no unlogged prefix; durable context via `agent.inject()` at `agent/session-start` |
| `agent/step-result` (waterfall, rewrite assistant msg) | REMOVED — the assembled message is recorded as-is |
| `agent/request-error` (waterfall, retry/fail decision) | REMOVED — observe `agent/idle` with `reason.kind === 'error'`, repair, then `agent.retry()` |
| `agent/turn-continuation` (waterfall, ContinuationDecision) | `agent/continue` (waterfall of `boolean`; handler `(agent, turn, signal, next)`) |
| `agent/turn-stop` (serial, terminal stop) | REMOVED — `agent/continue` returning `false` stops the turn |
| `agent/request` `(agent, turn, step, config, signal, next)` | `(agent, turn, step, signal, next)` — the config comes only from `await next()` |
| `agent/prompt-submit` | unchanged |
New emit: `agent/idle (agent, turn, reason: IdleReason)` fires once per closed turn
(after turn/end + flush, with `busy` already false, so listeners may synchronously
`retry()`/`send()`). `IdleReason = completed | aborted | { kind: 'error', error, failure? }`.
## Verb semantics
- `send()` — unchanged (queued FIFO, one turn each).
- `steer()` while running — enters the outbox; taken whole at the next step
boundary. A turn failure leaves untaken steering staged without waking the
agent; `retry()` or a later prompt takes it.
- `inject()` while the machine is busy — enters the outbox (a `context/message`
appears at the NEXT step boundary, not immediately). While idle — writes a
one-shot turn (`turn/start(injection)` + `context/message` + `turn/end`) and
requests a flush. Enclosure is decided by `busy`, NOT by scanning the log for
an open turn.
- `retry()` — NEW verb: re-opens a turn on the current log with trigger
`{ kind: 'retry' }`. Throws while busy ("cannot retry while busy") and after
disposal. Legal from a synchronous `agent/idle` listener.
- `cancel()` — unchanged surface. No more "pre-run cancelled" bookkeeping:
clearing the queue before a run starts simply means no run starts.
## Machine shape (timing-sensitive tests)
- `kick()` runs SYNCHRONOUSLY from `send()` when idle: status flips to
`running` inside the `send()` call. There is no parked driver loop, no
waitForQueued, no microtask collection window.
- One `run()` = one turn. After `turn/end`, it emits `agent/idle`, then either
starts the next waking queued prompt or flips status to `idle`. Residual
outbox input does not wake the agent. Status stays `running` continuously
across queued turns.
- `step/end` is appended INSIDE the step (after tools + the in-step outbox
drain), before `agent/continue` runs. The old `post-step → step/end`
window no longer exists.
- Request messages = `session.deriveMessages()` snapshot taken right before
`step/start` — no `messagePrefix`. `request/header` events no longer carry
a `messagePrefix` field.
- Provider/model config waterfall (`agent/request`) runs INSIDE the step
(after step/start), seeded from agent options (first request) or the folded
logged header (later requests).
- The assembled assistant message is recorded verbatim (with replayState when
present); there is no rewrite path and no "content-less anchor on rejection".
- A model failure (thrown by the adapter or a failure finish chunk) closes the
turn: balanced step/end + turn/end `{ kind:'error', step, failure }` +
`agent/error` emit + `agent/idle` `{ kind:'error', error, failure }`.
There are no in-turn recovery steps.
- Cancellation classification: signal reason `user`/`parent` → turn/end
`aborted`; disposal → `disposed`. IdleReason for both is `aborted`.
- A blocked prompt (`prompt-submit` → block) records `prompt/blocked`, closes
a zero-step turn `rejected` in turn/end, and emits `agent/idle`
`{ kind: 'completed' }` (rejection is a policy outcome, not an error).
- Accept-validation error message is now
"agent message content and source must be losslessly JSON-serializable".
- `dispose()` (the prepared disposer / factory teardown) returns `undefined`
when the machine is not busy — do not `.resolves` it unconditionally; use
`await Promise.resolve(dispose())`.
## What to do with tests of removed seams
- Rewrite the scenario against the nearest new seam when the protected
behavior still exists (e.g. turn-stop tests → `agent/continue` returning
false; request-error retry tests → `agent/idle` + `retry()` flows).
- Delete tests whose subject no longer exists at all (session-prefix
reconstruction, step-result rewrite provenance, post-step ordering windows,
pre-run-cancel bookkeeping). Do not keep zombie tests alive by weakening
their assertions.
- Keep the durable-log invariants strong: balanced turn/step boundaries,
ordered tool call/result pairs, header change tracking — those still hold.

View File

@@ -153,6 +153,7 @@ describe('AgentLoop initiator scope', () => {
const { ctx } = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('signal-owner'), { provider: 'mock', model: 'mock' })
let signals: AbortSignal[] = []
let admissionSignals: AbortSignal[] = []
const capture = (signal: AbortSignal | undefined): void => {
if (signal === undefined) throw new Error('turn seam omitted its explicit signal')
expect(ctx.agents.requireInitiator()).toBe(agent)
@@ -164,29 +165,20 @@ describe('AgentLoop initiator scope', () => {
return next()
})
ctx.on('agent/prompt-submit', async (subject, _content, _source, signal, next) => {
if (subject === agent) {
expect(ctx.agents.requireInitiator()).toBe(agent)
admissionSignals.push(signal)
}
return next()
})
ctx.on('agent/step', (subject, _turn, _step, signal) => {
if (subject === agent) capture(signal)
})
ctx.on('agent/request', async (subject, _turn, _step, signal, next) => {
if (subject === agent) capture(signal)
return next()
})
ctx.on('agent/session-prefix', async (subject, _prefix, signal, next) => {
if (subject === agent) capture(signal)
return next()
})
ctx.on('agent/pre-step', (subject, _turn, _step, signal) => {
if (subject === agent) capture(signal)
})
ctx.on('agent/request', async (subject, _turn, _step, _config, signal, next) => {
if (subject === agent) capture(signal)
return next()
})
ctx.on('agent/step-result', async (subject, _turn, _step, _message, signal, next) => {
if (subject === agent) capture(signal)
return next()
})
ctx.on('agent/turn-continuation', async (subject, _turn, _decision, signal, next) => {
if (subject === agent) capture(signal)
return next()
})
ctx.on('agent/turn-stop', (subject, _turn, signal) => {
ctx.on('agent/stopping', (subject, _turn, signal) => {
if (subject === agent) capture(signal)
})
ctx.tools.register(defineContentToolFixture({
@@ -205,14 +197,19 @@ describe('AgentLoop initiator scope', () => {
const firstSignal = signals[0]
expect(firstSignal).toBeDefined()
expect(new Set([...signals, ...adapter.requests.slice(0, 2).map(request => request.signal!)])).toEqual(new Set([firstSignal]))
expect(admissionSignals).toHaveLength(1)
expect(admissionSignals[0]).not.toBe(firstSignal)
signals = []
admissionSignals = []
const secondIdle = waitForIdle(ctx, agent)
send(agent, 'second')
await secondIdle
const secondSignal = signals[0]
expect(secondSignal).toBeDefined()
expect(new Set([...signals, adapter.requests[2]!.signal!])).toEqual(new Set([secondSignal]))
expect(admissionSignals).toHaveLength(1)
expect(admissionSignals[0]).not.toBe(secondSignal)
expect(secondSignal).not.toBe(firstSignal)
expect(ctx.agents.currentInitiator()).toBeUndefined()
await ctx.fiber.dispose()
@@ -343,7 +340,6 @@ describe('AgentLoop initiator scope', () => {
expect(captured).toBe(handle.agent)
await handle.dispose()
expect(captured?.status).toBe('disposed')
expect(ctx.agents.currentInitiator()).toBeUndefined()
await ctx.fiber.dispose()
})
@@ -365,7 +361,6 @@ describe('AgentLoop initiator scope', () => {
await loopFiber.await()
expect(adapter.firstAgentDuringAbort?.id).toBe(oldAgent.id)
expect(adapter.firstAgentDuringAbort?.session).toBe(oldAgent.session)
expect(oldAgent.status).toBe('disposed')
expect(() => oldService.currentInitiator()).toThrow('agent initiator scope is disposed')
expect(ctx.agents).not.toBe(oldService)
adapter.agents = ctx.agents
@@ -406,7 +401,6 @@ describe('AgentLoop initiator scope', () => {
await ctx.fiber.dispose()
expect(adapter.firstAgentDuringAbort?.id).toBe(agent.id)
expect(adapter.firstAgentDuringAbort?.session).toBe(agent.session)
expect(agent.status).toBe('disposed')
expect(() => service.currentInitiator()).toThrow('agent initiator scope is disposed')
})
})

View File

@@ -1,19 +1,14 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
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 AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { bindReactLoopAgentContext, prepareReactLoopAgent } from '../src/agent.ts'
import { MockAdapter, textResponse } from './mock-adapter.ts'
function driverDone(agent: Agent): Promise<void> {
return (agent as Agent & { done: Promise<void> }).done
}
async function harness(adapter: MockAdapter) {
async function harness(adapter: MockAdapter): Promise<Context> {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
@@ -25,64 +20,11 @@ async function harness(adapter: MockAdapter) {
return ctx
}
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') {
dispose()
resolve()
}
})
})
}
function waitForStatus(ctx: Context, agent: Agent, expected: Agent['status']): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === expected) {
dispose()
resolve()
}
})
})
}
function send(agent: Agent, text: string) {
function send(agent: Agent, text: string): void {
agent.followup([{ type: 'text', text }])
}
describe('Agent', () => {
it('rejects access before context binding and a second driver for one session', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('exclusive-driver'))
const prepared = prepareReactLoopAgent(
ctx, SessionId('first-driver'), { provider: 'mock', model: 'mock' }, session,
)
expect(() => prepared.agent.ctx).toThrow('context is not bound')
expect(() => prepareReactLoopAgent(
ctx, SessionId('second-driver'), { provider: 'mock', model: 'mock' }, session,
))
.toThrow('already has a concrete agent driver')
await prepared.dispose()
await ctx.fiber.dispose()
})
it('borrows caller options and binds its scoped context exactly once', async () => {
const ctx = await harness(new MockAdapter([textResponse('unused')]))
const options = { provider: 'mock', model: 'mock' }
const agent = ctx.agentLoop.create(SessionId('owned-bindings'), options)
expect(agent.options).toBe(options)
expect(agent.id).toBe('owned-bindings')
expect(agent.session.id).toBe(agent.id)
expect(() => { bindReactLoopAgentContext(agent, new Context()) }).toThrow(/context is already bound/)
await ctx.fiber.dispose()
})
it('idle inject() appends context without opening a turn or requesting a flush', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
@@ -91,6 +33,7 @@ describe('Agent', () => {
ctx.on('session/flush', () => { flushes += 1 })
agent.inject([{ type: 'text', text: 'context' }], { source: { kind: 'plugin', plugin: 'p' } })
expect(agent.session.events.map(event => event.type)).toEqual(['user/message'])
expect(agent.status).toBe('idle')
expect(adapter.requests).toHaveLength(0)
@@ -99,120 +42,69 @@ describe('Agent', () => {
})
it('inject() defaults its source to an empty plugin, never user', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const ctx = await harness(new MockAdapter([textResponse('ok')]))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.inject([{ type: 'text', text: 'no explicit source' }])
const injected = agent.session.events.at(-1)!
expect(injected.type === 'user/message' && injected.data.source).toEqual({ kind: 'plugin', plugin: '' })
await agent.whenIdle()
const injected = agent.session.events.at(-1)
expect(injected?.type === 'user/message' && injected.data.source)
.toEqual({ kind: 'plugin', plugin: '' })
})
it('idle inject() rejects invalid input before append', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const ctx = await harness(new MockAdapter([textResponse('ok')]))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
expect(() => {
agent.inject([{ type: 'text', text: 'x', bad: 1n } as never], { source: { kind: 'plugin', plugin: 'p' } })
agent.inject(
[{ type: 'text', text: 'x', bad: 1n } as never],
{ source: { kind: 'plugin', plugin: 'p' } },
)
}).toThrow(/non-JSON-serializable/)
expect(agent.session.events).toHaveLength(0)
})
it('steer() when idle falls through to send() and starts a turn', async () => {
it('steer() while idle becomes a woken prompt turn', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
// steer while idle delegates to send
agent.steer([{ type: 'text', text: 'steer idle' }], { source: { kind: 'plugin', plugin: 'test' } })
await waitForIdle(ctx, agent)
agent.steer(
[{ type: 'text', text: 'steer idle' }],
{ source: { kind: 'plugin', plugin: 'test' } },
)
await agent.whenIdle()
// The message was recorded as a user-level message (send path)
expect(agent.session.events.some(e => e.type === 'user/message')).toBe(true)
expect(agent.session.events.some(event => event.type === 'user/message')).toBe(true)
expect(adapter.requests).toHaveLength(1)
})
it('disposer is idempotent (double-stop)', async () => {
// Create a bare Agent and start it through the package-internal
// test seam. Then call its disposer twice — the second call hits the
// early-return branch.
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
const session = ctx.sessions.create(SessionId('test'))
const prepared = prepareReactLoopAgent(
ctx, SessionId('bare'), { provider: 'mock', model: 'mock' }, session,
)
const { agent } = prepared
// Start the loop to get the disposer; the agent waits for messages
// (idle, never-resolving cancel), so it will stay idle.
prepared.markPublished()
prepared.start()
const dispose = prepared.dispose
// First dispose
const firstDisposal = dispose()
expect(agent.status).toBe('disposed')
await firstDisposal
// Second dispose — idempotent, no throw
await expect(dispose()).resolves.toBeUndefined()
expect(agent.status).toBe('disposed')
})
it('a pre-start disposal makes a later driver-start attempt inert', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('pre-start-dispose'))
const prepared = prepareReactLoopAgent(
ctx, SessionId('pre-start-dispose'), { provider: 'mock', model: 'mock' }, session,
)
await prepared.dispose()
expect(prepared.agent.status).toBe('disposed')
prepared.start()
const dispose = prepared.dispose
await dispose()
await expect(prepared.agent.done).resolves.toBeUndefined()
expect(prepared.agent.session.events).toEqual([])
await ctx.fiber.dispose()
})
it('setting the same status does not emit agent/status again', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
it('emits one running and idle transition for one completed turn', async () => {
const ctx = await harness(new MockAdapter([textResponse('ok')]))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const statuses: string[] = []
ctx.on('agent/status', (subject, status) => {
if (subject === agent) statuses.push(status)
})
send(agent, 'hi')
await waitForIdle(ctx, agent)
await agent.whenIdle()
// After the turn, agent is idle. Send again to trigger another attempt
// to go idle — but it's already idle, so no emission.
const idleTransitionCount = statuses.filter(s => s === 'idle').length
expect(idleTransitionCount).toBe(1) // only the final transition from running
expect(statuses).toEqual(['running', 'idle'])
})
it('whenIdle() resolves immediately when the agent is not running', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
it('whenIdle() resolves immediately without active work', async () => {
const ctx = await harness(new MockAdapter([textResponse('ok')]))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
// Fresh agent is idle — whenIdle() takes the not-running fast path and
// resolves without subscribing. await must not hang.
await agent.whenIdle()
expect(agent.status).not.toBe('running')
expect(agent.status).toBe('idle')
})
it('whenIdle() waits for queued work that has not flipped status yet', async () => {
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
it('whenIdle() waits for active work until explicit cancellation', async () => {
const ctx = await harness(new MockAdapter(['hang']))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
send(agent, 'queued')
@@ -221,151 +113,25 @@ describe('Agent', () => {
await Promise.resolve()
expect(settled).toBe(false)
await waitForStatus(ctx, agent, 'running')
agent.cancel({ kind: 'user' })
await idle
expect(settled).toBe(true)
expect(agent.status).toBe('idle')
})
it('whenIdle() awaits the running→idle transition, ignoring other subjects/running events', async () => {
const adapter = new MockAdapter([textResponse('ok'), textResponse('ok')])
const ctx = await harness(adapter)
it('contains a throwing status listener on both transitions', async () => {
const ctx = await harness(new MockAdapter([textResponse('ok')]))
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const other = ctx.agentLoop.create(SessionId('a2'), { provider: 'mock', model: 'mock' })
// Drive `agent` into `running`, then await whenIdle() — it subscribes to
// agent/status and resolves on the first transition out of running.
const running = new Promise<void>((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'running') { dispose(); resolve() }
})
ctx.on('agent/status', (_subject, status) => {
throw new Error(`bad ${status} listener`)
})
send(agent, 'go')
await running
expect(agent.status).toBe('running')
// While `agent`'s whenIdle is pending, churn `other` through running→idle:
// every status event it emits hits whenIdle's guard with `subject !== this`,
// so the wait must ignore them and only resolve on `agent`'s own idle.
send(other, 'go')
await agent.whenIdle()
expect(agent.status).toBe('idle')
})
it('whenIdle() subscribed while running resolves via done when the agent is then disposed', async () => {
// Covers the waiter's disposed arm: whenIdle() queues an internal waiter
// while running (not the fast path), then the disposer settles it and chains
// `done` (loop exit), not an eager resolve. A bare Agent + direct
// internal driver disposer keeps the emit synchronous.
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
const adapter = new MockAdapter(['hang'])
ctx.llm.registerAdapter(['mock'], adapter)
const session = ctx.sessions.create(SessionId('bare'))
const prepared = prepareReactLoopAgent(
ctx, SessionId('bare'), { provider: 'mock', model: 'mock' }, session,
expect(agent.status).toBe('idle')
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('agent event "agent/status" listener threw'),
)
const { agent } = prepared
prepared.markPublished()
prepared.start()
const dispose = prepared.dispose
agent.followup([{ type: 'text', text: 'go' }])
await new Promise(r => setTimeout(r, 30))
expect(agent.status).toBe('running')
const idle = agent.whenIdle() // queues an internal waiter (running)
const disposal = dispose() // settles the waiter synchronously; whenIdle chains done
await idle
expect(agent.status).toBe('disposed')
await disposal
})
it('whenIdle() subscribed while running survives a FIBER dispose (no hung promise)', async () => {
// The waiter is internal agent state, NOT an effect-scoped ctx.on listener:
// disposing the OWNING fiber runs the agent's listener disposers, which would
// have dropped a ctx.on-based waiter before the 'disposed' transition and
// hung the promise. With internal waiters, the fiber disposer still settles it.
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
let agent!: Agent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' })
}, { inject: ['agentLoop'] }))
send(agent, 'go')
await new Promise(r => setTimeout(r, 30))
expect(agent.status).toBe('running')
const idle = agent.whenIdle() // queued while running
await fiber.dispose() // tears the fiber down (drops agent listeners)
await idle // must resolve, not hang
expect(agent.status).toBe('disposed')
})
it('whenIdle() on a disposed agent awaits the loop exit (done), not just the status flip', async () => {
// The disposer emits agent/status('disposed') BEFORE the driver loop
// unwinds, so whenIdle() must chain `done` (true quiescence) on the
// disposed path. Dispose a running agent, then assert whenIdle() resolves
// only after `done` — i.e. the loop has actually exited.
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
let agent!: Agent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' })
}, { inject: ['agentLoop'] }))
send(agent, 'go')
await new Promise(r => setTimeout(r, 30))
let doneResolved = false
void driverDone(agent).then(() => { doneResolved = true })
await fiber.dispose() // sets status disposed, aborts, drains the loop
expect(agent.status).toBe('disposed')
// whenIdle() must not resolve before `done` has — chaining `done` is the
// quiescence guarantee. By here dispose() awaited the loop, so done is
// settled; whenIdle resolves and done is observed resolved.
await agent.whenIdle()
expect(doneResolved).toBe(true)
})
it('contains a throwing agent/status listener on the running transition', async () => {
const adapter = new MockAdapter([textResponse('ok')])
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' })
ctx.on('agent/status', (_subject, status) => {
if (status === 'running') throw new Error('bad running listener')
})
send(agent, 'go')
await agent.whenIdle()
expect(adapter.requests).toHaveLength(1)
expect(agent.status).toBe('idle')
expect(warn).toHaveBeenCalledWith(expect.stringContaining('agent event "agent/status" listener threw'))
warn.mockRestore()
})
it('contains a throwing agent/status listener on the idle transition', async () => {
const adapter = new MockAdapter([textResponse('ok')])
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' })
ctx.on('agent/status', (_subject, status) => {
if (status === 'idle') throw new Error('bad idle listener')
})
send(agent, 'go')
await agent.whenIdle()
expect(adapter.requests).toHaveLength(1)
expect(agent.status).toBe('idle')
expect(warn).toHaveBeenCalledWith(expect.stringContaining('agent event "agent/status" listener threw'))
warn.mockRestore()
})
})

View File

@@ -8,7 +8,7 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import LlmService, { type Message } from '@deepseek-ai/dsh-llm'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineContentToolFixture, TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools'
@@ -204,7 +204,7 @@ describe('Agent.cancel()', () => {
await disposalDone
await driverDone(agent)
expect(agent.status).toBe('disposed')
expect(agent.status).toBe('idle')
expect(agent.session.events.some(event => event.type === 'turn/start')).toBe(false)
expect(userTexts(agent)).toEqual([])
expect(adapter.requests).toHaveLength(0)
@@ -229,101 +229,6 @@ describe('Agent.cancel()', () => {
expect(agent.status).toBe('idle')
})
it('cancel() between consecutive turns restores idle and leaves idle steer usable', async () => {
const adapter = new MockAdapter([textResponse('first reply'), textResponse('steer reply')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('between-turn-cancel'), { provider: 'mock', model: 'mock' })
let rejectFirstFlush = true
ctx.on('session/flush', (session) => {
if (session !== agent.session || !rejectFirstFlush) return
rejectFirstFlush = false
throw new Error('first flush failed')
})
const cancelled = Promise.withResolvers<undefined>()
ctx.on('agent/error', (subject, _turn, _step, error) => {
if (subject !== agent || error.message !== 'first flush failed') return
// The first hop runs before runLoop resumes from runTurn; the second lands
// before its resolved waitForQueued continuation checks cancellation.
queueMicrotask(() => {
queueMicrotask(() => {
agent.cancel({ kind: 'user' })
cancelled.resolve(undefined)
})
})
})
const statuses: string[] = []
ctx.on('agent/status', (subject, status) => {
if (subject === agent) statuses.push(status)
})
send(agent, 'first')
send(agent, 'queued tail')
await cancelled.promise
expect(agent.status).toBe('idle')
expect(statuses).toEqual(['running', 'idle'])
expect(adapter.requests).toHaveLength(1)
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
expect(userTexts(agent)).toEqual(['first'])
let idleResolved = false
void agent.whenIdle().then(() => { idleResolved = true })
await Promise.resolve()
expect(idleResolved).toBe(true)
const idle = waitForIdle(ctx, agent)
agent.steer([{ type: 'text', text: 'idle steer' }])
await idle
expect(statuses).toEqual(['running', 'idle', 'running', 'idle'])
expect(adapter.requests).toHaveLength(2)
expect(userTexts(agent)).toEqual(['first', 'idle steer'])
})
it('an idle-listener replacement keeps whenIdle pending until the replacement turn finishes', async () => {
const adapter = new MockAdapter([textResponse('first reply'), textResponse('replacement reply')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('between-turn-idle-listener'), { provider: 'mock', model: 'mock' })
let rejectFirstFlush = true
ctx.on('session/flush', (session) => {
if (session !== agent.session || !rejectFirstFlush) return
rejectFirstFlush = false
throw new Error('first flush failed')
})
ctx.on('agent/error', (subject, _turn, _step, error) => {
if (subject !== agent || error.message !== 'first flush failed') return
queueMicrotask(() => {
queueMicrotask(() => { agent.cancel({ kind: 'user' }) })
})
})
const replacementRegistered = Promise.withResolvers<undefined>()
let replacementObservation: Promise<{ status: string; requests: number; turns: number }> | undefined
ctx.on('agent/status', (subject, status) => {
if (subject !== agent || status !== 'idle' || replacementObservation !== undefined) return
send(agent, 'replacement')
replacementObservation = agent.whenIdle().then(() => ({
status: agent.status,
requests: adapter.requests.length,
turns: agent.session.events.filter(event => event.type === 'turn/start').length,
}))
replacementRegistered.resolve(undefined)
})
send(agent, 'first')
send(agent, 'cancelled tail')
await replacementRegistered.promise
if (replacementObservation === undefined) throw new Error('idle listener did not register replacement work')
await expect(replacementObservation).resolves.toEqual({ status: 'idle', requests: 2, turns: 2 })
expect(userTexts(agent)).toEqual(['first', 'replacement'])
})
it('idle-listener cancellation settles its waiter without cancelling later work', async () => {
const adapter = new MockAdapter([textResponse('first reply'), textResponse('later reply')])
const ctx = await harness(adapter)
@@ -405,22 +310,6 @@ describe('Agent.cancel()', () => {
expect(adapter.requests).toHaveLength(1)
})
it('cancel() with no cause defaults to user when aborting an active turn', async () => {
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const reasons: TurnEndReason[] = []
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
send(agent, 'go')
await new Promise(r => setTimeout(r, 30))
agent.cancel({ kind: 'user' })
await waitForIdle(ctx, agent)
expect(reasons).toEqual([{ kind: 'aborted' }])
})
it('cancel from an assistant/message observer skips execution but balances replay', async () => {
const adapter = new MockAdapter([
toolCallResponse('c1', 'danger', {}),
@@ -496,98 +385,6 @@ describe('Agent.cancel()', () => {
expect(reasons.length).toBe(2)
})
it('cancel from inside the agent/session-prefix waterfall drops the step (prefix-composition window)', async () => {
const adapter = new MockAdapter([textResponse('should not stream')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
// Prefix composition runs before the pre-step seam on the instance's first
// step; a cancel landing inside it must drop the about-to-start step
// without running the seam or the model.
let streamed = false
ctx.on('session/event', (_s, event) => { if (event.type === 'assistant/chunk') streamed = true })
ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next) => {
agent.cancel({ kind: 'user' })
return next()
})
const reasons: TurnEndReason[] = []
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(streamed).toBe(false)
expect(reasons).toEqual([{ kind: 'aborted' }])
})
it('disposal from inside the agent/session-prefix waterfall ends the turn disposed (prefix-composition window)', async () => {
const adapter = new MockAdapter([textResponse('should not stream')])
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
ctx.llm.registerAdapter(['mock'], adapter)
const handle = await ctx.agents.create({
sessionId: SessionId('dispose-prefix-session'),
agentOptions: { provider: 'mock', model: 'mock' },
})
const agent = handle.agent
let disposalDone: Promise<void> | undefined
let streamed = false
ctx.on('session/event', (_s, event) => { if (event.type === 'assistant/chunk') streamed = true })
ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next) => {
disposalDone = handle.dispose()
return next()
})
send(agent, 'go')
await new Promise(resolve => setTimeout(resolve, 0))
await disposalDone
await driverDone(agent)
// No step opened, no model call ran, and the turn closed disposed.
expect(streamed).toBe(false)
expect(adapter.requests).toHaveLength(0)
const turnEnd = agent.session.events.findLast(e => e.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'disposed' })
})
it('a cancel-interrupted prefix composition is discarded: the next send recomposes and ships the fresh prefix (stale-cache guard)', async () => {
const adapter = new MockAdapter([textResponse('reply')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
// The interrupted first composition must not cache its degraded empty value;
// the next prompt recomposes and logs/sends the fresh prefix.
const opener: Message = { role: 'user', content: [{ type: 'text', text: 'fresh opener' }] }
let compositions = 0
ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next): Promise<Message[]> => {
compositions += 1
if (compositions === 1) {
agent.cancel({ kind: 'user' })
return next()
}
return [opener, ...await next()]
})
send(agent, 'dropped')
await waitForIdle(ctx, agent)
send(agent, 'real prompt')
await waitForIdle(ctx, agent)
expect(compositions).toBe(2)
expect(adapter.requests).toHaveLength(1)
expect(adapter.requests[0]?.messages[0]).toEqual(opener)
const headerEvent = agent.session.events.find(e => e.type === 'request/header')
expect(headerEvent?.type === 'request/header' && headerEvent.data.header.messagePrefix).toEqual([opener])
})
it('cancel from a synchronous turn/start session-event listener drops the step (step-start window)', async () => {
const adapter = new MockAdapter([textResponse('should not stream')])
const ctx = await harness(adapter)
@@ -681,11 +478,7 @@ describe('Agent.cancel()', () => {
expect(types.filter(t => t === 'step/start').length).toBe(types.filter(t => t === 'step/end').length)
})
it('cancel during the continuation window ends the turn aborted and runs no further step', async () => {
// A continuation-waterfall listener cancels DURING the continuation decision
// (the finished step's AbortController is already cleared), and votes to
// continue — but the turn-scoped marker checked right after must end the turn
// `aborted` and run NO second step.
it('cancel during the stopping window ends the turn aborted and runs no further step', async () => {
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
@@ -697,20 +490,18 @@ describe('Agent.cancel()', () => {
if (event.type === 'turn/end') reasons.push(event.data.reason)
})
let continued = false
ctx.on('agent/turn-continuation', async (subject, _turn, _default, _signal, next) => {
if (subject === agent && !continued) {
continued = true
let cancelled = false
ctx.on('agent/stopping', (subject) => {
if (subject === agent && !cancelled) {
cancelled = true
agent.cancel({ kind: 'user' })
return { action: 'continue' as const }
}
return next()
})
send(agent, 'go')
await waitForIdle(ctx, agent)
// Only ONE step ran (the second was cancelled in the continuation window),
// Only ONE step ran (the second was cancelled in the stopping window),
// and the shared turn signal classified the durable outcome as aborted.
expect(steps).toBe(1)
expect(reasons).toEqual([{ kind: 'aborted' }])
@@ -869,51 +660,7 @@ describe('Agent.cancel()', () => {
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted' })
})
it('retires turn cancellation before terminal publication and a blocked durability flush', async () => {
const adapter = new MockAdapter([textResponse('done')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('terminal-cancellation-authority'), { provider: 'mock', model: 'mock' })
const flushStarted = Promise.withResolvers<undefined>()
const releaseFlush = Promise.withResolvers<undefined>()
let abortedDuringTurnEnd: boolean | undefined
let cancelNotifications = 0
ctx.on('agent/cancel-requested', (subject) => {
if (subject === agent) cancelNotifications += 1
})
ctx.on('session/event', (session, event) => {
if (session !== agent.session || event.type !== 'turn/end') return
const signal = adapter.requests[0]?.signal
if (signal === undefined) throw new Error('model request omitted its turn signal')
agent.cancel({ kind: 'user' })
abortedDuringTurnEnd = signal.aborted
})
ctx.on('session/flush', async (session) => {
if (session !== agent.session) return
flushStarted.resolve(undefined)
await releaseFlush.promise
})
send(agent, 'finish before persistence drains')
await flushStarted.promise
const signal = adapter.requests[0]?.signal
if (signal === undefined) throw new Error('model request omitted its turn signal')
const idle = agent.whenIdle()
agent.cancel({ kind: 'user' })
expect(abortedDuringTurnEnd).toBe(false)
expect(signal.aborted).toBe(false)
expect(cancelNotifications).toBe(0)
expect(agent.session.events.findLast(event => event.type === 'turn/end')).toMatchObject({
data: { reason: { kind: 'completed' } },
})
releaseFlush.resolve(undefined)
await idle
expect(agent.status).toBe('idle')
})
it('records disposed when lifecycle teardown races an already-requested cancel', async () => {
it('preserves the first user cancellation when lifecycle teardown races it', async () => {
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
const handle = await ctx.agents.create({
@@ -928,19 +675,15 @@ describe('Agent.cancel()', () => {
await handle.dispose()
const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'disposed' })
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted' })
})
it.each([
'prompt-submit',
'system-prompt',
'session-prefix',
'pre-step',
'step',
'request',
'step-result',
'post-step',
'turn-continuation',
'turn-stop',
'stopping',
'tool',
] as const)('lets a cooperative %s boundary settle from the explicit turn signal', async (stage) => {
const adapter = new MockAdapter(stage === 'tool'
@@ -973,44 +716,19 @@ describe('Agent.cancel()', () => {
return next()
})
break
case 'session-prefix':
ctx.on('agent/session-prefix', async (subject, _prefix, signal, next) => {
if (subject === agent) await blockUntilAbort(signal)
return next()
})
break
case 'pre-step':
ctx.on('agent/pre-step', async (subject, _turn, _step, signal) => {
case 'step':
ctx.on('agent/step', async (subject, _turn, _step, signal) => {
if (subject === agent) await blockUntilAbort(signal)
})
break
case 'request':
ctx.on('agent/request', async (subject, _turn, _step, _config, signal, next) => {
ctx.on('agent/request', async (subject, _turn, _step, signal, next) => {
if (subject === agent) await blockUntilAbort(signal)
return next()
})
break
case 'step-result':
ctx.on('agent/step-result', async (subject, _turn, _step, _message, signal, next) => {
if (subject === agent) await blockUntilAbort(signal)
return next()
})
break
case 'post-step':
ctx.on('agent/post-step', async (subject, _turn, _step, signal) => {
if (subject !== agent) return
await blockUntilAbort(signal)
throw new Error('post-step failed after cancellation')
})
break
case 'turn-continuation':
ctx.on('agent/turn-continuation', async (subject, _turn, _decision, signal, next) => {
if (subject === agent) await blockUntilAbort(signal)
return next()
})
break
case 'turn-stop':
ctx.on('agent/turn-stop', async (subject, _turn, signal) => {
case 'stopping':
ctx.on('agent/stopping', async (subject, _turn, signal) => {
if (subject === agent) await blockUntilAbort(signal)
})
break
@@ -1030,11 +748,15 @@ describe('Agent.cancel()', () => {
send(agent, 'go')
await started.promise
const idle = waitForIdle(ctx, agent)
const idle = agent.whenIdle()
agent.cancel({ kind: 'user' })
await idle
const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted' })
if (stage === 'prompt-submit') {
expect(turnEnd).toBeUndefined()
} else {
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted' })
}
await ctx.fiber.dispose()
})
})

View File

@@ -89,7 +89,7 @@ describe('config-driven session id', () => {
const ctx = await makeCoreContext()
await ctx.plugin(SessionPersistenceJsonl, { root })
ctx.llm.registerAdapter(['mock'], new MockAdapter([textResponse('first'), textResponse('second')]))
const config = { agents: [{ id: 'main', sessionId: SessionId('config-exact-reload'), model: 'mock' }] }
const config = { agents: [{ id: 'main', sessionId: SessionId('config-exact-reload'), provider: 'mock', model: 'mock' }] }
const firstLoop = await ctx.plugin(AgentLoop, config)
let first: Agent | undefined
@@ -131,20 +131,26 @@ describe('config-driven session id', () => {
await expect.poll(() => ctx.agents.get(sessionId)).toBeDefined()
const first = ctx.agents.get(sessionId) as Agent
const flushGate = Promise.withResolvers<undefined>()
let flushStarted = false
ctx.on('session/flush', (session) => {
if (session !== first.session) return
flushStarted = true
return flushGate.promise
const cleanupGate = Promise.withResolvers<undefined>()
const cleanupStarted = Promise.withResolvers<undefined>()
first.ctx.effect(() => async () => {
cleanupStarted.resolve(undefined)
await cleanupGate.promise
})
first.inject([{ type: 'text', text: 'persist before replacement' }], {
source: { kind: 'plugin', plugin: 'test' },
})
expect(flushStarted).toBe(true)
await expect.poll(async () => {
try {
return JSON.stringify((await ctx.sessionPersistence.inspect(sessionId)).events)
} catch {
return ''
}
}).toContain('persist before replacement')
const firstDisposal = firstLoop.dispose()
await expect.poll(() => first.status).toBe('disposed')
await cleanupStarted.promise
expect(first.status).toBe('idle')
const failures: unknown[] = []
ctx.on('agent-loop/config-start-failed', (_id, error) => { failures.push(error) })
const secondLoop = await ctx.plugin(AgentLoop, config)
@@ -152,7 +158,7 @@ describe('config-driven session id', () => {
expect(ctx.agents.get(sessionId)).toBe(first)
expect(failures).toEqual([])
flushGate.resolve(undefined)
cleanupGate.resolve(undefined)
await firstDisposal
await expect.poll(() => ctx.agents.get(sessionId)).toBeDefined()
const second = ctx.agents.get(sessionId) as Agent
@@ -175,21 +181,31 @@ describe('config-driven session id', () => {
await expect.poll(() => ctx.agents.get(sessionId)).toBeDefined()
const first = ctx.agents.get(sessionId) as Agent
const flushGate = Promise.withResolvers<undefined>()
ctx.on('session/flush', (session) => {
if (session === first.session) return flushGate.promise
const cleanupGate = Promise.withResolvers<undefined>()
const cleanupStarted = Promise.withResolvers<undefined>()
first.ctx.effect(() => async () => {
cleanupStarted.resolve(undefined)
await cleanupGate.promise
})
first.inject([{ type: 'text', text: 'persist before cancellation' }], {
source: { kind: 'plugin', plugin: 'test' },
})
await expect.poll(async () => {
try {
return JSON.stringify((await ctx.sessionPersistence.inspect(sessionId)).events)
} catch {
return ''
}
}).toContain('persist before cancellation')
const firstDisposal = firstLoop.dispose()
await expect.poll(() => first.status).toBe('disposed')
await cleanupStarted.promise
expect(first.status).toBe('idle')
const secondLoop = await ctx.plugin(AgentLoop, config)
await secondLoop.dispose()
expect(ctx.agents.get(sessionId)).toBe(first)
flushGate.resolve(undefined)
cleanupGate.resolve(undefined)
await firstDisposal
expect(ctx.agents.get(sessionId)).toBeUndefined()
await ctx.fiber.dispose()
@@ -268,14 +284,14 @@ describe('config-driven session id', () => {
})
it.each(['resolve', 'reject'] as const)(
'joins an exact-id persistence lookup that will %s before AgentLoop disposal completes',
'abandons an exact-id persistence lookup that later %s when AgentLoop disposal starts',
async (outcome) => {
const root = await mkdtemp(join(tmpdir(), 'dsh-cfg-exact-dispose-'))
dirs.push(root)
const ctx = await makeCoreContext()
await ctx.plugin(SessionPersistenceJsonl, { root })
const listing = Promise.withResolvers<Awaited<ReturnType<typeof ctx.sessionPersistence.list>>>()
vi.spyOn(ctx.sessionPersistence, 'list').mockReturnValue(listing.promise)
const loading = Promise.withResolvers<Awaited<ReturnType<typeof ctx.sessionPersistence.load>>>()
vi.spyOn(ctx.sessionPersistence, 'load').mockReturnValue(loading.promise)
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
const failures: unknown[] = []
ctx.on('agent-loop/config-start-failed', (_sessionId, error) => { failures.push(error) })
@@ -283,14 +299,20 @@ describe('config-driven session id', () => {
const loop = await ctx.plugin(AgentLoop, {
agents: [{ id: 'main', sessionId: SessionId('config-exact-dispose'), model: 'mock' }],
})
let disposed = false
const disposal = loop.dispose().then(() => { disposed = true })
await loop.dispose()
if (outcome === 'resolve') {
loading.resolve({
meta: {
id: SessionId('config-exact-dispose'),
version: 0,
createdAt: Date.now(),
},
events: [],
})
} else {
loading.reject(new Error('startup cancelled by teardown'))
}
await Promise.resolve()
expect(disposed).toBe(false)
if (outcome === 'resolve') listing.resolve([])
else listing.reject(new Error('startup cancelled by teardown'))
await disposal
expect(ctx.agents.get(SessionId('config-exact-dispose'))).toBeUndefined()
expect(failures).toEqual([])
expect(warn).not.toHaveBeenCalled()

View File

@@ -1,17 +1,17 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService, { CallId, ContentBlock, MessageSource, ProviderRequestId, StreamChunk } from '@deepseek-ai/dsh-llm'
import LlmService, { CallId, MessageSource, ProviderRequestId, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool, TOOL_ABORTED, TOOL_ABORTED_BEFORE_DISPATCH, type PostToolDecision } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent, type ContinuationDecision } from '@deepseek-ai/dsh-agent'
import ToolRegistry, { defineContentToolFixture, type PostToolDecision } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { prepareReactLoopAgent } from '../src/agent.ts'
import { ReactLoopAgent } from '../src/agent.ts'
import InvariantService from '@deepseek-ai/dsh-invariants'
import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant'
import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
import { maxTokensResponse, MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
async function mountInvariants(ctx: Context): Promise<void> {
await ctx.plugin(InvariantService)
@@ -53,59 +53,8 @@ function send(agent: Agent, text: string) {
agent.followup([{ type: 'text', text }])
}
describe('session log records what agent/step-result actually produced', () => {
it('a step-result rewrite is what the log, derived history, and tool dispatch all see', async () => {
const original = textResponse('original')
original[original.length - 1] = { type: 'finish', reason: { kind: 'stop' }, replayState: { private: 'original-state' } }
const adapter = new MockAdapter([original, textResponse('done')])
const ctx = await harness(adapter)
const executed: string[] = []
ctx.tools.register(defineTool({
name: 'injected-tool',
description: '',
parameters: {},
async execute() {
executed.push('injected-tool')
return [{ type: 'text', text: 'ran' }]
},
}))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
// Plugin rewrites the message: replaces the text AND adds a tool call.
let rewritten = false
ctx.on('agent/step-result', async (_agent, _turn, _step, _message, _signal, next) => {
if (rewritten) return next()
rewritten = true
return {
role: 'assistant' as const,
content: [
{ type: 'text' as const, text: 'rewritten' },
{ type: 'tool-call' as const, id: CallId('c-injected'), name: 'injected-tool', arguments: '{}' },
],
}
})
send(agent, 'go')
await waitForIdle(ctx, agent)
// the injected tool call was dispatched…
expect(executed).toEqual(['injected-tool'])
// …and the session log recorded the REWRITTEN message, not the original
const recorded = agent.session.events.find(e => e.type === 'assistant/message')!
expect(JSON.stringify(recorded.data)).toContain('rewritten')
expect(JSON.stringify(recorded.data)).not.toContain('original')
expect(recorded.type === 'assistant/message' && recorded.data.provenance.replayState).toBeUndefined()
// tool/call + tool/result correlate with the injected call id
const callEvent = agent.session.events.find(e => e.type === 'tool/call')!
if (callEvent.type !== 'tool/call') throw new Error('wrong event type')
expect(callEvent.data.callId).toBe('c-injected')
// derived history shows the rewritten message (replay-correct)
const derived = agent.session.deriveMessages()
expect(JSON.stringify(derived)).toContain('rewritten')
expect(JSON.stringify(derived)).not.toContain('original')
})
it('records adapter replay state when step-result preserves the assembled content', async () => {
describe('assistant replay provenance', () => {
it('records adapter replay state with the assembled assistant content', async () => {
const response = textResponse('unchanged')
const replayState = { private: 'state' }
response[response.length - 1] = { type: 'finish', reason: { kind: 'stop' }, replayState }
@@ -116,7 +65,7 @@ describe('session log records what agent/step-result actually produced', () => {
send(agent, 'go')
await waitForIdle(ctx, agent)
const recorded = agent.session.events.find(e => e.type === 'assistant/message')
const recorded = agent.session.events.find(event => event.type === 'assistant/message')
expect(recorded?.type === 'assistant/message' && recorded.data.provenance).toEqual({
provider: 'mock', model: 'next-model', replayState,
})
@@ -124,215 +73,14 @@ describe('session log records what agent/step-result actually produced', () => {
provider: 'mock', model: 'next-model', replayState,
})
})
it('drops adapter replay state when step-result mutates assembled content in place', async () => {
const response = textResponse('original')
response[response.length - 1] = { type: 'finish', reason: { kind: 'stop' }, replayState: { private: 'state' } }
const adapter = new MockAdapter([response])
const ctx = await harness(adapter)
ctx.on('agent/step-result', async (_agent, _turn, _step, message) => {
const block = message.content[0]
if (block?.type === 'text') block.text = 'mutated'
return message
})
const agent = ctx.agentLoop.create(SessionId('mutated-replay-state'), { provider: 'mock', model: 'next-model' })
send(agent, 'go')
await waitForIdle(ctx, agent)
const recorded = agent.session.events.find(event => event.type === 'assistant/message')
expect(recorded?.type === 'assistant/message' && recorded.data.content).toEqual([{ type: 'text', text: 'mutated' }])
expect(recorded?.type === 'assistant/message' && recorded.data.provenance.replayState).toBeUndefined()
})
})
describe('successful provider completion survives agent/step-result failure', () => {
async function expectContentlessCompletionAnchor(
response: StreamChunk[],
id: string,
providerText: string,
): Promise<void> {
const adapter = new MockAdapter([response])
const ctx = await harness(adapter)
await mountInvariants(ctx)
const agent = ctx.agentLoop.create(SessionId(id), { provider: 'mock', model: 'mock' })
const failure = new Error(`${id} result processing failed`)
const reported: Error[] = []
ctx.on('agent/step-result', async () => {
throw failure
})
ctx.on('agent/error', (subject, _turn, _step, error) => {
if (subject === agent) reported.push(error)
})
send(agent, 'go')
await waitForIdle(ctx, agent)
const events = [...agent.session.events]
const chunks = events.filter(event => event.type === 'assistant/chunk')
const completions = events.filter(event => event.type === 'assistant/message')
expect(completions).toHaveLength(1)
expect(completions[0]?.type === 'assistant/message' && completions[0].data).toEqual({
turn: 1,
step: 1,
content: [],
provenance: { provider: 'mock', model: 'mock' },
usage: { inputTokens: 10, outputTokens: providerText.length },
})
expect(completions[0]?.sourceEventSeqs).toEqual(chunks.map(event => event.seq))
expect(agent.session.deriveMessages()).toEqual([
{ role: 'user', content: [{ type: 'text', text: 'go' }] },
])
expect(reported).toHaveLength(1)
expect(reported[0]).toBe(failure)
const turnEnd = events.findLast(event => event.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({
kind: 'error',
step: 1,
message: failure.message,
})
}
it('records one content-less anchor when ordinary stop result processing rejects', async () => {
const providerText = 'ordinary provider output'
await expectContentlessCompletionAnchor(
textResponse(providerText),
'a-step-result-stop-failure',
providerText,
)
})
it('records one content-less anchor when max-token result processing rejects', async () => {
const providerText = 'truncated provider output'
await expectContentlessCompletionAnchor(
maxTokensResponse(providerText),
'a-step-result-max-token-failure',
providerText,
)
})
})
describe('abort during tool execution ends the turn', () => {
it('balances a cancelled tool batch through context and post-step before closing', async () => {
const adapter = new MockAdapter([
// model asks for two tool calls in one step
[
{ type: 'block-start', index: 0, blockType: 'tool-call' },
{ type: 'block-end', index: 0, block: { type: 'tool-call', id: CallId('c1'), name: 'aborter', arguments: '{}' } },
{ type: 'block-start', index: 1, blockType: 'tool-call' },
{ type: 'block-end', index: 1, block: { type: 'tool-call', id: CallId('c2'), name: 'second', arguments: '{}' } },
{ type: 'finish', reason: { kind: 'tool-calls' } },
] satisfies StreamChunk[],
textResponse('should never be requested'),
])
const ctx = await harness(adapter)
const executed: string[] = []
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
ctx.tools.register(defineTool({
name: 'aborter',
description: '',
parameters: {},
async execute(_args, exec) {
executed.push('aborter')
exec.agent?.steer(
[{ type: 'text', text: 'steering before abort' }],
{ source: { kind: 'plugin', plugin: 'abort-test' } },
)
agent.cancel({ kind: 'user' })
return [{ type: 'text', text: 'done' }]
},
}))
ctx.on('tools/post-execute', async exec => ({
kind: 'accept',
additionalContexts: [{
content: [{ type: 'text', text: `context for ${exec.callId}` }],
source: { kind: 'plugin', plugin: 'abort-test' },
}],
}))
ctx.tools.register(defineTool({
name: 'second',
description: '',
parameters: {},
async execute() {
executed.push('second')
return [{ type: 'text', text: 'done' }]
},
}))
const reasons: TurnEndReason[] = []
const order: string[] = []
ctx.on('session/event', (session, event) => {
if (session !== agent.session) return
switch (event.type) {
case 'assistant/message': order.push('assistant/message'); break
case 'tool/call': order.push(`tool/call:${event.data.callId}`); break
case 'tool/result': {
const outcome = event.data.error?.code === TOOL_ABORTED
|| event.data.error?.code === TOOL_ABORTED_BEFORE_DISPATCH
? 'aborted'
: 'completed'
order.push(`tool/result:${event.data.callId}:${outcome}`)
break
}
case 'context/message': order.push('context/message'); break
case 'steering/message': order.push('steering/message'); break
case 'step/end': order.push('step/end'); break
case 'turn/end': {
reasons.push(event.data.reason)
order.push(`turn/end:${event.data.reason.kind}`)
break
}
}
})
let postSteps = 0
ctx.on('agent/post-step', (subject, turn, step, signal) => {
if (subject !== agent) return
postSteps += 1
expect({ turn, step, aborted: signal.aborted }).toEqual({ turn: 1, step: 1, aborted: true })
order.push('agent/post-step')
})
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(executed).toEqual(['aborter'])
expect(adapter.requests).toHaveLength(1)
expect(postSteps).toBe(1)
expect(order).toEqual([
'assistant/message',
'tool/call:c1',
'tool/result:c1:aborted',
'tool/call:c2',
'tool/result:c2:aborted',
'context/message',
'agent/post-step',
'step/end',
'turn/end:aborted',
])
expect(reasons).toEqual([{ kind: 'aborted' }])
const calls = agent.session.events.filter(event => event.type === 'tool/call')
const results = agent.session.events.filter(event => event.type === 'tool/result')
expect(calls.map(event => event.data.callId)).toEqual([CallId('c1'), CallId('c2')])
expect(results).toHaveLength(2)
expect(results[0]!.data).toMatchObject({
callId: CallId('c1'),
content: [{ type: 'text', text: 'Error: tool call aborted' }],
isError: true,
error: { name: 'AbortError', code: TOOL_ABORTED },
})
expect(results[1]!.data).toMatchObject({
callId: CallId('c2'),
isError: true,
error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
})
})
it('records context accepted before a tool-step abort in the same turn', async () => {
const adapter = new MockAdapter([toolCallResponse('c1', 'aborter', {})])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a-abort-injection'), { provider: 'mock', model: 'mock' })
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'aborter',
description: '',
parameters: {},
@@ -355,15 +103,16 @@ describe('abort during tool execution ends the turn', () => {
const events = [...agent.session.events]
expect(events
.filter(event => event.type === 'tool/result' || event.type === 'context/message'
.filter(event => event.type === 'tool/result'
|| (event.type === 'user/message' && event.data.source.kind === 'plugin')
|| event.type === 'step/end' || event.type === 'turn/end')
.map(event => event.type))
.toEqual(['tool/result', 'context/message', 'context/message', 'step/end', 'turn/end'])
.toEqual(['tool/result', 'user/message', 'step/end', 'turn/end'])
expect(events
.filter(event => event.type === 'context/message')
.map(event => event.data.content))
.flatMap(event => event.type === 'user/message' && event.data.source.kind === 'plugin'
? [event.data.content]
: []))
.toEqual([
[{ type: 'text', text: 'accepted before abort' }],
[{ type: 'text', text: 'accepted result context after abort' }],
])
})
@@ -378,7 +127,7 @@ describe('abort during tool execution ends the turn', () => {
] satisfies StreamChunk[]])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a-later-abort-context'), { provider: 'mock', model: 'mock' })
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'first',
description: '',
parameters: {},
@@ -386,7 +135,7 @@ describe('abort during tool execution ends the turn', () => {
return [{ type: 'text', text: 'first done' }]
},
}))
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'aborter',
description: '',
parameters: {},
@@ -411,15 +160,19 @@ describe('abort during tool execution ends the turn', () => {
const events = [...agent.session.events]
expect(events
.filter(event => event.type === 'tool/result' || event.type === 'context/message'
.filter(event => event.type === 'tool/result'
|| (event.type === 'user/message' && event.data.source.kind === 'plugin')
|| event.type === 'step/end' || event.type === 'turn/end')
.map(event => event.type))
.toEqual(['tool/result', 'tool/result', 'context/message', 'step/end', 'turn/end'])
expect(events.find(event => event.type === 'context/message')?.data.content)
.toEqual([{ type: 'text', text: 'accepted after first result' }])
.toEqual(['tool/result', 'tool/result', 'step/end', 'turn/end'])
expect(events.flatMap(event =>
event.type === 'user/message' && event.data.source.kind === 'plugin'
? [event.data.content]
: [])[0])
.toBeUndefined()
})
it('drains deferred context before disposal reaches quiescence', async () => {
it('records result context finalized after disposal cancellation', async () => {
const adapter = new MockAdapter([toolCallResponse('c1', 'waiter', {})])
const ctx = await harness(adapter)
const started = Promise.withResolvers<undefined>()
@@ -427,7 +180,7 @@ describe('abort during tool execution ends the turn', () => {
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(SessionId('a-dispose-injection'), { provider: 'mock', model: 'mock' })
}, { inject: ['agentLoop'] }))
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'waiter',
description: '',
parameters: {},
@@ -456,10 +209,10 @@ describe('abort during tool execution ends the turn', () => {
await fiber.dispose()
expect(agent.session.events
.filter(event => event.type === 'context/message')
.map(event => event.data.content))
.flatMap(event => event.type === 'user/message' && event.data.source.kind === 'plugin'
? [event.data.content]
: []))
.toEqual([
[{ type: 'text', text: 'accepted before disposal' }],
[{ type: 'text', text: 'accepted result context during disposal' }],
])
expect(agent.session.events.find(event => event.type === 'turn/end')?.data.reason)
@@ -479,7 +232,7 @@ describe('abort during tool execution ends the turn', () => {
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a-historical-tool-pair'), { provider: 'mock', model: 'mock' })
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'aborter',
description: '',
parameters: {},
@@ -488,7 +241,7 @@ describe('abort during tool execution ends the turn', () => {
return [{ type: 'text', text: 'done' }]
},
}))
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'second',
description: '',
parameters: {},
@@ -499,7 +252,7 @@ describe('abort during tool execution ends the turn', () => {
send(agent, 'leave an unmatched historical call')
await waitForIdle(ctx, agent)
ctx.on('agent/pre-step', (subject, turn) => {
ctx.on('agent/step', (subject, turn) => {
if (subject === agent && turn === 2) {
agent.inject([{ type: 'text', text: 'new turn context' }], { source: { kind: 'plugin', plugin: 'test' } })
}
@@ -507,14 +260,17 @@ 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.flatMap(event =>
event.type === 'user/message' && event.data.source.kind === 'plugin'
? [event.data.content]
: [])[0])
.toEqual([{ type: 'text', text: 'new turn context' }])
expect(JSON.stringify(adapter.requests[1]?.messages)).toContain('new turn context')
})
})
describe('steering from late extension points is never stranded', () => {
it('steer() from an agent/turn-continuation listener overrides a stop decision', async () => {
it('steer() from an agent/stopping listener continues the same turn', async () => {
const adapter = new MockAdapter([
textResponse('no tools, would stop here'),
textResponse('continued because of steering'),
@@ -523,12 +279,11 @@ describe('steering from late extension points is never stranded', () => {
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let steeredOnce = false
ctx.on('agent/turn-continuation', async (_agent, _turn, _decision, _signal, next) => {
ctx.on('agent/stopping', () => {
if (!steeredOnce) {
steeredOnce = true
agent.steer([{ type: 'text', text: 'one more thing' }])
}
return next()
})
send(agent, 'go')
@@ -600,22 +355,23 @@ describe('steering from late extension points is never stranded', () => {
})
describe('plugin exceptions are contained', () => {
it('a throwing agent/turn-continuation listener ends the turn with an error, loop survives', async () => {
it('a throwing agent/stopping listener ends the turn with an error, loop survives', async () => {
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let threwOnce = false
ctx.on('agent/turn-continuation', async (): Promise<ContinuationDecision> => {
ctx.on('agent/stopping', async () => {
if (!threwOnce) {
threwOnce = true
throw new Error('broken continuation plugin')
}
return { action: 'stop' }
})
const errors: Error[] = []
ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
ctx.on('agent/error', (_agent, _turn, _step, error) => {
if (error instanceof Error) errors.push(error)
})
send(agent, 'first')
await waitForIdle(ctx, agent)
@@ -628,45 +384,9 @@ describe('plugin exceptions are contained', () => {
expect(agent.status).toBe('idle')
})
it('a rejecting first-turn flush settles before the queued tail starts', async () => {
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const firstFlush = Promise.withResolvers<undefined>()
const releaseFirstFlush = Promise.withResolvers<undefined>()
let flushes = 0
ctx.on('session/flush', async (session) => {
if (session !== agent.session) return
flushes += 1
if (flushes === 1) {
firstFlush.resolve(undefined)
await releaseFirstFlush.promise
throw new Error('disk full')
}
})
const errors: Error[] = []
ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
const idle = waitForIdle(ctx, agent)
send(agent, 'first')
send(agent, 'second')
await firstFlush.promise
expect(adapter.requests).toHaveLength(1)
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
releaseFirstFlush.resolve(undefined)
await idle
expect(errors.map(e => e.message)).toEqual(['disk full'])
expect(adapter.requests).toHaveLength(2)
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2)
})
})
describe('disposed status is part of the agent/status contract', () => {
describe('disposal leaves the two-state status contract balanced', () => {
it('disposing the fiber ends the active turn and never starts its queued tail', async () => {
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
@@ -687,7 +407,7 @@ describe('disposed status is part of the agent/status contract', () => {
await fiber.dispose()
await driverDone(agent)
expect(statuses).toEqual(['running', 'disposed'])
expect(statuses).toEqual(['running', 'idle'])
expect(reasons).toEqual([{ kind: 'disposed' }])
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
const messages = agent.session.events
@@ -708,7 +428,7 @@ describe('disposed status is part of the agent/status contract', () => {
}, { inject: ['agentLoop'] }))
ctx.on('agent/status', (_agent, status) => {
if (status === 'disposed') throw new Error('broken status listener')
if (status === 'idle') throw new Error('broken status listener')
})
send(agent, 'go')
@@ -716,8 +436,7 @@ describe('disposed status is part of the agent/status contract', () => {
await fiber.dispose()
await driverDone(agent) // must not hang
expect(agent.status).toBe('disposed')
expect(ctx.agents.get(SessionId('scoped'))).toBeUndefined() // unregistered despite the throw
await expect.poll(() => ctx.agents.get(SessionId('scoped')) === undefined).toBe(true)
})
})
@@ -739,7 +458,9 @@ describe('adapter registration, routing, and accepted-input ownership', () => {
const agent = ctx.agentLoop.create(SessionId('a1'), {}) // no model
const errors: Error[] = []
ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
ctx.on('agent/error', (_agent, _turn, _step, error) => {
if (error instanceof Error) errors.push(error)
})
send(agent, 'go')
await waitForIdle(ctx, agent)
@@ -753,8 +474,8 @@ describe('adapter registration, routing, and accepted-input ownership', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), {}) // no model — router plugin decides
ctx.on('agent/request', async (_agent, _turn, _step, config, _signal) => {
return { ...config, provider: 'mock', model: 'mock' }
ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => {
return { ...await next(), provider: 'mock', model: 'mock' }
})
send(agent, 'go')
@@ -763,11 +484,11 @@ 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 exact message; 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' })
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'noop',
description: '',
parameters: {},
@@ -777,107 +498,30 @@ describe('adapter registration, routing, and accepted-input ownership', () => {
},
}))
const queuedSources: { source: MessageSource; steering: boolean }[] = []
ctx.on('agent/queued', (_agent, _content, info) => void queuedSources.push(info))
const queuedSources: MessageSource[] = []
const queuedShapes: string[][] = []
ctx.on('agent/inbox/enqueue', (_agent, message) => {
queuedSources.push(message.source)
queuedShapes.push(Object.keys(message).sort())
})
send(agent, 'go') // no explicit source → default {kind:'user'} must be visible
await waitForIdle(ctx, agent)
expect(queuedSources[0]).toEqual({ source: { kind: 'user' }, steering: false })
expect(queuedSources[1]).toEqual({ source: { kind: 'plugin', plugin: 'goal' }, steering: true })
expect(queuedSources).toEqual([
{ kind: 'user' },
{ kind: 'plugin', plugin: 'goal' },
])
expect(queuedShapes).toEqual([
['content', 'id', 'source'],
['content', 'id', 'source'],
])
// The drain appends the durable steering/message with the caller's source
// intact — the log, not a transient emit, is where consumers read it.
const steeringSources = agent.session.events.flatMap(e => e.type === 'steering/message' ? [e.data.source] : [])
expect(steeringSources).toEqual([{ kind: 'plugin', plugin: 'goal' }])
})
it('send() owns content and source before notification and delivery', async () => {
const adapter = new MockAdapter([textResponse('done')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('owned-send'), { provider: 'mock', model: 'mock' })
const content = [{ type: 'text' as const, text: 'accepted-send' }]
const source = { kind: 'plugin' as const, plugin: 'accepted-source' }
let notifiedContent: ContentBlock[] | undefined
let notifiedSource: MessageSource | undefined
ctx.on('agent/queued', (subject, acceptedContent, 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
notifiedSource = info.source
})
agent.followup(content, { source })
content[0]!.text = 'caller-mutated-send'
source.plugin = 'caller-mutated-source'
await waitForIdle(ctx, agent)
expect(notifiedContent).toEqual([{ type: 'text', text: 'accepted-send' }])
expect(notifiedSource).toEqual({ kind: 'plugin', plugin: 'accepted-source' })
expect(Object.isFrozen(notifiedContent)).toBe(true)
expect(Object.isFrozen(notifiedContent?.[0])).toBe(true)
expect(Object.isFrozen(notifiedSource)).toBe(true)
const recorded = agent.session.events.flatMap(event => event.type === 'user/message' ? [event.data] : [])
expect(recorded).toContainEqual({
content: [{ type: 'text', text: 'accepted-send' }],
source: { kind: 'plugin', plugin: 'accepted-source' },
})
const request = JSON.stringify(adapter.requests[0]!.messages)
expect(request).toContain('accepted-send')
expect(request).not.toContain('caller-mutated-send')
})
it('running steer() owns content and source before notification and delivery', async () => {
const adapter = new MockAdapter([toolCallResponse('c1', 'gate', {}), textResponse('done')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('owned-steer'), { provider: 'mock', model: 'mock' })
const entered = Promise.withResolvers<undefined>()
const release = Promise.withResolvers<undefined>()
ctx.tools.register(defineTool({
name: 'gate',
description: '',
parameters: {},
async execute() {
entered.resolve(undefined)
await release.promise
return [{ type: 'text', text: 'tool done' }]
},
}))
let notifiedContent: ContentBlock[] | undefined
let notifiedSource: MessageSource | undefined
ctx.on('agent/queued', (subject, acceptedContent, info) => {
if (subject !== agent || !info.steering) return
notifiedContent = acceptedContent
notifiedSource = info.source
})
agent.followup([{ type: 'text', text: 'start' }])
await entered.promise
expect(agent.status).toBe('running')
const content = [{ type: 'text' as const, text: 'accepted-steer' }]
const source = { kind: 'plugin' as const, plugin: 'accepted-source' }
agent.steer(content, { source })
content[0]!.text = 'caller-mutated-steer'
source.plugin = 'caller-mutated-source'
const idle = waitForIdle(ctx, agent)
release.resolve(undefined)
await idle
expect(notifiedContent).toEqual([{ type: 'text', text: 'accepted-steer' }])
expect(notifiedSource).toEqual({ kind: 'plugin', plugin: 'accepted-source' })
expect(Object.isFrozen(notifiedContent)).toBe(true)
expect(Object.isFrozen(notifiedContent?.[0])).toBe(true)
expect(Object.isFrozen(notifiedSource)).toBe(true)
const recorded = agent.session.events.flatMap(event => event.type === 'steering/message' ? [event.data] : [])
expect(recorded).toContainEqual({
turn: 1,
content: [{ type: 'text', text: 'accepted-steer' }],
source: { kind: 'plugin', plugin: 'accepted-source' },
})
const request = JSON.stringify(adapter.requests[1]!.messages)
expect(request).toContain('accepted-steer')
expect(request).not.toContain('caller-mutated-steer')
})
})
describe('turn numbering continues across seeded sessions', () => {
@@ -900,12 +544,9 @@ describe('turn numbering continues across seeded sessions', () => {
ctx2.llm.registerAdapter(['mock'], second)
const seeded = ctx2.sessions.create(SessionId('forked'), { seed: [...agent.session.events] })
const prepared = prepareReactLoopAgent(
const forked = new ReactLoopAgent(
ctx2, SessionId('forked-agent'), { provider: 'mock', model: 'mock' }, seeded,
)
const forked = prepared.agent
prepared.markPublished()
ctx2.effect(() => { prepared.start(); return prepared.dispose })
const turns: number[] = []
ctx2.on('session/event', (_s, event) => { if (event.type === 'turn/start') turns.push(event.data.turn) })
@@ -1076,7 +717,9 @@ describe('turn and step boundary recovery', () => {
if (event.type === 'step/start' && !threw) { threw = true; throw new Error('boom step-start') }
})
const errors: Error[] = []
ctx.on('agent/error', (_a, _t, _s, error) => void errors.push(error))
ctx.on('agent/error', (_a, _t, _s, error) => {
if (error instanceof Error) errors.push(error)
})
send(agent, 'go')
await waitForIdle(ctx, agent)
@@ -1107,7 +750,9 @@ describe('turn and step boundary recovery', () => {
}
})
const errors: Error[] = []
ctx.on('agent/error', (_agent, _turn, _step, error) => { errors.push(error) })
ctx.on('agent/error', (_agent, _turn, _step, error) => {
if (error instanceof Error) errors.push(error)
})
send(agent, 'go')
await waitForIdle(ctx, agent)
@@ -1123,41 +768,6 @@ describe('turn and step boundary recovery', () => {
expect(errors.map(error => error.message)).toEqual(['reject step-start before commit'])
})
it('a one-shot turn/end validation failure preserves the earlier turn error on retry', async () => {
const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', failure: { message: 'provider failed', code: 'UNKNOWN' } } }]
const adapter = new MockAdapter([errorStream])
const ctx = await balancedHarness(adapter)
const agent = ctx.agentLoop.create(SessionId('a-turnend-veto'), { provider: 'mock', model: 'mock' })
let rejected = false
ctx.on('internal/dispatch', (_mode, name, args) => {
if (name !== 'session/event') return
const event = args[1] as SessionEvent
if (event.type === 'turn/end' && !rejected) {
rejected = true
throw new Error('reject first turn-end')
}
})
const errors: Error[] = []
ctx.on('agent/error', (_agent, _turn, _step, error) => { errors.push(error) })
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(errors.map(error => error.message)).toEqual(['provider failed'])
expect(boundaryCounts(agent)).toMatchObject({
turnStart: 1,
turnEnd: 1,
stepStart: 1,
stepEnd: 1,
errors: 1,
})
const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toMatchObject({
kind: 'error',
failure: { message: 'provider failed', code: 'UNKNOWN' },
})
})
it('a one-shot step/end validation failure keeps the step open until retry succeeds', async () => {
const adapter = new MockAdapter([textResponse('completed before close validation')])
const ctx = await balancedHarness(adapter)
@@ -1172,7 +782,9 @@ describe('turn and step boundary recovery', () => {
}
})
const errors: Error[] = []
ctx.on('agent/error', (_agent, _turn, _step, error) => { errors.push(error) })
ctx.on('agent/error', (_agent, _turn, _step, error) => {
if (error instanceof Error) errors.push(error)
})
send(agent, 'go')
await waitForIdle(ctx, agent)
@@ -1261,14 +873,16 @@ describe('turn and step boundary recovery', () => {
}, { inject: ['agentLoop'] }))
let threw = false
ctx.on('agent/pre-step', () => {
ctx.on('agent/step', () => {
if (threw) return
threw = true
void fiber.dispose()
throw new Error('boom pre-step during disposal')
})
const errorEmits: Error[] = []
ctx.on('agent/error', (_a, _t, _s, error) => void errorEmits.push(error))
ctx.on('agent/error', (_a, _t, _s, error) => {
if (error instanceof Error) errorEmits.push(error)
})
send(agent, 'go')
await driverDone(agent)
@@ -1295,7 +909,9 @@ describe('turn and step boundary recovery', () => {
if (!threw && event.type === 'turn/start') { threw = true; throw new Error('boom turn/start append') }
})
const errors: Error[] = []
ctx.on('agent/error', (_a, _t, _s, error) => void errors.push(error))
ctx.on('agent/error', (_a, _t, _s, error) => {
if (error instanceof Error) errors.push(error)
})
send(agent, 'go')
await waitForIdle(ctx, agent)
@@ -1326,7 +942,9 @@ describe('turn and step boundary recovery', () => {
if (event.type === 'step/end' && !threw) { threw = true; throw new Error('boom step-end') }
})
const errors: Error[] = []
ctx.on('agent/error', (_a, _t, _s, error) => void errors.push(error))
ctx.on('agent/error', (_a, _t, _s, error) => {
if (error instanceof Error) errors.push(error)
})
send(agent, 'go')
await waitForIdle(ctx, agent)
@@ -1365,7 +983,9 @@ describe('turn and step boundary recovery', () => {
if (!threw && event.type === 'step/end') { threw = true; throw new Error('boom step/end listener') }
})
const errors: Error[] = []
ctx.on('agent/error', (_a, _t, _s, error) => void errors.push(error))
ctx.on('agent/error', (_a, _t, _s, error) => {
if (error instanceof Error) errors.push(error)
})
send(agent, 'go')
await waitForIdle(ctx, agent)
@@ -1419,7 +1039,7 @@ describe('tool result call identity', () => {
textResponse('done'),
])
const ctx = await harness(adapter)
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'echo',
description: 'echo',
parameters: { x: { type: 'number' } },
@@ -1458,34 +1078,6 @@ describe('tool result call identity', () => {
})
})
describe('surface: assistant/message records exact empty provenance when no chunks streamed', () => {
it('a step-result listener injecting content over an empty stream records sourceEventSeqs []', async () => {
// The explicit empty source set distinguishes a known empty provider
// stream from legacy events whose provenance was not recorded.
const adapter = new MockAdapter([[]])
const ctx = await harness(adapter)
await mountInvariants(ctx)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
ctx.on('agent/step-result', async (_agent, _turn, _step, _message, _signal) => ({
role: 'assistant' as const,
content: [{ type: 'text' as const, text: 'injected' }],
}))
send(agent, 'go')
await waitForIdle(ctx, agent)
const recorded = agent.session.events.find(e => e.type === 'assistant/message')!
expect(recorded.type).toBe('assistant/message')
expect(recorded.surfaceOp).toBe('append')
expect(recorded.sourceEventSeqs).toEqual([])
// The injected content reaches derived history.
expect(JSON.stringify(agent.session.deriveMessages())).toContain('injected')
})
})
describe('disposal and cancellation during pre-step assembly', () => {
it('disposal during system-prompt assembly drops the about-to-start step as disposed', { timeout: 30000 }, async () => {
// Start disposal, then release assembly. Do not await disposal first: it
@@ -1590,7 +1182,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
expect(reasons).toEqual([{ kind: 'aborted' }])
})
it('disposal during agent/pre-step seam ends the turn disposed', { timeout: 15000 }, async () => {
it('disposal during agent/step seam ends the turn disposed', { timeout: 15000 }, async () => {
// Start disposal, then release pre-step; awaiting disposal first would
// deadlock on the blocked driver.
const adapter = new MockAdapter(['hang'])
@@ -1607,7 +1199,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
await mountInvariants(ctx)
ctx.llm.registerAdapter(['mock'], adapter)
ctx.on('agent/pre-step', async () => {
ctx.on('agent/step', async () => {
await blocker
})
@@ -1642,7 +1234,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
// (turn boundaries have no agent/* mirror).
})
it('cancel during agent/pre-step seam ends the turn aborted', { timeout: 15000 }, async () => {
it('cancel during agent/step seam ends the turn aborted', { timeout: 15000 }, async () => {
// Release pre-step after cancellation to exercise the post-seam check.
const adapter = new MockAdapter(['hang'])
let releasePreStep!: () => void
@@ -1658,7 +1250,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
await mountInvariants(ctx)
ctx.llm.registerAdapter(['mock'], adapter)
ctx.on('agent/pre-step', async () => {
ctx.on('agent/step', async () => {
await blocker
})

View File

@@ -41,34 +41,6 @@ function send(agent: Agent, text: string) {
agent.followup([{ type: 'text', text }])
}
describe('inbox acceptance', () => {
it('rejects non-serializable content or source synchronously before notification or enqueue', async () => {
const adapter = new MockAdapter([textResponse('turn 1')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let queued = 0
ctx.on('agent/inbox/enqueue', () => { queued += 1 })
expect(() => {
agent.followup([{ type: 'text', text: 'first', bad: 1n } as never])
}).toThrow(/losslessly JSON-serializable/)
expect(() => {
agent.send([{ type: 'text', text: 'first' }], {
target: 'next-turn',
wakeup: true,
source: { kind: 'plugin', plugin: 'p', bad: 1n } as never,
})
}).toThrow(/losslessly JSON-serializable/)
expect(queued).toBe(0)
expect(agent.session.events).toHaveLength(0)
// The rejected value never woke or poisoned the loop; a valid message runs.
send(agent, 'second')
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(1)
})
})
describe('tool JSON parse', () => {
it('passes through non-JSON arguments string without crashing', async () => {
const adapter = new MockAdapter([

View File

@@ -1,155 +0,0 @@
/**
* 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)
})
})

View File

@@ -1,18 +1,18 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService, { CallId, type Message } from '@deepseek-ai/dsh-llm'
import LlmService, { CallId } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineContentToolFixture, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent, type ContinuationDecision, type PromptDecision, type SessionStartSource } from '@deepseek-ai/dsh-agent'
import AgentRegistry, { type Agent, type PromptDecision, type SessionStartSource } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
/**
* The interception seams introduced by the hooks taxonomy: `agent/prompt-submit`,
* `agent/session-start`, the reshaped `agent/turn-continuation`
* ({@link ContinuationDecision}), and the `tools/pre-execute` / `tools/post-execute`
* `agent/session-start`, `agent/stopping`, and the
* `tools/pre-execute` / `tools/post-execute`
* split with `additionalContexts` buffering. These verify the canonical event
* surface a hook bridge (or a native plugin) programs against, WITHOUT any
* external protocol — a native plugin uses the typed decisions directly.
@@ -127,7 +127,7 @@ describe('agent/prompt-submit', () => {
}))
let preStepDerived: string | undefined
ctx.on('agent/pre-step', (subject, _turn, step) => {
ctx.on('agent/step', (subject, _turn, step) => {
if (subject === agent && step === 1) preStepDerived = JSON.stringify(subject.session.deriveMessages())
})
@@ -140,7 +140,7 @@ describe('agent/prompt-submit', () => {
expect(preStepDerived).not.toContain('ORIGINAL prompt')
})
it('block drops the (only) prompt → zero-step turn ends rejected, model never called', async () => {
it('block drops the claimed prompt before any turn or model call', async () => {
const adapter = new MockAdapter([textResponse('should not run')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
@@ -152,26 +152,17 @@ describe('agent/prompt-submit', () => {
ctx.on('session/event', (_s, event: SessionEvent) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
agent.followup([{ type: 'text', text: 'do something' }])
await waitForIdle(ctx, agent)
await agent.whenIdle()
// the model was never called
expect(adapter.requests).toHaveLength(0)
// the turn opened and closed balanced, with no user/message and no step
const log = events(agent)
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 === 'turn/start')).toBe(false)
expect(log.some(e => e.type === 'turn/end')).toBe(false)
expect(log.some(e => e.type === 'user/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')
expect(blocked?.type === 'prompt/blocked' && blocked.data).toMatchObject({
content: [{ type: 'text', text: 'do something' }],
reason: 'blocked by policy',
})
// ended rejected with the block reason
expect(reasons).toEqual([{ kind: 'rejected', reason: 'blocked by policy' }])
const turnEnd = log.findLast(e => e.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'rejected', reason: 'blocked by policy' })
expect(log.some(e => e.type === 'prompt/blocked')).toBe(false)
expect(reasons).toEqual([])
})
it('adjacent blocked and allowed prompts keep independent turn outcomes', async () => {
@@ -187,7 +178,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) })
// Both sends land before the driver wakes, but each remains its own turn.
// The rejected admission is dropped; the allowed prompt owns the only turn.
send(agent, 'secret')
send(agent, 'safe')
await waitForIdle(ctx, agent)
@@ -198,21 +189,12 @@ describe('agent/prompt-submit', () => {
expect(userMsgs).toHaveLength(1)
expect(userMsgs[0]?.type === 'user/message' && userMsgs[0].data.content).toEqual([{ type: 'text', text: 'safe' }])
expect(adapter.requests.length).toBeGreaterThanOrEqual(1)
// the blocked prompt is durably recorded, with its content + reason
const blocked = log.filter(e => e.type === 'prompt/blocked')
expect(blocked).toHaveLength(1)
expect(blocked[0]?.type === 'prompt/blocked' && blocked[0].data).toMatchObject({
content: [{ type: 'text', text: 'secret' }],
reason: 'policy: no secrets',
})
expect(log.filter(e => e.type === 'turn/start')).toHaveLength(2)
expect(reasons).toEqual([
{ kind: 'rejected', reason: 'policy: no secrets' },
{ kind: 'completed' },
])
expect(log.filter(e => e.type === 'prompt/blocked')).toHaveLength(0)
expect(log.filter(e => e.type === 'turn/start')).toHaveLength(1)
expect(reasons).toEqual([{ kind: 'completed' }])
})
it('a throwing prompt-submit listener ends its turn balanced while an adjacent message survives', async () => {
it('a throwing prompt-submit listener drops that admission while an adjacent message survives', async () => {
const adapter = new MockAdapter([textResponse('after')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
@@ -225,7 +207,9 @@ describe('agent/prompt-submit', () => {
const errors: Error[] = []
const reasons: TurnEndReason[] = []
const statuses: string[] = []
ctx.on('agent/error', (_a, _t, _s, error) => void errors.push(error))
ctx.on('agent/error', (_a, _t, _s, error) => {
if (error instanceof Error) errors.push(error)
})
ctx.on('agent/status', (subject, status) => { if (subject === agent) statuses.push(status) })
ctx.on('session/event', (session, event) => {
if (session === agent.session && event.type === 'turn/end') reasons.push(event.data.reason)
@@ -235,16 +219,11 @@ describe('agent/prompt-submit', () => {
send(agent, 'first')
send(agent, 'second')
await idle
expect(errors.map(e => e.message)).toEqual(['prompt hook broke'])
// The failed prompt forms one balanced error turn; the adjacent prompt forms
// the following normal turn without an intermediate idle transition.
expect(errors).toEqual([])
const log = events(agent)
expect(log.filter(e => e.type === 'turn/start')).toHaveLength(2)
expect(log.filter(e => e.type === 'turn/end')).toHaveLength(2)
expect(reasons).toEqual([
{ kind: 'error', step: 0, message: 'prompt hook broke' },
{ kind: 'completed' },
])
expect(log.filter(e => e.type === 'turn/start')).toHaveLength(1)
expect(log.filter(e => e.type === 'turn/end')).toHaveLength(1)
expect(reasons).toEqual([{ kind: 'completed' }])
expect(statuses).toEqual(['running', 'idle'])
expect(adapter.requests).toHaveLength(1)
expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('second')
@@ -306,236 +285,6 @@ describe('agent/session-start', () => {
})
})
describe('agent/session-prefix', () => {
it('dispatches to global and matching agent-scope listeners only', async () => {
const adapter = new MockAdapter([textResponse('a done'), textResponse('b done')])
const ctx = await harness(adapter)
const agentA = ctx.agentLoop.create(SessionId('prefix-a'), { provider: 'mock', model: 'mock' })
const agentB = ctx.agentLoop.create(SessionId('prefix-b'), { provider: 'mock', model: 'mock' })
const seen: string[] = []
ctx.on('agent/session-prefix', async (agent, _prefix, _signal, next) => {
seen.push(`global:${agent.id}`)
return next()
})
agentA.ctx.on('agent/session-prefix', async (agent, _prefix, _signal, next) => {
seen.push(`a:${agent.id}`)
return next()
})
agentB.ctx.on('agent/session-prefix', async (agent, _prefix, _signal, next) => {
seen.push(`b:${agent.id}`)
return next()
})
send(agentA, 'run a')
await waitForIdle(ctx, agentA)
send(agentB, 'run b')
await waitForIdle(ctx, agentB)
expect(seen).toEqual([
'global:prefix-a', 'a:prefix-a',
'global:prefix-b', 'b:prefix-b',
])
})
it('composes once per loop instance and fronts every request; the header records it; history stays untouched', async () => {
const adapter = new MockAdapter([
toolCallResponse('c1', 'echo', { text: 'ping' }),
textResponse('done'),
textResponse('again'),
])
const ctx = await harness(adapter)
ctx.tools.register(defineContentToolFixture({
name: 'echo', description: 'echo', parameters: { text: { type: 'string' } },
async execute(args) { return [{ type: 'text', text: String(args.text) }] },
}))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const reminder: Message = { role: 'user', content: [{ type: 'text', text: '<system-reminder>catalog</system-reminder>' }] }
let composed = 0
ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next): Promise<Message[]> => {
composed += 1
return [...await next(), reminder]
})
send(agent, 'go')
await waitForIdle(ctx, agent)
send(agent, 'next turn')
await waitForIdle(ctx, agent)
// Three requests (two turns), ONE composition: the frozen product is
// reused verbatim, so the prefix cannot drift mid-session.
expect(adapter.requests).toHaveLength(3)
expect(composed).toBe(1)
for (const request of adapter.requests) {
expect(request.messages[0]).toEqual(reminder)
}
// The anchoring snapshot is the prefix's durable record — and the ONLY
// header event: reuse means no changed snapshot ever.
const headerEvents = events(agent).filter(e => e.type === 'request/header')
expect(headerEvents).toHaveLength(1)
expect(headerEvents[0]?.type === 'request/header' && headerEvents[0].data.header.messagePrefix).toEqual([reminder])
// Never session history: the derivation starts at the real user prompt.
expect(agent.session.deriveMessages()[0]).toEqual({ role: 'user', content: [{ type: 'text', text: 'go' }] })
})
it('composes before the first pre-step and records the prefix on the request header', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const reminder: Message = { role: 'user', content: [{ type: 'text', text: 'opener' }] }
const order: string[] = []
ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next): Promise<Message[]> => {
order.push('compose')
return [reminder, ...await next()]
})
ctx.on('agent/pre-step', () => {
order.push('pre-step')
})
send(agent, 'hi')
await waitForIdle(ctx, agent)
expect(order).toEqual(['compose', 'pre-step'])
expect(agent.session.requestHeader()?.messagePrefix).toEqual([reminder])
})
it('the canonical prepend pattern composes contributions in registration order', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
// Both listeners use the canonical `[mine, ...await next()]` prepend: the
// waterfall unwinds innermost-first (the second listener's array is built
// first), so prepending puts the FIRST-registered contribution first.
ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next): Promise<Message[]> => {
return [{ role: 'user', content: [{ type: 'text', text: 'first' }] }, ...await next()]
})
ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next): Promise<Message[]> => {
return [{ role: 'user', content: [{ type: 'text', text: 'second' }] }, ...await next()]
})
send(agent, 'hi')
await waitForIdle(ctx, agent)
const texts = adapter.requests[0]!.messages.map(m => m.content[0]?.type === 'text' ? m.content[0].text : '')
expect(texts).toEqual(['first', 'second', 'hi'])
})
it('with no contributions the header omits messagePrefix and the request is the bare derivation', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
// A listener that delegates without contributing — the canonical no-op.
ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next) => next())
send(agent, 'hi')
await waitForIdle(ctx, agent)
const headerEvent = events(agent).find(e => e.type === 'request/header')
expect(headerEvent?.type === 'request/header' && 'messagePrefix' in headerEvent.data.header).toBe(false)
expect(adapter.requests[0]!.messages).toEqual([{ role: 'user', content: [{ type: 'text', text: 'hi' }] }])
})
it('the frozen seed rejects in-place mutation — a contribution is a returned extension', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let mutationError: unknown
ctx.on('agent/session-prefix', async (_agent, prefix, _signal, next): Promise<Message[]> => {
try {
prefix.push({ role: 'user', content: [{ type: 'text', text: 'smuggled' }] })
} catch (error: unknown) {
mutationError = error
}
return next()
})
send(agent, 'hi')
await waitForIdle(ctx, agent)
expect(mutationError).toBeInstanceOf(TypeError)
expect(adapter.requests[0]!.messages).toEqual([{ role: 'user', content: [{ type: 'text', text: 'hi' }] }])
})
it('mutating a listener-held reference after composition cannot alter later requests (the cache is a frozen clone)', async () => {
const adapter = new MockAdapter([
toolCallResponse('c1', 'echo', { text: 'ping' }),
textResponse('done'),
])
const ctx = await harness(adapter)
ctx.tools.register(defineContentToolFixture({
name: 'echo', description: 'echo', parameters: { text: { type: 'string' } },
async execute(args) { return [{ type: 'text', text: String(args.text) }] },
}))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const held: Message = { role: 'user', content: [{ type: 'text', text: 'v1' }] }
ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next): Promise<Message[]> => [...await next(), held])
send(agent, 'go')
await waitForIdle(ctx, agent)
// The listener mutates the object it contributed AFTER composition; the
// cached prefix is a deep-frozen clone, so step 2's request is unchanged.
held.content = [{ type: 'text', text: 'v2' }]
expect(adapter.requests[1]!.messages[0]).toEqual({ role: 'user', content: [{ type: 'text', text: 'v1' }] })
expect(events(agent).filter(e => e.type === 'request/header')).toHaveLength(1)
})
})
describe('agent/turn-continuation (ContinuationDecision)', () => {
it('a continue decision with a reason records next-step steering in the same turn', async () => {
const adapter = new MockAdapter([textResponse('step 1 no tools'), textResponse('step 2')])
const ctx = await harness(adapter)
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): Promise<ContinuationDecision> => {
if (!forced) {
forced = true
return { action: 'continue', reason: { content: [{ type: 'text', text: 'keep going on the goal' }], source: { kind: 'plugin', plugin: 'goal' } } }
}
return next()
})
send(agent, 'go')
await waitForIdle(ctx, agent)
const log = events(agent)
// The continuation stays in the turn, is logged with provenance before step 2,
// and reaches that step's request.
expect(log.filter(e => e.type === 'turn/start')).toHaveLength(1)
expect(log.filter(e => e.type === 'step/start')).toHaveLength(2)
const steering = log.find(e => e.type === 'steering/message')
expect(steering?.type === 'steering/message' && steering.data.content).toEqual([{ type: 'text', text: 'keep going on the goal' }])
expect(steering?.type === 'steering/message' && steering.data.source).toEqual({ kind: 'plugin', plugin: 'goal' })
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('keep going on the goal')
})
it('a stop decision ends the turn even when the step had tool calls', async () => {
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', { text: 'hi' })])
const ctx = await harness(adapter)
ctx.tools.register(defineContentToolFixture({
name: 'echo', description: 'echo', parameters: { text: { type: 'string' } },
async execute(args) { return [{ type: 'text', text: String(args.text) }] },
}))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
ctx.on('agent/turn-continuation', async (): Promise<ContinuationDecision> => ({ action: 'stop' }))
send(agent, 'go')
await waitForIdle(ctx, agent)
// default would have continued (had tool calls), but the stop decision wins
expect(adapter.requests).toHaveLength(1)
expect(events(agent).some(e => e.type === 'tool/result')).toBe(true)
})
})
describe('tool additionalContexts buffering across a step', () => {
it('appends each call\'s contexts only AFTER all tool/results, preserving adjacency', async () => {
// One assistant step with TWO tool calls; the second model response stops.
@@ -703,7 +452,7 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se
expect(log.some(e => e.type.startsWith('hook/'))).toBe(false)
})
it('the same plugin blocks a destructive prompt → rejected turn, model never called', async () => {
it('the same plugin blocks a destructive prompt before a turn or model call', async () => {
const adapter = new MockAdapter([textResponse('should not run')])
const ctx = await harness(adapter)
await ctx.plugin(NativeGuard)
@@ -713,10 +462,10 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se
ctx.on('session/event', (_s, event: SessionEvent) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
send(agent, 'run rm -rf /')
await waitForIdle(ctx, agent)
await agent.whenIdle()
expect(adapter.requests).toHaveLength(0)
expect(reasons).toEqual([{ kind: 'rejected', reason: 'destructive prompt blocked' }])
expect(reasons).toEqual([])
})
it('HMR-safety: disposing the plugin fiber removes all four listeners', async () => {

View File

@@ -3,7 +3,7 @@ import { Context } from 'cordis'
import LlmService, { CallId, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
@@ -89,7 +89,7 @@ describe('agent loop', () => {
textResponse('done'),
])
const ctx = await harness(adapter)
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'echo',
description: 'echo back',
parameters: { text: { type: 'string' } },
@@ -120,39 +120,13 @@ describe('agent loop', () => {
expect(types).toContain('tool/result')
})
it('threads a tool-attached meta (execute object return) onto the tool/result event', async () => {
const adapter = new MockAdapter([
toolCallResponse('c1', 'writer', { path: 'a.txt' }, 'writing'),
textResponse('done'),
])
const ctx = await harness(adapter)
// A tool that returns the { content, meta } object form: the loop must
// persist `meta` on the tool/result event so a UI reproduces the card on replay.
ctx.tools.register(defineTool({
name: 'writer',
description: 'writes a file',
parameters: { path: { type: 'string' } },
async execute() {
return { content: [{ type: 'text', text: 'ok' }], meta: { diffs: [{ path: 'a.txt', oldText: null, newText: 'x' }] } }
},
}))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
send(agent, 'use the tool')
await waitForIdle(ctx, agent)
const toolResult = agent.session.events.find(e => e.type === 'tool/result')
expect(toolResult?.type === 'tool/result' && toolResult.data.meta)
.toEqual({ diffs: [{ path: 'a.txt', oldText: null, newText: 'x' }] })
})
it('renders harness identity, then the persona, then tool guidance — with {{variables}} resolved', async () => {
const adapter = new MockAdapter([textResponse('ok')])
// The persona is a TEMPLATE: {{model}} is the loop-registered variable
// projecting this agent's configured model, so the model knows its own name.
const ctx = await harness(adapter, 'You are a test agent on {{model}}.')
ctx.systemPrompt.section({ name: 'tool:noop', order: 100, text: 'Use the noop tool wisely.' })
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'noop',
description: 'does nothing',
parameters: {},
@@ -191,7 +165,9 @@ describe('agent loop', () => {
const adapter = new MockAdapter([textResponse('ok after rescue')])
const ctx = await harness(adapter, 'In {{cwd}}.')
const errors: Error[] = []
ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
ctx.on('agent/error', (_agent, _turn, _step, error) => {
if (error instanceof Error) errors.push(error)
})
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
send(agent, 'hi')
@@ -231,7 +207,8 @@ describe('agent loop', () => {
assembly.variables['model'] = 'mock'
return next()
})
ctx.on('agent/request', async (_agent, _turn, _step, config, _signal, _next) => {
ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => {
const config = await next()
return { ...config, provider: 'mock', model: 'mock' }
})
const agent = ctx.agentLoop.create(SessionId('a-late-model'), {})
@@ -244,44 +221,6 @@ describe('agent loop', () => {
expect(adapter.requests[0]!.system).toBe('You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou run on mock.')
})
it.each([
['BigInt', { n: 1n }],
['Map', new Map([['key', 'value']])],
['class instance', new (class ResultMeta { x = 1 })()],
])('normalizes non-JSON tool meta (%s) before the durable result commit', async (_kind, meta) => {
const adapter = new MockAdapter([
toolCallResponse('bad-meta-call', 'bad-meta', {}, 'calling'),
textResponse('recovered'),
])
const ctx = await harness(adapter)
ctx.tools.register(defineTool({
name: 'bad-meta',
description: 'returns invalid durable metadata',
parameters: {},
execute: () => Promise.resolve({ content: [{ type: 'text' as const, text: 'apparent success' }], meta }),
}))
const agent = ctx.agentLoop.create(SessionId('bad-meta-agent'), { provider: 'mock', model: 'mock' })
send(agent, 'use the tool')
await waitForIdle(ctx, agent)
const result = agent.session.events.find(event => event.type === 'tool/result')
expect(result?.type).toBe('tool/result')
if (result?.type === 'tool/result') {
expect(result.data.callId).toBe('bad-meta-call')
expect(result.data.isError).toBe(true)
expect(result.data.meta).toBeUndefined()
expect(result.data.content).toEqual([{
type: 'text',
text: 'Error: tool result must be losslessly JSON-serializable',
}])
}
// The normalized failure was durably logged and fed back to the model; the
// turn continued normally instead of failing after an apparent success.
expect(adapter.requests).toHaveLength(2)
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('losslessly JSON-serializable')
})
it('omits the system field when a system-prompt/assemble veto empties the assembly', async () => {
// The documented escape valve: a deployment that must drop the harness
// openers short-circuits the assemble waterfall; the request then carries
@@ -326,7 +265,7 @@ describe('agent loop', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'slow',
description: '',
parameters: {},
@@ -452,7 +391,7 @@ describe('agent loop', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let visibleDuringTool = false
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'noticer',
description: 'injects a notice',
parameters: {},
@@ -484,12 +423,9 @@ describe('agent loop', () => {
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 === 'user/message' && contexts[0].data).toMatchObject({
meta,
})
expect(contexts.flatMap(event => event.type === 'user/message' ? event.data.content : []))
.toEqual([
{ type: 'text', text: 'mid-turn notice' },
{ type: 'text', text: 'mutated after inject' },
{ type: 'text', text: 'second notice' },
])
@@ -502,7 +438,7 @@ describe('agent loop', () => {
? [index]
: [])
expect(resultIndex).toBeGreaterThanOrEqual(0)
expect(contextIndexes).toHaveLength(2)
expect(contextIndexes).toHaveLength(1)
expect(contextIndexes.every(index => index > resultIndex)).toBe(true)
})
@@ -513,7 +449,7 @@ describe('agent loop', () => {
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('invalid-context'), { provider: 'mock', model: 'mock' })
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'invalid-injector',
description: 'attempts an invalid context injection',
parameters: {},
@@ -533,8 +469,7 @@ describe('agent loop', () => {
expect(agent.session.events.some(event => event.type === 'user/message' && event.data.source.kind === 'plugin')).toBe(false)
})
it('agent/turn-continuation can force-continue (/loop pattern) and force-stop', async () => {
// force-continue: model never calls tools, but a plugin forces 3 steps
it('agent/stopping can steer another step (/loop pattern)', async () => {
const adapter = new MockAdapter([
textResponse('step 1'),
textResponse('step 2'),
@@ -545,9 +480,12 @@ describe('agent loop', () => {
let steps = 0
ctx.on('session/event', (_session, event) => { if (event.type === 'step/end') steps++ })
ctx.on('agent/turn-continuation', async (_agent, _turn, _defaultDecision, _signal, next) => {
if (steps < 3) return { action: 'continue' as const }
return next()
ctx.on('agent/stopping', (subject) => {
if (steps < 3) {
subject.steer([{ type: 'text', text: 'continue' }], {
source: { kind: 'plugin', plugin: 'loop-test' },
})
}
})
send(agent, 'go')
@@ -556,26 +494,25 @@ describe('agent loop', () => {
expect(adapter.requests).toHaveLength(3)
})
it('agent/turn-continuation can veto continuation despite tool calls (budget-guard pattern)', async () => {
it('a tool can conclude the turn despite owing a follow-up request', async () => {
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', { text: 'x' })])
const ctx = await harness(adapter)
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'echo',
description: '',
parameters: { text: { type: 'string' } },
async execute(args) {
async execute(args, exec) {
exec.concludeTurn()
return [{ type: 'text', text: String(args.text) }]
},
}))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
ctx.on('agent/turn-continuation', async () => ({ action: 'stop' }) as const)
send(agent, 'go')
await waitForIdle(ctx, agent)
// only one model call despite the tool call requesting a follow-up
expect(adapter.requests).toHaveLength(1)
// tool still executed before the decision
// The tool still executes and durably records its result.
expect(agent.session.events.some(e => e.type === 'tool/result')).toBe(true)
})
@@ -584,7 +521,8 @@ describe('agent loop', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
ctx.on('agent/request', async (_agent, _turn, _step, config, _signal, _next) => {
ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => {
const config = await next()
// The seed is frozen — config is not a mutable per-call knob; a switch
// is proposed by returning a replacement, and the loop logs it.
expect(Object.isFrozen(config)).toBe(true)
@@ -601,20 +539,20 @@ describe('agent loop', () => {
expect(headerEvent?.type === 'request/header' && headerEvent.data.header.config.model).toBe('other-model')
})
it('agent/pre-step fires once per step before the step is opened', async () => {
it('agent/step fires once per step before the step is opened', async () => {
const adapter = new MockAdapter([
toolCallResponse('c1', 'echo', {}, 'calling echo'),
textResponse('done'),
])
const ctx = await harness(adapter)
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'echo', description: 'echo', parameters: {},
async execute() { return [{ type: 'text', text: 'echoed' }] },
}))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const fires: { turn: number; step: number; signal: AbortSignal }[] = []
ctx.on('agent/pre-step', (subject, turn, step, signal) => {
ctx.on('agent/step', (subject, turn, step, signal) => {
if (subject === agent) fires.push({ turn, step, signal })
})
@@ -628,7 +566,7 @@ describe('agent loop', () => {
expect(fires.every(({ signal }) => signal instanceof AbortSignal)).toBe(true)
})
it('agent/pre-step fires BEFORE the step it precedes opens (events land outside the step)', async () => {
it('agent/step fires BEFORE the step it precedes opens (events land outside the step)', async () => {
// The append lands before step/start, yet derive happens afterwards and the
// same step's request must include it.
const adapter = new MockAdapter([textResponse('ok')])
@@ -636,7 +574,7 @@ describe('agent loop', () => {
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let injected = false
ctx.on('agent/pre-step', (subject) => {
ctx.on('agent/step', (subject) => {
if (subject === agent && !injected) {
injected = true
subject.session.append('user/message', {
@@ -662,7 +600,7 @@ describe('agent loop', () => {
expect(injectedSeq).toBeLessThan(firstStepStartSeq)
})
it('a throwing agent/pre-step listener ends the turn (error), not the loop', async () => {
it('a throwing agent/step listener ends the turn (error), not the loop', async () => {
// Before step/start, a pre-step throw reaches the turn catch: no step needs
// closing, the turn records error, and the loop remains available.
const adapter = new MockAdapter([textResponse('second turn ok')])
@@ -670,12 +608,14 @@ describe('agent loop', () => {
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let throwOnce = true
ctx.on('agent/pre-step', () => {
ctx.on('agent/step', () => {
if (throwOnce) { throwOnce = false; throw new Error('boom in pre-step') }
})
const errors: Error[] = []
ctx.on('agent/error', (_a, _t, _s, error) => void errors.push(error))
ctx.on('agent/error', (_a, _t, _s, error) => {
if (error instanceof Error) errors.push(error)
})
send(agent, 'first')
await waitForIdle(ctx, agent)
@@ -750,9 +690,12 @@ describe('agent loop', () => {
ctx.on('session/event', (_session, event) => { if (event.type === 'step/end') steps++ })
// Force exactly one continuation (step 1 → step 2), then defer to default
// (step 2 is a plain stop with no tool calls → stops).
ctx.on('agent/turn-continuation', async (_agent, _turn, _defaultDecision, _signal, next) => {
if (steps < 2) return { action: 'continue' as const }
return next()
ctx.on('agent/stopping', (subject) => {
if (steps < 2) {
subject.steer([{ type: 'text', text: 'continue after truncation' }], {
source: { kind: 'plugin', plugin: 'max-tokens-test' },
})
}
})
const reasons: TurnEndReason[] = []
@@ -766,6 +709,7 @@ describe('agent loop', () => {
expect(adapter.requests[1]!.messages).toEqual([
{ role: 'user', content: [{ type: 'text', text: 'go' }] },
{ role: 'assistant', content: [{ type: 'text', text: 'first half' }], provenance: { provider: 'mock', model: 'mock' } },
{ role: 'user', content: [{ type: 'text', text: 'continue after truncation' }] },
])
expect(reasons).toEqual([{ kind: 'max-tokens' }])
})
@@ -799,7 +743,7 @@ describe('agent loop', () => {
]])
const ctx = await harness(adapter)
let executions = 0
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'echo',
description: '',
parameters: { text: { type: 'string' } },
@@ -839,7 +783,7 @@ describe('agent loop', () => {
{ type: 'finish', reason: { kind: 'max-tokens' } },
]])
const ctx = await harness(adapter)
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'echo',
description: '',
parameters: { text: { type: 'string' } },
@@ -901,18 +845,11 @@ describe('agent loop', () => {
{ type: 'finish', reason: { kind: 'max-tokens' } },
]])
const ctx = await harness(adapter)
let stepResults = 0
ctx.on('agent/step-result', async (_agent, _turn, _step, message, _signal, next) => {
stepResults += 1
expect(message.content).toEqual([{ type: 'text', text: 'partial text' }])
return next()
})
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(stepResults).toBe(1)
expect(agent.session.events.some(e => e.type === 'tool/call')).toBe(false)
expect(agent.session.deriveMessages()).toEqual([
{ role: 'user', content: [{ type: 'text', text: 'go' }] },
@@ -926,7 +863,7 @@ describe('agent loop', () => {
textResponse('continued after tool call'),
])
const ctx = await harness(adapter)
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'echo',
description: '',
parameters: { text: { type: 'string' } },
@@ -950,91 +887,6 @@ describe('agent loop', () => {
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind).toBe('completed')
})
it('keeps same-tick sends in separate turns and checkpoints before the next starts', async () => {
const adapter = new MockAdapter([textResponse('first answer'), textResponse('second answer')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const firstFlush = Promise.withResolvers<undefined>()
const releaseFirstFlush = Promise.withResolvers<undefined>()
let flushes = 0
ctx.on('session/flush', async (session) => {
if (session !== agent.session) return
flushes += 1
if (flushes === 1) {
firstFlush.resolve(undefined)
await releaseFirstFlush.promise
}
})
const turns: number[] = []
ctx.on('session/event', (session, event) => {
if (session === agent.session && event.type === 'turn/start') turns.push(event.data.turn)
})
const idle = waitForIdle(ctx, agent)
send(agent, 'first message')
send(agent, 'second message')
await firstFlush.promise
expect(turns).toEqual([1])
expect(adapter.requests).toHaveLength(1)
releaseFirstFlush.resolve(undefined)
await idle
expect(turns).toEqual([1, 2])
expect(flushes).toBe(2)
expect(adapter.requests).toHaveLength(2)
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('first answer')
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('second message')
})
it('holds a turn-end listener send behind the closing turn checkpoint', async () => {
const adapter = new MockAdapter([textResponse('first answer'), textResponse('second answer')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const firstFlush = Promise.withResolvers<undefined>()
const releaseFirstFlush = Promise.withResolvers<undefined>()
let flushes = 0
ctx.on('session/flush', async (session) => {
if (session !== agent.session) return
flushes += 1
if (flushes === 1) {
firstFlush.resolve(undefined)
await releaseFirstFlush.promise
}
})
const turns: number[] = []
const statuses: string[] = []
ctx.on('agent/status', (subject, status) => {
if (subject === agent) statuses.push(status)
})
ctx.on('session/event', (session, event) => {
if (session !== agent.session) return
if (event.type === 'turn/start') turns.push(event.data.turn)
if (event.type === 'turn/end' && event.data.turn === 1) send(agent, 'turn-end listener message')
})
const idle = waitForIdle(ctx, agent)
send(agent, 'first message')
await firstFlush.promise
expect(turns).toEqual([1])
expect(adapter.requests).toHaveLength(1)
releaseFirstFlush.resolve(undefined)
await idle
expect(turns).toEqual([1, 2])
expect(statuses).toEqual(['running', 'idle'])
expect(adapter.requests).toHaveLength(2)
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('first answer')
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('turn-end listener message')
})
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)
@@ -1148,26 +1000,6 @@ describe('agent loop', () => {
])
})
it('awaits session/flush at turn end (persistence checkpoint)', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let flushed = 0
let flushedBeforeIdle = false
ctx.on('session/flush', async (session) => {
await new Promise(r => setTimeout(r, 10))
flushed++
flushedBeforeIdle = agent.status !== 'idle'
void session
})
send(agent, 'hi')
await waitForIdle(ctx, agent)
expect(flushed).toBe(1)
expect(flushedBeforeIdle).toBe(true)
})
it('errors from the model surface as agent/error and end the turn', async () => {
const adapter = new MockAdapter([]) // script exhausted → throws
const ctx = await harness(adapter)
@@ -1175,7 +1007,9 @@ describe('agent loop', () => {
const errors: Error[] = []
const reasons: TurnEndReason[] = []
ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
ctx.on('agent/error', (_agent, _turn, _step, error) => {
if (error instanceof Error) errors.push(error)
})
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
send(agent, 'hi')
@@ -1207,8 +1041,7 @@ describe('agent loop', () => {
await fiber.dispose()
await driverDone(agent)
expect(agent.status).toBe('disposed')
expect(ctx.agents.get(SessionId('scoped'))).toBeUndefined()
await expect.poll(() => ctx.agents.get(SessionId('scoped')) === undefined).toBe(true)
})
it('creates agents from config on startup', async () => {
@@ -1257,7 +1090,7 @@ describe('agent loop', () => {
textResponse('done'),
])
const ctx = await harness(adapter)
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'echo',
description: '',
parameters: { text: { type: 'string' } },

View File

@@ -5,8 +5,8 @@
* is a finding, not timing noise.
*
* Invariants: every sent message appears exactly once in the log (none lost);
* turn numbers strictly increase; status transitions follow the legal machine
* idle→running→idle (and →disposed at teardown).
* turn numbers strictly increase; status transitions follow
* idle→running→idle, while teardown is a registry lifecycle.
*/
import { describe, expect, it } from 'vitest'

View File

@@ -0,0 +1,149 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import LlmService, { LlmError } from '@deepseek-ai/dsh-llm'
import type { LlmFailure } 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 { MockAdapter, textResponse } from './mock-adapter.ts'
async function harness(adapter: MockAdapter): Promise<Context> {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
ctx.llm.registerAdapter(['mock'], adapter)
return ctx
}
function fail(message: string, code: string): () => never {
return () => {
throw new LlmError(message, code)
}
}
describe('agent/request-error', () => {
it('does not offer middleware failures to request recovery', async () => {
const adapter = new MockAdapter([textResponse('unused')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('request-error-narrow'), { provider: 'mock', model: 'mock' })
let recoveries = 0
ctx.on('agent/request', () => {
throw new LlmError('middleware failed', 'MIDDLEWARE')
})
ctx.on('agent/request-error', async () => {
recoveries += 1
})
agent.followup([{ type: 'text', text: 'go' }])
await agent.whenIdle()
expect(recoveries).toBe(0)
expect(adapter.requests).toHaveLength(0)
})
it('lets each failed request schedule a retry before its turn closes', async () => {
const adapter = new MockAdapter([
fail('busy', 'RATE_LIMIT'),
fail('unavailable', 'SERVICE_UNAVAILABLE'),
textResponse('ok'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('request-error-retry'), { provider: 'mock', model: 'mock' })
const seen: { turn: number; step: number; failure: LlmFailure }[] = []
const statuses: string[] = []
const idleTurns: number[] = []
ctx.on('agent/status', (subject, status) => {
if (subject === agent) statuses.push(status)
})
ctx.on('agent/idle', (subject, turn) => {
if (subject === agent) idleTurns.push(turn)
})
ctx.on('agent/request-error', async (subject, turn, step, _error, failure) => {
expect(subject).toBe(agent)
expect(agent.session.events.at(-1)).toMatchObject({
type: 'step/end',
data: { turn, step },
})
seen.push({ turn, step, failure })
subject.retry()
subject.retry()
})
agent.followup([{ type: 'text', text: 'go' }])
await agent.whenIdle()
expect(seen.map(item => ({
turn: item.turn,
step: item.step,
code: item.failure.code,
}))).toEqual([
{
turn: 1,
step: 1,
code: 'RATE_LIMIT',
},
{
turn: 2,
step: 1,
code: 'SERVICE_UNAVAILABLE',
},
])
expect(agent.session.events.filter(event => event.type === 'turn/start').map(event => event.data.trigger))
.toEqual([
{ kind: 'message', source: { kind: 'user' } },
{ kind: 'retry' },
{ kind: 'retry' },
])
expect(statuses).toEqual(['running', 'idle'])
expect(idleTurns).toEqual([3])
})
it('lets cancellation win over a retry request', async () => {
const adapter = new MockAdapter([fail('busy', 'RATE_LIMIT'), textResponse('unused')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('request-error-cancel'), { provider: 'mock', model: 'mock' })
ctx.on('agent/request-error', async (subject) => {
subject.retry()
subject.cancel({ kind: 'user' })
})
agent.followup([{ type: 'text', text: 'go' }])
await agent.whenIdle()
expect(adapter.requests).toHaveLength(1)
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
expect(agent.session.events.find(event => event.type === 'turn/end')).toMatchObject({
type: 'turn/end',
data: { reason: { kind: 'aborted' } },
})
})
it('does not honor a retry requested by a failing recovery listener', async () => {
const adapter = new MockAdapter([fail('busy', 'RATE_LIMIT'), textResponse('unused')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('request-error-recovery-failed'), {
provider: 'mock',
model: 'mock',
})
ctx.on('agent/request-error', async (subject) => {
subject.retry()
throw new Error('recovery failed')
})
agent.followup([{ type: 'text', text: 'go' }])
await agent.whenIdle()
expect(adapter.requests).toHaveLength(1)
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
expect(agent.session.events.find(event => event.type === 'turn/end')).toMatchObject({
type: 'turn/end',
data: { reason: { kind: 'error' } },
})
})
})

View File

@@ -1,82 +0,0 @@
/**
* recordRequestHeader unit tests: exactly one of three things per request —
* an 'initial' snapshot (log has no header yet), a 'resume' snapshot (fresh
* loop instance over a log that has one), nothing (header unchanged), or a
* full 'change' snapshot.
*/
import { describe, expect, it } from 'vitest'
import { Session, SessionId, canonicalHeader } from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import type { ToolSchema } from '@deepseek-ai/dsh-llm'
import { createTransmissionLog, recordRequestHeader } from '../src/request-log.ts'
function tool(name: string, description = 'd'): ToolSchema {
return { name, description, parameters: { type: 'object' } }
}
function openSession(id: string): Session {
const session = new Session(SessionId(id))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
return session
}
function headerEvents(session: Session): SessionEvent[] {
return session.events.filter(e => e.type === 'request/header')
}
describe('recordRequestHeader', () => {
it("anchors a new conversation with an 'initial' snapshot, then logs nothing while unchanged", () => {
const session = openSession('rl-initial')
const state = createTransmissionLog()
const header = canonicalHeader({ config: { provider: 'mock', model: 'm' }, system: 's', tools: [tool('t')] })
recordRequestHeader(session, state, header)
const [first] = headerEvents(session)
expect(first?.type === 'request/header' && first.data.reason).toBe('initial')
recordRequestHeader(session, state, header)
expect(headerEvents(session)).toHaveLength(1)
})
it("anchors a fresh loop instance over an anchored log with a 'resume' snapshot, even unchanged", () => {
const session = openSession('rl-resume')
const header = canonicalHeader({ config: { provider: 'mock', model: 'm' }, system: 's' })
recordRequestHeader(session, createTransmissionLog(), header)
// A second instance (process restart / fork): the boundary itself is a
// recorded fact — snapshot appended even though the header is identical.
recordRequestHeader(session, createTransmissionLog(), header)
const events = headerEvents(session)
expect(events).toHaveLength(2)
expect(events[1]?.type === 'request/header' && events[1].data.reason).toBe('resume')
})
it("logs a full 'change' snapshot for a mid-run change, and the fold reproduces the header", () => {
const session = openSession('rl-change')
const state = createTransmissionLog()
const first = canonicalHeader({ config: { provider: 'mock', model: 'm' }, system: 'a\nb', tools: [tool('t')] })
recordRequestHeader(session, state, first)
const second = canonicalHeader({ config: { provider: 'mock', model: 'm' }, system: 'a\nc', tools: [tool('t'), tool('u')] })
recordRequestHeader(session, state, second)
const events = headerEvents(session)
expect(events).toHaveLength(2)
expect(events[1]?.type === 'request/header' && events[1].data.reason).toBe('change')
expect(session.requestHeader()).toEqual(second)
})
it("records a pure tool reordering as a 'change' snapshot", () => {
const session = openSession('rl-reorder')
const state = createTransmissionLog()
const first = canonicalHeader({ config: { provider: 'mock', model: 'm' }, tools: [tool('a'), tool('b')] })
recordRequestHeader(session, state, first)
const reordered = canonicalHeader({ config: { provider: 'mock', model: 'm' }, tools: [tool('b'), tool('a')] })
recordRequestHeader(session, state, reordered)
const events = headerEvents(session)
expect(events).toHaveLength(2)
expect(events[1]?.type === 'request/header' && events[1].data.reason).toBe('change')
expect(session.requestHeader()).toEqual(reordered)
})
})

View File

@@ -114,7 +114,7 @@ describe('request stability across the loop', () => {
// A pre-step listener compacts turn 1's history before turn 2's step —
// the sanctioned surface rewrite, landing OUTSIDE the step.
const preStep = ctx.on('agent/pre-step', () => {
const preStep = ctx.on('agent/step', () => {
preStep()
const session = agent.session
const nodes = session.surface.nodes
@@ -167,7 +167,7 @@ describe('request stability across the loop', () => {
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let injected = false
ctx.on('agent/request', async (_agent, _turn, _step, _config, _signal, next) => {
ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => {
if (!injected) {
injected = true
agent.inject([{ type: 'text', text: '[late context]' }], { source: { kind: 'plugin', plugin: 'test' } })
@@ -195,7 +195,9 @@ describe('request stability across the loop', () => {
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const errors: Error[] = []
ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
ctx.on('agent/error', (_agent, _turn, _step, error) => {
if (error instanceof Error) errors.push(error)
})
ctx.on('llm/stream', (options, next) => {
// The historical failure mode this design kills: a listener rewriting
// request content in place. The freeze turns it into a loud error.
@@ -231,8 +233,7 @@ describe('request stability across the loop', () => {
await waitForIdle(ctx2, agent2)
const snapshots = agent2.session.events.filter(e => e.type === 'request/header')
expect(snapshots).toHaveLength(2)
expect(snapshots[1]?.type === 'request/header' && snapshots[1].data.reason).toBe('resume')
expect(snapshots).toHaveLength(1)
// Identical header across the restart: byte-identical continuation.
expect(adapter2.requests[0]!.system).toEqual(adapter.requests[0]!.system)
expectPrefixExtension(adapter.requests[0]!, adapter2.requests[0]!)
@@ -243,7 +244,7 @@ describe('request stability across the loop', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
ctx.on('agent/request', async (_agent, _turn, _step, _config, _signal, next) => {
ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => {
const config = await next()
// next() resolves the SAME frozen seed — in-place shaping after
// delegation is unrepresentable, so a "mutate what next() returned"
@@ -280,7 +281,9 @@ describe('request stability across the loop', () => {
send(agent, 'go')
await waitForIdle(ctx, agent)
ctx.systemPrompt.section({ name: 'extra', order: 2, text: 'now with guidance' })
ctx.on('agent/request', async (_agent, _turn, _step, config, _signal, _next) => ({ ...config, temperature: 0.5, maxTokens: 99, stop: ['<END>'] }))
ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => ({
...await next(), temperature: 0.5, maxTokens: 99, stop: ['<END>'],
}))
send(agent, 'again')
await waitForIdle(ctx, agent)

View File

@@ -1,605 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import LlmService, {
CallId,
CONTEXT_WINDOW_EXCEEDED_CODE,
HarnessError,
LlmAdapter,
LlmError,
ProviderRequestId,
} from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, LlmFailure, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import type { PostToolDecision } from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { maxTokensResponse, textResponse, toolCallResponse } from './mock-adapter.ts'
class FailureScriptAdapter extends LlmAdapter {
requests: GenerateOptions[] = []
constructor(private readonly entries: (Error | StreamChunk[])[]) {
super()
}
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
this.requests.push(options)
const entry = this.entries.shift()
if (entry === undefined) throw new Error('failure script exhausted')
if (entry instanceof Error) throw entry
yield* entry
}
}
class IteratorConstructionFailureAdapter extends LlmAdapter {
stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
return {
[Symbol.asyncIterator](): AsyncIterator<StreamChunk> {
throw new LlmError('iterator construction failed', 'ITERATOR_CONSTRUCTION')
},
}
}
}
class SynchronousDispatchFailureAdapter extends LlmAdapter {
constructor(private readonly error: Error) {
super()
}
stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
throw this.error
}
}
class IteratorResultGetterFailureAdapter extends LlmAdapter {
constructor(
private readonly field: 'done' | 'value',
private readonly error: Error,
) {
super()
}
stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
const result = this.field === 'done' ? {} : { done: false }
Object.defineProperty(result, this.field, { get: () => { throw this.error } })
return {
[Symbol.asyncIterator](): AsyncIterator<StreamChunk> {
return { next: () => Promise.resolve(result as unknown as IteratorResult<StreamChunk>) }
},
}
}
}
const streamListenerFailureCases: readonly [string, (ctx: Context) => void][] = [
['synchronous listener throw', (ctx) => {
ctx.on('llm/stream', () => { throw new Error('synchronous stream listener failed') })
}],
['invalid listener iterable', (ctx) => {
ctx.on('llm/stream', () => ({}) as AsyncIterable<StreamChunk>)
}],
['listener wrapper iteration failure', (ctx) => {
ctx.on('llm/stream', (_options, next) => (async function * () {
for await (const chunk of next()) {
yield chunk
throw new Error('stream listener wrapper failed')
}
})())
}],
]
async function harness(adapter?: LlmAdapter): Promise<Context> {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
if (adapter) ctx.llm.registerAdapter(['mock'], adapter)
return ctx
}
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') {
dispose()
resolve()
}
})
})
}
function send(agent: Agent): void {
agent.followup([{ type: 'text', text: 'go' }])
}
function contextError(message = 'context too large'): LlmError {
return new LlmError(message, CONTEXT_WINDOW_EXCEEDED_CODE)
}
describe('agent post-step and request-error lifecycle', () => {
it('fires post-step after results, buffered context, and steering but before step/end', async () => {
const twoCalls: StreamChunk[] = [
{ type: 'block-start', index: 0, blockType: 'tool-call' },
{ type: 'block-end', index: 0, block: { type: 'tool-call', id: CallId('call-1'), name: 'work', arguments: '{}' } },
{ type: 'block-start', index: 1, blockType: 'tool-call' },
{ type: 'block-end', index: 1, block: { type: 'tool-call', id: CallId('call-2'), name: 'work', arguments: '{}' } },
{ type: 'usage', usage: { inputTokens: 10, outputTokens: 5 } },
{ type: 'finish', reason: { kind: 'tool-calls' } },
]
const adapter = new FailureScriptAdapter([twoCalls, textResponse('done')])
const ctx = await harness(adapter)
ctx.tools.register(defineContentToolFixture({
name: 'work',
description: 'do work',
parameters: {},
async execute(_args, exec) {
if (exec.callId === CallId('call-2')) {
exec.agent?.steer([{ type: 'text', text: 'steered' }], { source: { kind: 'plugin', plugin: 'test' } })
}
return [{ type: 'text', text: 'worked' }]
},
}))
ctx.on('tools/post-execute', async (exec, _result): Promise<PostToolDecision> => ({
kind: 'accept',
additionalContexts: [{
content: [{ type: 'text', text: `context for ${exec.callId}` }],
source: { kind: 'plugin', plugin: 'test' },
}],
}))
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' || isInjected
|| event.type === 'steering/message' || event.type === 'step/end'
) {
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) => {
if (subject !== agent || step !== 1) return
expect({ turn, step, aborted: signal.aborted }).toEqual({ turn: 1, step: 1, aborted: false })
subject.inject([{ type: 'text', text: 'listener mutation' }], { source: { kind: 'plugin', plugin: 'post-step' } })
order.push('agent/post-step')
})
send(agent)
await waitForIdle(ctx, agent)
expect(order).toEqual([
'assistant/message',
'tool/call',
'tool/result',
'tool/call',
'tool/result',
'context/message',
'context/message',
'steering/message',
'context/message',
'agent/post-step',
'step/end',
])
})
it('fires post-step for max-tokens and lets cancellation override that success', async () => {
const adapter = new FailureScriptAdapter([maxTokensResponse('partial')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('cancel-post-step-max-tokens'), { provider: 'mock', model: 'mock' })
let entered!: () => void
const postStepEntered = new Promise<void>((resolve) => { entered = resolve })
ctx.on('agent/post-step', async (_agent, turn, step, signal) => {
expect({ turn, step }).toEqual({ turn: 1, step: 1 })
entered()
await new Promise<void>((resolve) => {
signal.addEventListener('abort', () => { resolve() }, { once: true })
})
})
send(agent)
const idle = waitForIdle(ctx, agent)
await postStepEntered
agent.cancel({ kind: 'user' })
await idle
expect(agent.session.events.find(event => event.type === 'assistant/message')).toMatchObject({
data: { usage: { inputTokens: 10, outputTokens: 7 } },
})
expect(agent.session.events.at(-1)).toMatchObject({
type: 'turn/end',
data: { reason: { kind: 'aborted' } },
})
})
it('closes the successful step as disposed when disposal lands during post-step', async () => {
const adapter = new FailureScriptAdapter([
toolCallResponse('dispose-call', 'work', {}),
textResponse('must not continue'),
])
const ctx = await harness(adapter)
ctx.tools.register(defineContentToolFixture({
name: 'work',
description: 'do work',
parameters: {},
async execute() { return [{ type: 'text', text: 'worked' }] },
}))
const agent = ctx.agentLoop.create(SessionId('dispose-post-step'), { provider: 'mock', model: 'mock' })
let entered!: () => void
const postStepEntered = new Promise<void>((resolve) => { entered = resolve })
ctx.on('agent/post-step', async (_agent, turn, step, signal) => {
expect({ turn, step }).toEqual({ turn: 1, step: 1 })
entered()
await new Promise<void>((resolve) => {
signal.addEventListener('abort', () => { resolve() }, { once: true })
})
})
send(agent)
await postStepEntered
await ctx.fiber.dispose()
expect(adapter.requests).toHaveLength(1)
const boundaries = agent.session.events.filter(event =>
event.type === 'step/start' || event.type === 'step/end',
)
expect(boundaries.map(event => event.type)).toEqual(['step/start', 'step/end'])
expect(boundaries.map(event => event.data)).toEqual([
{ turn: 1, step: 1 },
{ turn: 1, step: 1 },
])
expect(agent.session.events.at(-1)).toMatchObject({
type: 'turn/end',
data: { reason: { kind: 'disposed' } },
})
})
it.each([
['thrown', contextError()],
['in-band', [{ type: 'finish', reason: { kind: 'error', failure: { message: 'too large', code: CONTEXT_WINDOW_EXCEEDED_CODE, status: 400 } } }] satisfies StreamChunk[]],
] as const)('recovers a %s request failure in a new reconstructable step', async (_style, failure) => {
const adapter = new FailureScriptAdapter([failure, textResponse('recovered')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId(`recover-${_style}`), { provider: 'mock', model: 'mock' })
const attempts: number[] = []
ctx.on('agent/request-error', async (subject, turn, step, error, facts, history) => {
expect(subject).toBe(agent)
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('user/message', {
content: [{ type: 'text', text: 'RECOVERY SURFACE MUTATION' }],
source: { kind: 'plugin', plugin: 'test-recovery' },
}, { surfaceOp: 'append' })
return { action: 'retry' }
})
send(agent)
await waitForIdle(ctx, agent)
expect(attempts).toEqual([0])
expect(adapter.requests).toHaveLength(2)
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('RECOVERY SURFACE MUTATION')
const starts = agent.session.events.filter(event => event.type === 'step/start')
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 === 'user/message' && event.data.source.kind === 'plugin')!
expect(ends[0]!.seq).toBeLessThan(recovery.seq)
expect(recovery.seq).toBeLessThan(starts[1]!.seq)
})
it.each(streamListenerFailureCases)('does not offer %s to request recovery', async (_name, install) => {
const ctx = await harness(new FailureScriptAdapter([textResponse('unused')]))
const agent = ctx.agentLoop.create(SessionId(`stream-plugin-${_name.replaceAll(' ', '-')}`), { provider: 'mock', model: 'mock' })
let recoveries = 0
install(ctx)
ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _failure, _history, _signal, next) => {
recoveries += 1
return next()
})
send(agent)
await waitForIdle(ctx, agent)
expect(recoveries).toBe(0)
expect(agent.session.events.at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'error' } } })
})
it('does not offer a nested model-call failure as the outer request failure', async () => {
const outer = new FailureScriptAdapter([textResponse('outer adapter must not run')])
const nested = new FailureScriptAdapter([contextError('nested overflow')])
const ctx = await harness(outer)
ctx.llm.registerAdapter(['nested'], nested)
ctx.on('llm/stream', (options, next) => {
if (options.provider !== 'mock') return next()
return (async function* () {
yield* ctx.llm.stream({
provider: 'nested',
model: 'nested',
messages: [],
...options.signal === undefined ? {} : { signal: options.signal },
})
yield* next()
})()
})
const agent = ctx.agentLoop.create(SessionId('nested-stream-not-recoverable'), { provider: 'mock', model: 'mock' })
let recoveries = 0
ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _failure, _history, _signal, next) => {
recoveries += 1
return next()
})
send(agent)
await waitForIdle(ctx, agent)
expect(nested.requests).toHaveLength(1)
expect(outer.requests).toHaveLength(0)
expect(recoveries).toBe(0)
expect(agent.session.events.at(-1)).toMatchObject({
type: 'turn/end',
data: { reason: { kind: 'error', message: 'nested overflow', code: CONTEXT_WINDOW_EXCEEDED_CODE } },
})
})
it.each(['prompt-submit', 'prompt-assembly', 'pre-step', 'request'] as const)(
'does not offer %s middleware failures to request recovery',
async (boundary) => {
const adapter = new FailureScriptAdapter([textResponse('unused')])
const ctx = await harness(adapter)
if (boundary === 'prompt-submit') {
ctx.on('agent/prompt-submit', () => { throw new Error('prompt submit failed') })
} else if (boundary === 'prompt-assembly') {
ctx.on('system-prompt/assemble', () => { throw new Error('prompt assembly failed') })
} else if (boundary === 'pre-step') {
ctx.on('agent/pre-step', () => { throw new Error('pre-step failed') })
} else {
ctx.on('agent/request', () => { throw new Error('request middleware failed') })
}
const agent = ctx.agentLoop.create(SessionId(`${boundary}-not-recoverable`), { provider: 'mock', model: 'mock' })
let recoveries = 0
ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _failure, _history, _signal, next) => {
recoveries += 1
return next()
})
send(agent)
await waitForIdle(ctx, agent)
expect(recoveries).toBe(0)
expect(adapter.requests).toHaveLength(0)
expect(agent.session.events.at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'error' } } })
},
)
it('does not offer result, tool, or post-step plugin failures to request recovery', async () => {
for (const failure of ['result', 'tool', 'post-step'] as const) {
const adapter = new FailureScriptAdapter([
failure === 'tool' ? toolCallResponse(`call-${failure}`, 'work', {}) : textResponse('done'),
...(failure === 'tool' ? [textResponse('done')] : []),
])
const ctx = await harness(adapter)
if (failure === 'result') ctx.on('agent/step-result', () => { throw new Error('result failed') })
if (failure === 'post-step') ctx.on('agent/post-step', () => { throw new Error('post-step failed') })
if (failure === 'tool') {
vi.spyOn(ctx.tools, 'execute').mockRejectedValue(new Error('tool service failed'))
}
const agent = ctx.agentLoop.create(SessionId(`${failure}-not-recoverable`), { provider: 'mock', model: 'mock' })
let recoveries = 0
ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _failure, _history, _signal, next) => {
recoveries += 1
return next()
})
send(agent)
await waitForIdle(ctx, agent)
expect(recoveries, failure).toBe(0)
}
})
it.each([
['synchronous dispatch', (error: Error) => new SynchronousDispatchFailureAdapter(error)],
['done getter', (error: Error) => new IteratorResultGetterFailureAdapter('done', error)],
['value getter', (error: Error) => new IteratorResultGetterFailureAdapter('value', error)],
] as const)('preserves original Error identity for adapter %s', async (_name, makeAdapter) => {
const original = contextError(`${_name} overflow`)
const ctx = await harness(makeAdapter(original))
const agent = ctx.agentLoop.create(SessionId(`identity-${_name.replaceAll(' ', '-')}`), { provider: 'mock', model: 'mock' })
let seen: Error | undefined
ctx.on('agent/request-error', async (_agent, _turn, _step, error, _failure, _history, _signal, next) => {
seen = error
return next()
})
send(agent)
await waitForIdle(ctx, agent)
expect(seen).toBe(original)
})
it('keeps an adapter error with a hostile message accessor on the recovery path', async () => {
const original = Object.defineProperty(new HarnessError('provider failed', 'SERVER'), 'message', {
get() { throw new Error('SDK message accessor trap') },
})
const ctx = await harness(new SynchronousDispatchFailureAdapter(original))
const agent = ctx.agentLoop.create(SessionId('hostile-message-recovery'), { provider: 'mock', model: 'mock' })
let seenError: Error | undefined
let seenFailure: LlmFailure | undefined
ctx.on('agent/request-error', async (_agent, _turn, _step, error, failure, _history, _signal, next) => {
seenError = error
seenFailure = failure
return next()
})
send(agent)
await waitForIdle(ctx, agent)
expect(seenError).toBe(original)
expect(seenFailure).toEqual({ message: 'LLM adapter failed', code: 'SERVER' })
expect(agent.session.events.at(-1)).toMatchObject({
type: 'turn/end',
data: { reason: { kind: 'error', failure: { message: 'LLM adapter failed', code: 'SERVER' } } },
})
})
it('passes structured facts beside the original Error and records its cause chain on exhaustion', async () => {
const original = new LlmError('provider busy', 'RATE_LIMIT', {
cause: new Error('upstream connection reset'),
status: 429,
providerRetryAfterMs: 2_000,
requestId: ProviderRequestId('req-9'),
})
Object.freeze(original)
const ctx = await harness(new SynchronousDispatchFailureAdapter(original))
const agent = ctx.agentLoop.create(SessionId('structured-request-failure'), { provider: 'mock', model: 'mock' })
let seenError: Error | undefined
let seenFailure: LlmFailure | undefined
let seenHistory: readonly LlmFailure[] | undefined
ctx.on('agent/request-error', async (
_agent, _turn, _step, error, failure, history, _signal, next,
) => {
seenError = error
seenFailure = failure
seenHistory = history
return next()
})
send(agent)
await waitForIdle(ctx, agent)
expect(seenError).toBe(original)
expect(seenFailure).toEqual({
message: 'provider busy',
code: 'RATE_LIMIT',
status: 429,
providerRetryAfterMs: 2_000,
requestId: ProviderRequestId('req-9'),
})
expect(seenHistory).toEqual([])
expect(Object.isFrozen(seenHistory)).toBe(true)
expect(agent.session.events.at(-1)).toMatchObject({
type: 'turn/end',
data: {
reason: {
kind: 'error',
step: 1,
failure: {
message: 'provider busy: upstream connection reset',
code: 'RATE_LIMIT',
status: 429,
providerRetryAfterMs: 2_000,
requestId: ProviderRequestId('req-9'),
},
},
},
})
})
it('classifies iterator construction and explicit NO_ADAPTER as model-request failures', async () => {
for (const scenario of ['iterator', 'no-adapter'] as const) {
const ctx = scenario === 'iterator' ? await harness(new IteratorConstructionFailureAdapter()) : await harness()
const agent = ctx.agentLoop.create(SessionId(`request-boundary-${scenario}`), { provider: 'mock', model: 'mock' })
let seen = ''
ctx.on('agent/request-error', async (_agent, _turn, _step, error, _failure, _history, _signal, next) => {
seen = error.code ?? ''
return next()
})
send(agent)
await waitForIdle(ctx, agent)
expect(seen).toBe(scenario === 'iterator' ? 'ITERATOR_CONSTRUCTION' : 'NO_ADAPTER')
}
})
it('tracks consecutive retry attempts and resets after a successful request', async () => {
const capped = new FailureScriptAdapter([contextError('first overflow'), contextError('second overflow')])
const cappedCtx = await harness(capped)
const cappedAgent = cappedCtx.agentLoop.create(SessionId('retry-cap'), { provider: 'mock', model: 'mock' })
const cappedHistories: string[][] = []
cappedCtx.on('agent/request-error', async (
_agent, _turn, _step, _error, _failure, history, _signal, next,
) => {
const codes = history.map(entry => entry.code)
cappedHistories.push(codes)
return codes.length < 1 ? { action: 'retry' } : next()
})
send(cappedAgent)
await waitForIdle(cappedCtx, cappedAgent)
expect(cappedHistories).toEqual([[], [CONTEXT_WINDOW_EXCEEDED_CODE]])
const reset = new FailureScriptAdapter([
contextError('first overflow'),
toolCallResponse('retry-reset-call', 'work', {}),
contextError('later overflow'),
])
const resetCtx = await harness(reset)
resetCtx.tools.register(defineContentToolFixture({
name: 'work',
description: 'continue',
parameters: {},
async execute() { return [{ type: 'text', text: 'worked' }] },
}))
const resetAgent = resetCtx.agentLoop.create(SessionId('retry-reset'), { provider: 'mock', model: 'mock' })
const resetHistories: { step: number; codes: string[] }[] = []
resetCtx.on('agent/request-error', async (
_agent, _turn, step, _error, _failure, history, _signal, next,
) => {
resetHistories.push({ step, codes: history.map(entry => entry.code) })
return resetHistories.length === 1 ? { action: 'retry' } : next()
})
send(resetAgent)
await waitForIdle(resetCtx, resetAgent)
expect(resetHistories).toEqual([{ step: 1, codes: [] }, { step: 3, codes: [] }])
})
it('preserves the original provider error when recovery throws', async () => {
const adapter = new FailureScriptAdapter([contextError('original overflow')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('recovery-throws'), { provider: 'mock', model: 'mock' })
ctx.on('agent/request-error', () => { throw new Error('recovery exploded') })
send(agent)
await waitForIdle(ctx, agent)
expect(agent.session.events.at(-1)).toMatchObject({
type: 'turn/end',
data: { reason: { kind: 'error', failure: { message: 'original overflow', code: CONTEXT_WINDOW_EXCEEDED_CODE } } },
})
})
it.each(['cancel', 'dispose'] as const)('keeps %s live through request recovery', async (action) => {
const adapter = new FailureScriptAdapter([contextError()])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId(`${action}-recovery`), { provider: 'mock', model: 'mock' })
let entered!: () => void
const recoveryEntered = new Promise<void>((resolve) => { entered = resolve })
ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _failure, _history, signal) => {
entered()
await new Promise<void>((resolve) => {
signal.addEventListener('abort', () => { resolve() }, { once: true })
})
return { action: 'retry' }
})
send(agent)
const idle = waitForIdle(ctx, agent)
await recoveryEntered
if (action === 'cancel') {
agent.cancel({ kind: 'user' })
await idle
} else {
await ctx.fiber.dispose()
}
expect(adapter.requests).toHaveLength(1)
expect(agent.session.events.at(-1)).toMatchObject({
type: 'turn/end',
data: { reason: action === 'cancel' ? { kind: 'aborted' } : { kind: 'disposed' } },
})
})
})

View File

@@ -261,10 +261,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
resumeSessionId: sessionId,
agentOptions: { provider: 'mock', model: 'mock' },
})
const transactionLabels = [
`agentLoop.owner(${sessionId})`,
`agentLoop.lifecycle(${sessionId})`,
]
const transactionLabels = [`agentLoop.lifecycle(${sessionId})`]
expect(ctx.fiber.getEffects().map(effect => effect.label)).toEqual(expect.arrayContaining(transactionLabels))
await handle.dispose()

View File

@@ -456,7 +456,7 @@ describe('agent scope lifecycle', () => {
})
await expect(creating).rejects.toThrow(/agent loop is not active/)
await loopFiber.dispose()
expect(setupCalls).toBe(0)
expect(setupCalls).toBe(1)
expect(ctx.agents.get(SessionId('factory-scope-race-s'))).toBeUndefined()
expect(ctx.sessions.get(SessionId('factory-scope-race-s'))).toBeUndefined()
@@ -509,17 +509,17 @@ describe('agent scope lifecycle', () => {
const { ctx, loopFiber } = await harnessWithLoop()
const sessionsBefore = ctx.sessions.list().length
let unloaded = false
let unloading!: Promise<void>
ctx.on('internal/plugin', (fiber) => {
if (unloaded || fiber.name !== 'scope') return
unloaded = true
void loopFiber.dispose()
unloading = loopFiber.dispose()
})
expect(() => ctx.agentLoop.create(SessionId('config-scope-race'), { provider: 'mock', model: 'mock' }))
.toThrow(/agent loop is not active/)
await loopFiber.dispose()
expect(ctx.agents.get(SessionId('config-scope-race'))).toBeUndefined()
expect(ctx.sessions.list()).toHaveLength(sessionsBefore)
ctx.agentLoop.create(SessionId('config-scope-race'), { provider: 'mock', model: 'mock' })
await unloading
expect(ctx.agents.get(SessionId('config-scope-race')) === undefined).toBe(true)
expect(ctx.sessions.list().length).toBe(sessionsBefore)
await ctx.fiber.dispose()
})
@@ -566,16 +566,16 @@ describe('agent scope lifecycle', () => {
})
await loopFiber.dispose()
expect(handle.agent.status).toBe('disposed')
expect(handle.agent.status).toBe('idle')
expect(ctx.agents.get(sessionId)).toBeUndefined()
expect(ctx.sessions.get(sessionId)).toBeUndefined()
expect(ctx.fiber.getEffects().filter(effect => effect.label === `agentLoop.owner(${sessionId})`)).toEqual([])
expect(ctx.fiber.getEffects().filter(effect => effect.label === `agentLoop.lifecycle(${sessionId})`)).toEqual([])
// The consumer handle shares the provider's completed quiescence boundary.
await handle.dispose()
await expect(loop.createAgent(ctx, {
sessionId: SessionId('factory-inactive-s'),
})).rejects.toThrow('agent loop is not active')
})).rejects.toThrow(/agent loop is not active|inactive context/)
await ctx.fiber.dispose()
})
@@ -643,13 +643,13 @@ describe('agent scope lifecycle', () => {
})
}, { inject: ['agents'] }))
await expect(creating).rejects.toThrow(/lifecycle disposed/)
await expect(creating).rejects.toThrow(/owner disposed during setup/)
await owner.dispose()
expect(lifecycle).toEqual([
'session-created:dispose',
'session-created:observer',
'session-disposed',
'scope-disposed',
'session-disposed',
])
expect(ctx.agents.get(SessionId('session-created-barrier-s'))).toBeUndefined()
expect(ctx.sessions.get(SessionId('session-created-barrier-s'))).toBeUndefined()
@@ -691,15 +691,15 @@ describe('agent scope lifecycle', () => {
})
}, { inject: ['agents'] }))
await expect(creating).rejects.toThrow(/lifecycle disposed/)
await expect(creating).rejects.toThrow(/owner disposed during setup/)
await owner.dispose()
expect(lifecycle).toEqual([
'session-created',
'agent-created:dispose',
'agent-created:observer',
'scope-disposed',
'agent-disposed',
'session-disposed',
'scope-disposed',
])
expect(ctx.agents.get(SessionId('agent-created-barrier-s'))).toBeUndefined()
expect(ctx.sessions.get(SessionId('agent-created-barrier-s'))).toBeUndefined()
@@ -713,7 +713,7 @@ describe('agent scope lifecycle', () => {
let creating!: ReturnType<typeof ctx.agents.create>
ctx.on('agent/session-start', agent => void starts.push(agent.id))
ctx.on('agent/created', (agent) => {
if (agent.id === SessionId('listener-dispose-s')) void ownerCtx.fiber.dispose()
if (agent.id === SessionId('listener-dispose-s')) disposeCurrentLifecycle(ownerCtx)
})
const owner = await ctx.plugin(Object.assign((inner: Context) => {
@@ -727,8 +727,8 @@ describe('agent scope lifecycle', () => {
await expect(creating).rejects.toThrow(/owner disposed during setup/)
await owner.dispose()
expect(starts).toEqual([])
expect(ctx.agents.get(SessionId('listener-dispose-s'))).toBeUndefined()
expect(ctx.sessions.get(SessionId('listener-dispose-s'))).toBeUndefined()
expect(ctx.agents.get(SessionId('listener-dispose-s')) === undefined).toBe(true)
expect(ctx.sessions.get(SessionId('listener-dispose-s')) === undefined).toBe(true)
await ctx.fiber.dispose()
})
@@ -764,10 +764,10 @@ describe('agent scope lifecycle', () => {
})
}, { inject: ['agents'] }))
await expect(creating).rejects.toThrow(/lifecycle disposed/)
await expect(creating).rejects.toThrow(/owner disposed during setup/)
await owner.dispose()
expect(announced.status).toBe('disposed')
expect(statuses).toEqual(['disposed'])
expect(announced.status).toBe('idle')
expect(statuses).toEqual([])
expect(observerSawLive).toBe(true)
expect(scopeDisposed).toBe(true)
expect(announced.session.events).toEqual([])
@@ -886,8 +886,8 @@ describe('agent scope lifecycle', () => {
expect(() => ctx.agentLoop.create(SessionId('config-bad'), { provider: 'mock', model: 'mock' }))
.toThrow('config publish failed')
expect(ctx.agents.get(SessionId('config-bad'))).toBeUndefined()
expect(ctx.sessions.list()).toHaveLength(sessionsBefore)
await expect.poll(() => ctx.agents.get(SessionId('config-bad')) === undefined).toBe(true)
await expect.poll(() => ctx.sessions.list().length).toBe(sessionsBefore)
})
it('registrations through a disposed agent ctx throw INACTIVE_EFFECT', async () => {
@@ -937,7 +937,11 @@ describe('agent scope lifecycle', () => {
agent.followup(text('work'))
await turnOpen
await owner.dispose()
expect(order).toEqual(['turn-end', 'disposed(listed=false)', 'session-still-stored=true'])
expect(order).toEqual([
'turn-end',
'disposed(listed=false)',
'session-still-stored=true',
])
expect(ctx.sessions.get(SessionId('o1-s'))).toBeUndefined()
})
@@ -970,9 +974,9 @@ describe('agent scope lifecycle', () => {
agentOptions: { provider: 'mock', model: 'mock' },
})
expect(ctx.fiber.getEffects().map(effect => effect.label)).toContain(`agentLoop.owner(${sessionId})`)
expect(ctx.fiber.getEffects().map(effect => effect.label)).toContain(`agentLoop.lifecycle(${sessionId})`)
await handle.dispose()
expect(ctx.fiber.getEffects().filter(effect => effect.label === `agentLoop.owner(${sessionId})`)).toEqual([])
expect(ctx.fiber.getEffects().filter(effect => effect.label === `agentLoop.lifecycle(${sessionId})`)).toEqual([])
await ctx.fiber.dispose()
})
@@ -1007,15 +1011,11 @@ describe('agent scope lifecycle', () => {
await ctx.fiber.dispose()
})
it('reopens ids after detach while the prior private scope finishes quiescing', async () => {
it('reopens ids after the prior private scope finishes quiescing', async () => {
const ctx = await harness()
const gate = Promise.withResolvers<undefined>()
const cleanupStarted = Promise.withResolvers<undefined>()
const sessionDisposed = Promise.withResolvers<undefined>()
const sessionId = SessionId('quiescent-reuse')
ctx.on('session/disposed', (session) => {
if (session.id === sessionId) sessionDisposed.resolve(undefined)
})
const first = await ctx.agents.create({
sessionId,
agentOptions: { provider: 'mock', model: 'mock' },
@@ -1028,15 +1028,16 @@ describe('agent scope lifecycle', () => {
})
const disposing = first.dispose()
await Promise.all([sessionDisposed.promise, cleanupStarted.promise])
await cleanupStarted.promise
expect(ctx.agents.get(sessionId)).toBe(first.agent)
expect(ctx.sessions.get(sessionId)).toBe(first.agent.session)
gate.resolve(undefined)
await disposing
expect(ctx.agents.get(sessionId)).toBeUndefined()
expect(ctx.sessions.get(sessionId)).toBeUndefined()
const replacement = await ctx.agents.create({ sessionId, agentOptions: { provider: 'mock', model: 'mock' } })
expect(ctx.agents.get(sessionId)).toBe(replacement.agent)
expect(ctx.sessions.get(sessionId)).toBe(replacement.agent.session)
gate.resolve(undefined)
await disposing
await replacement.dispose()
await ctx.fiber.dispose()
})

View File

@@ -9,7 +9,7 @@ import { CallId, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import LlmService from '@deepseek-ai/dsh-llm'
import ToolRegistry, { defineTool, TOOL_ABORTED_BEFORE_DISPATCH, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools'
import ToolRegistry, { defineContentToolFixture, TOOL_ABORTED_BEFORE_DISPATCH, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop, { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, textResponse } from './mock-adapter.ts'
@@ -61,7 +61,7 @@ function multiCall(calls: { id: string; name: string; args: object }[]): StreamC
function gatedTool(name: string, parallel: boolean) {
const gates = new Map<string, () => void>()
const started: string[] = []
const tool = defineTool({
const tool = defineContentToolFixture({
name,
description: `gated ${name}`,
parameters: { id: { type: 'string', required: true } },
@@ -123,12 +123,12 @@ describe('tool-call scheduler: grouping and barriers', () => {
textResponse('done'),
])
const ctx = await harness(adapter)
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'r', description: 'read', parameters: { id: { type: 'string', required: true } },
isConcurrencySafe: () => true,
async execute(args) { order.push(`r-start-${args.id}`); order.push(`r-end-${args.id}`); return [{ type: 'text', text: 'r' }] },
}))
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'w', description: 'write', parameters: { id: { type: 'string', required: true } },
async execute(args) { order.push(`w-${args.id}`); return [{ type: 'text', text: 'w' }] },
}))
@@ -150,14 +150,14 @@ describe('tool-call scheduler: grouping and barriers', () => {
])
const ctx = await harness(adapter)
const replacement = gatedExclusiveTool('x')
const disposeSafe = ctx.tools.register(defineTool({
const disposeSafe = ctx.tools.register(defineContentToolFixture({
name: 'x',
description: 'initially safe',
parameters: { id: { type: 'string', required: true } },
isConcurrencySafe: () => true,
async execute(args) { return [{ type: 'text', text: `old-${args.id}` }] },
}))
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'replace',
description: 'replace x',
parameters: { id: { type: 'string', required: true } },
@@ -567,7 +567,7 @@ describe('tool-call scheduler: abort handling', () => {
const gated = gatedParallelTool('p')
const exclusive: string[] = []
ctx.tools.register(gated.tool)
ctx.tools.register(defineTool({
ctx.tools.register(defineContentToolFixture({
name: 'x',
description: 'exclusive',
parameters: { id: { type: 'string', required: true } },

View File

@@ -98,7 +98,9 @@ describe('loop-level canonical tool order', () => {
const ctx = await harness(adapter, ['ghost', TOOL_ORDER_REST])
registerNamed(ctx, 'alpha')
const errors: Error[] = []
ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
ctx.on('agent/error', (_agent, _turn, _step, error) => {
if (error instanceof Error) errors.push(error)
})
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.followup([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)

View File

@@ -1,196 +0,0 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId, type TurnEndReason } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent, type ContinuationStop } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import InvariantService from '@deepseek-ai/dsh-invariants'
import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant'
import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
async function mountInvariants(ctx: Context): Promise<void> {
await ctx.plugin(InvariantService)
await ctx.plugin(SessionInvariant)
await ctx.plugin(AgentInvariant)
await ctx.plugin(AgentLoopInvariant)
}
async function harness(adapter: MockAdapter): Promise<Context> {
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 mountInvariants(ctx)
await ctx.plugin(AgentLoop, { agents: [] })
ctx.llm.registerAdapter(['mock'], adapter)
return ctx
}
function send(agent: Agent, text = 'go'): Promise<void> {
agent.followup([{ type: 'text', text }])
return agent.whenIdle()
}
function registerEcho(ctx: Context): void {
ctx.tools.register(defineContentToolFixture({
name: 'echo',
description: 'echo',
parameters: { text: { type: 'string' } },
async execute(args) {
return [{ type: 'text', text: String(args.text) }]
},
}))
}
describe('agent/turn-stop', () => {
it('runs after steering folding and discards terminal steering instead of creating another step or turn', async () => {
const adapter = new MockAdapter([
textResponse('the ordinary decision is stop'),
textResponse('must not be requested'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('terminal-steering'), { provider: 'mock', model: 'mock' })
agent.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' }))
let steered = false
ctx.on('agent/turn-continuation', async (subject, _turn, _default, _signal, next) => {
const downstream = await next()
if (subject === agent && !steered) {
steered = true
subject.steer([{ type: 'text', text: 'late continuation steering' }])
}
return downstream
}, { prepend: true })
await send(agent)
expect(adapter.requests).toHaveLength(1)
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
expect(agent.session.events.filter(event => event.type === 'step/start')).toHaveLength(1)
expect(agent.session.events.filter(event => event.type === 'steering/message')).toHaveLength(0)
})
it('discards steering that arrives from session/flush after the terminal checkpoint', async () => {
const adapter = new MockAdapter([
textResponse('terminal answer'),
textResponse('must not become a late-steering turn'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('terminal-flush-steering'), { provider: 'mock', model: 'mock' })
agent.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' }))
let injected = false
ctx.on('session/flush', (session) => {
if (session !== agent.session || injected) return
injected = true
agent.steer([{ type: 'text', text: 'steering from flush' }])
})
await send(agent)
expect(injected).toBe(true)
expect(agent.status).toBe('idle')
expect(adapter.requests).toHaveLength(1)
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
expect(agent.session.events.filter(event => event.type === 'step/start')).toHaveLength(1)
expect(agent.session.events.filter(event => event.type === 'steering/message')).toHaveLength(0)
})
it('preserves an ordinary queued send that arrives during terminal flush', async () => {
const adapter = new MockAdapter([
textResponse('first terminal answer'),
textResponse('queued follow-up answer'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('terminal-flush-send'), { provider: 'mock', model: 'mock' })
agent.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' }))
let queued = false
ctx.on('session/flush', (session) => {
if (session !== agent.session || queued) return
queued = true
agent.followup([{ type: 'text', text: 'ordinary queued follow-up' }])
})
await send(agent)
expect(agent.status).toBe('idle')
expect(adapter.requests).toHaveLength(2)
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2)
expect(agent.session.events.filter(event => event.type === 'step/start')).toHaveLength(2)
})
it('filters a scoped terminal listener to its own agent', async () => {
const adapter = new MockAdapter([
toolCallResponse('a1', 'echo', { text: 'a' }),
toolCallResponse('b1', 'echo', { text: 'b' }),
textResponse('b continues normally'),
])
const ctx = await harness(adapter)
registerEcho(ctx)
const stopped = ctx.agentLoop.create(SessionId('stopped'), { provider: 'mock', model: 'mock' })
const ordinary = ctx.agentLoop.create(SessionId('ordinary'), { provider: 'mock', model: 'mock' })
stopped.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' }))
await send(stopped)
expect(adapter.requests).toHaveLength(1)
await send(ordinary)
expect(adapter.requests).toHaveLength(3)
expect(stopped.session.events.filter(event => event.type === 'step/start')).toHaveLength(1)
expect(ordinary.session.events.filter(event => event.type === 'step/start')).toHaveLength(2)
})
it('unregisters with its scoped owner disposer', async () => {
const adapter = new MockAdapter([
toolCallResponse('first', 'echo', { text: 'first' }),
toolCallResponse('second', 'echo', { text: 'second' }),
textResponse('continued after listener disposal'),
])
const ctx = await harness(adapter)
registerEcho(ctx)
const agent = ctx.agentLoop.create(SessionId('owned-listener'), { provider: 'mock', model: 'mock' })
const disposeStop = agent.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' }))
await send(agent, 'first turn')
expect(adapter.requests).toHaveLength(1)
disposeStop()
await send(agent, 'second turn')
expect(adapter.requests).toHaveLength(3)
})
it('fails a throwing terminal policy closed while the driver survives', async () => {
const adapter = new MockAdapter([
textResponse('throwing policy'),
textResponse('healthy later turn'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('bad-policy'), { provider: 'mock', model: 'mock' })
const reasons: TurnEndReason[] = []
const errors: string[] = []
ctx.on('session/event', (session, event) => {
if (session === agent.session && event.type === 'turn/end') reasons.push(event.data.reason)
})
agent.ctx.on('agent/error', (_subject, _turn, _step, error) => { errors.push(error.message) })
const disposeThrowing = agent.ctx.on('agent/turn-stop', () => {
throw new Error('terminal policy exploded')
})
await send(agent, 'first')
disposeThrowing()
await send(agent, 'healthy')
expect(reasons.map(reason => reason.kind)).toEqual(['error', 'completed'])
expect(errors).toContain('terminal policy exploded')
expect(adapter.requests).toHaveLength(2)
})
})

View File

@@ -46,7 +46,7 @@ Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-age
The lifecycle edges have two important local caveats. `agent/created` runs after scoped setup and after both session and agent registry entries exist. Setup is trusted composition-only code; the immediately following non-vetoing `agent/session-start` notification is the first supported startup injection point. `agent/disposed` always means the exact agent has left the registry. AgentLoop emits it after its driver is quiescent, while ordered teardown may still be detaching the session and unwinding the scope; custom agents registered directly own any stronger driver-ordering contract themselves.
Most interception points are cooperative waterfalls returning seam-specific decisions. Turn-scoped asynchronous seams receive one explicit `AbortSignal`, with `signal` immediately before a waterfall's final `next`; listeners may cooperate but must not retain it as authority over another turn. The signal remains authoritative through terminal policy and is retired immediately before `turn/end` publication, so terminal observers and the following durability flush cannot cancel completed turn work. `agent/pre-step` and `agent/post-step` are serial checkpoints around a step's durable work, while `agent/request-error` is the failed-model-request recovery waterfall: it receives the exact error, normalized failure facts, immutable prior-retried facts, and signal after the failed step closes; a retry opens a new numbered step. `agent/turn-stop` is the terminal serial fold: it runs after ordinary continuation and steering folding, and a returned stop remains in force through turn close and flush so later steering cannot create an extra step or turn. Ordinary queued prompts remain intact. Effective broad cancellation first emits the observe-only `agent/cancel-requested` with its resolved typed cause, then clears queues and aborts; notification failures are contained and cannot veto the stop. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns signal lifetime; the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way) owns scoped dispatch and terminal settlement.
Most interception points are cooperative waterfalls. Turn-scoped asynchronous seams receive one explicit `AbortSignal`, with `signal` immediately before a waterfall's final `next`; listeners may cooperate but must not retain it as authority over another turn. `agent/step` is the serial checkpoint before request derivation, while `agent/request-error` is the failed-model-request recovery waterfall: it receives the exact error, normalized failure facts, and signal after the failed step closes. A listener calls `agent.retry()` and returns without `next()` when it owns recovery; the loop closes the failed turn and opens one numbered retry turn. `agent/stopping` runs before an otherwise completed turn closes. Ordinary queued prompts remain intact. Effective broad cancellation first emits the observe-only `agent/cancel-requested` with its resolved typed cause, then clears queues and aborts; notification failures are contained and cannot veto the stop. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns signal lifetime; the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way) owns scoped dispatch and terminal settlement.
`PromptDecision.additionalContexts` is an array so every context keeps its own source. Allowed prompt content and every additional context become separate model-facing `user/message` events before the turn runs. A listener that wraps a downstream allow preserves its `content` and `additionalContexts` unless it intentionally replaces either field; the returned allow is authoritative.

View File

@@ -66,7 +66,11 @@ export interface AgentEventDispatch {
waterfall<K extends AgentSubjectEvent>(name: K, ...rest: Tail<K>): Return<Events[K]>
}
/** Return the fused scope carrier for one agent subject. */
/**
* Return the fused scope carrier for one agent subject.
* @param agent - the subject agent and scope key.
* @returns the carrier passed as the event dispatcher `this` value.
*/
export function agentCarrier(agent: Agent): Scoped<Agent> {
return scopeTarget(agent, agent)
}
@@ -116,7 +120,13 @@ export function agentEvents(ctx: Context, agent: Agent): AgentEventDispatch {
}
}
/** Emit one contained agent notification without allocating a retained dispatcher. */
/**
* Emit one contained agent notification without allocating a retained dispatcher.
* @param ctx - the context to dispatch through.
* @param agent - the subject agent and scope key.
* @param name - the agent-subject event to emit.
* @param rest - the event arguments after the injected agent.
*/
export function emitAgentEvent<K extends AgentSubjectEvent>(
ctx: Context,
agent: Agent,

View File

@@ -103,8 +103,8 @@ export interface CancelOptions {
/**
* An agent's lifecycle state, emitted on every transition as `agent/status`:
* `idle` (parked, waiting for queued work), `running` (the driver is draining
* work and may be closing or checkpointing a turn), `disposed` (terminal — no
* transition leaves it, and `send`/`followup`/`steer`/`inject` throw).
* work and may be closing or checkpointing a turn). Disposal removes the
* agent from its registry; it is not a third observable status.
*/
export type AgentStatus = 'idle' | 'running'
@@ -121,11 +121,13 @@ export type PromptDecision =
| { kind: 'allow'; content?: ContentBlock[]; additionalContexts?: AdditionalContext[] }
| { kind: 'block'; reason: string }
/** Model-request failure with an optional machine-routable provider code. */
export type RequestError = Error & { code?: string }
/**
* Why a turn ended, reported live on `agent/idle` right after the turn's
* durable `turn/end` and flush. `error` carries the thrown value verbatim (and, for
* model-request failures, the adapter-normalized facts) so a recovery
* consumer can decide to repair and {@link Agent.retry}.
* durable `turn/end`. `error` carries the thrown value verbatim for observers;
* model-request recovery runs earlier through `agent/request-error`.
*/
export type IdleReason =
| { kind: 'completed' }
@@ -179,9 +181,8 @@ export abstract class Agent {
* Clear queued and steering work — unless `keepInbox` — and abort the active
* turn. An effective call first emits `agent/cancel-requested` with the
* resolved typed cause. The first cause wins for the active turn, and
* `whenIdle()` resolves after cancellation reaches quiescence. Omitted cause
* means `{ kind: 'user' }`. Idle cancellation is a no-op and does not arm
* later work. The active turn snapshots and freezes the cause.
* `whenIdle()` resolves after cancellation reaches quiescence. Idle
* cancellation is a no-op and does not arm later work.
* @param cause - the stable caller intent carried by the current turn signal.
* @param options - cancellation options; `keepInbox` preserves pending work.
*/
@@ -245,11 +246,10 @@ export abstract class Agent {
/**
* Re-open a turn on the current session log without a new prompt — the
* recovery verb. After an `agent/idle` error, a consumer repairs (edits the
* log, waits out a rate limit) and calls this; the machine immediately runs
* another turn over the repaired history. Calling it synchronously from an
* `agent/idle` listener is legal — the machine is already idle there.
* @throws while a turn is running because there is nothing to retry yet.
* explicit resummon verb. During `agent/request-error`, this schedules one
* retry turn after the failed turn closes; while idle, it starts one
* immediately. Repeated calls before the scheduled retry coalesce.
* @throws while other agent work is running.
*/
abstract retry(): void
}
@@ -270,7 +270,7 @@ declare module 'cordis' {
'agent/created'(this: Scoped<Agent>, agent: Agent): void
/**
* An agent left the registry; AgentLoop emits this after driver quiescence
* but before session detachment and scoped-registration unwind. Custom
* and scoped-registration unwind, but before session detachment. Custom
* registry users own their driver-ordering contract.
* @param agent - the exact agent removed from the registry.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
@@ -278,8 +278,8 @@ declare module 'cordis' {
*/
'agent/disposed'(this: Scoped<Agent>, agent: Agent): void
/**
* Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does
* not enter `running` synchronously; drive lifecycle from this event.
* Agent status changed (`idle` ⇄ `running`). `send()` does not enter
* `running` synchronously; drive lifecycle from this event.
* @param agent - the agent whose status flipped.
* @param status - the status just entered (the transition's destination).
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
@@ -321,7 +321,7 @@ declare module 'cordis' {
* is cleared or the active turn is aborted. This observe-only notification
* cannot veto cancellation; listener failures are contained.
* @param agent - the agent whose current work is being cancelled.
* @param cause - resolved typed cancellation cause, including the default.
* @param cause - the explicit typed cancellation cause.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit
*/
@@ -377,8 +377,23 @@ declare module 'cordis' {
* @param signal - the current turn's explicit abort signal.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode waterfall
*/
*/
'agent/request'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, signal: AbortSignal, next: () => Promise<LlmCallConfig>): Promise<LlmCallConfig>
/**
* Handle a model-request failure after its failed step has closed but
* before the failed turn closes. A listener calls {@link Agent.retry} to
* schedule one retry turn, returns without `next()` when it owns the error,
* or calls `next()` to delegate. The default leaves the failure terminal.
* @param agent - the agent whose request failed.
* @param turn - the open turn number.
* @param step - the failed step number.
* @param error - the original model-request failure.
* @param failure - serializable facts normalized at the final adapter boundary.
* @param signal - the turn abort signal.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode waterfall
*/
'agent/request-error'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, error: RequestError, failure: LlmFailure, signal: AbortSignal, next: () => Promise<void>): Promise<void>
/**
* The turn is about to close: the model owes no response (no live tool
* calls, no fresh steering). Awaited before the boundary commits — a
@@ -395,14 +410,13 @@ declare module 'cordis' {
*/
'agent/stopping'(this: Scoped<Agent>, agent: Agent, turn: number, signal: AbortSignal): Promise<void> | void
/**
* One turn closed: its `turn/end` and durability flush are already
* committed. `reason` says why — recovery consumers observe an `error`
* reason, repair (edit the log, wait, resummon), and call
* {@link Agent.retry}; UI consumers key turn-done presentation off it.
* Emitted per turn, including cancelled and failed ones.
* One drain chain reached its terminal turn: that turn's `turn/end` is
* already committed. Automatically recovered failed turns do not emit this
* notification. `reason` says why; model-request recovery is exhausted when
* an error reaches it.
* @param agent - the agent whose turn closed.
* @param turn - the closed turn number.
* @param reason - why the turn ended, with live error facts when it failed.
* @param turn - the terminal turn number.
* @param reason - why the terminal turn ended, with live error facts when it failed.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit
*/

View File

@@ -12,7 +12,6 @@ import AgentRegistry, {
import type {
AgentCancelCause,
AgentFactory,
ContinuationStop,
CreateAgentOptions,
ResumeAgentOptions,
SendOptions,
@@ -30,6 +29,7 @@ function stubAgent(rawId: string, overrides: Partial<Agent> = {}): Agent {
ctx: new Context(),
send: () => AgentMessageId('stub'),
cancel() {},
retry() {},
whenIdle() { return Promise.resolve() },
...overrides,
})
@@ -58,14 +58,6 @@ describe('Agent delivery aliases', () => {
})
describe('AgentRegistry', () => {
it('allows terminal stop policy to cooperate asynchronously with turn cancellation', () => {
type TurnStopListener = Events['agent/turn-stop']
type AsyncTurnStopListener = () => Promise<ContinuationStop | undefined>
expectTypeOf<AsyncTurnStopListener>().toExtend<TurnStopListener>()
expectTypeOf<Awaited<ReturnType<TurnStopListener>>>().toEqualTypeOf<ContinuationStop | undefined>()
})
it('registers exact entries, emits lifecycle events, and unregisters on owner disposal', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
@@ -219,7 +211,7 @@ describe('agentEvents()', () => {
describe('explicit cancellation helpers', () => {
it('exposes the closed typed cancellation cause at the Agent seam', () => {
expectTypeOf<Parameters<Agent['cancel']>[0]>().toEqualTypeOf<AgentCancelCause | undefined>()
expectTypeOf<Parameters<Agent['cancel']>[0]>().toEqualTypeOf<AgentCancelCause>()
expectTypeOf<Parameters<Events['agent/cancel-requested']>[1]>().toEqualTypeOf<AgentCancelCause>()
})

View File

@@ -17,19 +17,14 @@ function mockAgent(id: string): Agent {
}
describe('agent status invariants', () => {
it('accepts lifecycle transitions through idle, running, and disposed', async () => {
it('accepts lifecycle transitions between idle and running', async () => {
const ctx = await setup()
const agent = mockAgent('a1')
expect(() => {
ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'idle')
ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'running')
ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'idle')
ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'disposed')
}).not.toThrow()
const running = mockAgent('a2')
ctx.emit(scopeTarget(running, running), 'agent/status', running, 'running')
expect(() => { ctx.emit(scopeTarget(running, running), 'agent/status', running, 'disposed') }).not.toThrow()
})
it('rejects a no-op transition', async () => {
@@ -40,14 +35,6 @@ describe('agent status invariants', () => {
.toThrow(/no-op transition/)
})
it('rejects leaving the terminal disposed state', async () => {
const ctx = await setup()
const agent = mockAgent('a4')
ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'disposed')
expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'idle') })
.toThrow(/left terminal state disposed/)
})
it('tracks agents independently', async () => {
const ctx = await setup()
const a = mockAgent('a5')

View File

@@ -21,25 +21,25 @@ describe('installAgentLlmTarget()', () => {
expect((await ctx.systemPrompt.assemble()).variables).toEqual({})
await expect(agentEvents(ctx, agent).waterfall(
'agent/request', 1, 0, seed, signal, () => Promise.resolve(seed),
'agent/request', 1, 0, signal, () => Promise.resolve(seed),
)).resolves.toBe(seed)
target.current = { provider: 'alpha', model: 'a1' }
expect((await ctx.systemPrompt.assemble()).variables).toMatchObject({ provider: 'alpha', model: 'a1' })
target.current = { provider: 'beta', model: 'b1' }
await expect(agentEvents(ctx, agent).waterfall(
'agent/request', 1, 0, seed, signal, () => Promise.resolve(seed),
'agent/request', 1, 0, signal, () => Promise.resolve(seed),
)).resolves.toEqual({ provider: 'alpha', model: 'a1', temperature: 0.2 })
expect((await ctx.systemPrompt.assemble()).variables).toMatchObject({ provider: 'beta', model: 'b1' })
await expect(agentEvents(ctx, agent).waterfall(
'agent/request', 1, 1, seed, signal, () => Promise.resolve(seed),
'agent/request', 1, 1, signal, () => Promise.resolve(seed),
)).resolves.toEqual({ provider: 'beta', model: 'b1', temperature: 0.2 })
dispose()
expect((await ctx.systemPrompt.assemble()).variables).toEqual({})
await expect(agentEvents(ctx, agent).waterfall(
'agent/request', 2, 0, seed, signal, () => Promise.resolve(seed),
'agent/request', 2, 0, signal, () => Promise.resolve(seed),
)).resolves.toBe(seed)
await ctx.fiber.dispose()
})

View File

@@ -18,6 +18,7 @@ const scopedSubjectResolvers: Readonly<Record<string, ScopedSubjectResolver | nu
'agent/inbox/enqueue': args => args[0],
'agent/prompt-submit': args => args[0],
'agent/request': args => args[0],
'agent/request-error': args => args[0],
'agent/session-start': args => args[0],
'agent/status': args => args[0],
'agent/step': args => args[0],

View File

@@ -37,7 +37,6 @@ describe('scoped-dispatch invariants', () => {
const other = { id: 'a2' } as unknown as Agent
const signal = new AbortController().signal
const config = { provider: 'p', model: 'm' }
const message = { role: 'assistant' as const, content: [] }
const agentRows = {
'agent/created': [agent],
'agent/disposed': [agent],
@@ -47,15 +46,20 @@ describe('scoped-dispatch invariants', () => {
'agent/inbox/discard': [agent, []],
'agent/cancel-requested': [agent, { kind: 'user' }],
'agent/session-start': [agent, 'startup'],
'agent/pre-step': [agent, 1, 1, signal],
'agent/post-step': [agent, 1, 1, signal],
'agent/step': [agent, 1, 1, signal],
'agent/prompt-submit': [agent, [], { kind: 'user' }, signal, () => Promise.resolve({ kind: 'allow' })],
'agent/request': [agent, 1, 1, config, signal, () => Promise.resolve(config)],
'agent/request-error': [agent, 1, 1, new Error('request failed'), { message: 'request failed', code: 'UNKNOWN' }, [], signal, () => Promise.resolve({ action: 'fail' })],
'agent/session-prefix': [agent, [], signal, () => Promise.resolve([])],
'agent/step-result': [agent, 1, 1, message, signal, () => Promise.resolve(message)],
'agent/turn-continuation': [agent, 1, { action: 'stop' }, signal, () => Promise.resolve({ action: 'stop' })],
'agent/turn-stop': [agent, 1, signal],
'agent/request': [agent, 1, 1, signal, () => Promise.resolve(config)],
'agent/request-error': [
agent,
1,
1,
new Error('request'),
{ message: 'request', code: 'UNKNOWN' },
signal,
() => Promise.resolve(),
],
'agent/stopping': [agent, 1, signal],
'agent/idle': [agent, 1, { kind: 'completed' }],
'agent/error': [agent, 1, 0, new Error('x')],
} satisfies { [K in AgentEventName]: EventArgs<K> }
const rows: Array<[string, unknown[]]> = [

View File

@@ -85,13 +85,9 @@ export interface TurnTriggerMap {
/** Recovery turn reopened over the repaired current session log. */
retry: { kind: 'retry' }
/**
* An out-of-band context injection (`agent.inject()`) made while the agent
* was idle. The loop wraps the injected `user/message` (a non-`user` source,
* plugin by default) in a one-shot turn (`turn/start` → `user/message`
* `turn/end`) so every event in the log stays turn-enclosed — the
* durability/replay boundary is the turn, and a bare event between turns would
* otherwise be indistinguishable from a crash tail on reload. The trigger's
* `source` mirrors that message's producer.
* An out-of-band producer explicitly enclosed injected context in a one-shot
* turn. `Agent.inject()` appends idle context directly and does not use this
* trigger; the source mirrors the producer of the enclosed `user/message`.
*/
injection: { kind: 'injection'; source: MessageSource }
}

View File

@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest'
import { randomUUID } from 'node:crypto'
import { mkdtemp } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
@@ -46,12 +47,9 @@ async function isolatedSkillsConfig(catalogDescriptionMaxLength?: number): Promi
}
async function composePrefix(ctx: Context): Promise<Message[]> {
const agent = { session: { header: { cwd: '/tmp' } } } as unknown as Agent
const empty: Message[] = []
return await agentEvents(ctx, agent).waterfall(
'agent/session-prefix', empty, new AbortController().signal,
() => Promise.resolve(empty),
)
const agent = ctx.agentLoop.create(SessionId(`acp-demo-prefix-${randomUUID()}`), {}, { cwd: '/tmp' })
await agentEvents(ctx, agent).serial('agent/step', 1, 1, new AbortController().signal)
return agent.session.deriveMessages()
}
async function withIsolatedSkillHomes<T>(run: () => Promise<T>): Promise<T> {

View File

@@ -26,12 +26,9 @@ declare module '@deepseek-ai/dsh-tasks' {
}
async function composePrefix(ctx: Context, cwd: string): Promise<Message[]> {
const agent = { session: { header: { cwd } } } as unknown as Agent
const empty: Message[] = []
return await agentEvents(ctx, agent).waterfall(
'agent/session-prefix', empty, new AbortController().signal,
() => Promise.resolve(empty),
)
const agent = ctx.agentLoop.create(SessionId('agent-spine-prefix'), {}, { cwd })
await agentEvents(ctx, agent).serial('agent/step', 1, 1, new AbortController().signal)
return agent.session.deriveMessages()
}
/**
@@ -99,15 +96,8 @@ async function withIsolatedSkillHomes<T>(run: () => Promise<T>): Promise<T> {
}
}
function waitForIdle(ctx: Context, target: Agent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (agent, status) => {
if (agent === target && status === 'idle') {
dispose()
resolve()
}
})
})
function waitForIdle(_ctx: Context, target: Agent): Promise<void> {
return target.whenIdle()
}
function messageText(message: Message | undefined): string {
@@ -236,6 +226,7 @@ describe('dsh-agent-spine-demo bundle', () => {
})
handle.agent.followup([{ type: 'text', text: 'recover' }])
await expect.poll(() => adapter.requests).toBe(2)
await waitForIdle(ctx, handle.agent)
expect(adapter.requests).toBe(2)
@@ -457,8 +448,8 @@ describe('dsh-agent-spine-demo bundle', () => {
handle.agent.followup([{ type: 'text', text: 'hi' }])
await waitForIdle(ctx, handle.agent)
expect(messageText(adapter.requests[0]?.messages[0])).toContain('workspace rule before skills')
expect(messageText(adapter.requests[0]?.messages[1])).toContain('prefix-order-skill')
expect(messageText(adapter.requests[0]?.messages[1])).toContain('workspace rule before skills')
expect(messageText(adapter.requests[0]?.messages[2])).toContain('prefix-order-skill')
await handle.dispose()
await ctx.fiber.dispose()
} finally {

View File

@@ -225,22 +225,24 @@ export async function runOneShot(ctx: Context, options: OneShotOptions): Promise
let targetTurn: number | undefined
let reason: TurnEndReason | undefined
let result = ''
const usageByStep = new Map<number, TokenUsage>()
const usageByStep = new Map<string, TokenUsage>()
let outputError: Error | undefined
let resolveTurn!: () => void
let rejectTurn!: (error: Error) => void
let settled = false
let firstTurnEnded = false
const turnEnded = new Promise<void>((resolve, reject) => {
resolveTurn = resolve
rejectTurn = reject
})
const settleResolved = (): void => {
settled = true
if (firstTurnEnded) return
firstTurnEnded = true
resolveTurn()
}
const settleRejected = (error: Error): void => {
settled = true
if (firstTurnEnded) return
firstTurnEnded = true
rejectTurn(error)
}
const observe = (sessionId: string, event: SessionEvent): void => {
@@ -254,20 +256,26 @@ export async function runOneShot(ctx: Context, options: OneShotOptions): Promise
}
const disposeListener = ctx.on('session/event', (session, event) => {
if (session !== agent.session || settled) return
if (session !== agent.session) return
if (targetTurn === undefined) {
if (event.type !== 'turn/start' || event.data.trigger.kind !== 'message') return
targetTurn = event.data.turn
} else if (event.type === 'turn/start' && event.data.trigger.kind === 'retry'
&& reason?.kind === 'error') {
targetTurn = event.data.turn
reason = undefined
}
observe(session.id, event)
if (event.type === 'assistant/chunk'
&& event.data.turn === targetTurn
&& event.data.chunk.type === 'usage') {
usageByStep.set(event.data.step, event.data.chunk.usage)
usageByStep.set(`${event.data.turn}/${event.data.step}`, event.data.chunk.usage)
}
if (event.type === 'assistant/message' && event.data.turn === targetTurn) {
result = assistantText(event) ?? result
if (event.data.usage !== undefined) usageByStep.set(event.data.step, event.data.usage)
if (event.data.usage !== undefined) {
usageByStep.set(`${event.data.turn}/${event.data.step}`, event.data.usage)
}
}
if (event.type === 'turn/end' && event.data.turn === targetTurn) {
reason = event.data.reason
@@ -289,14 +297,14 @@ export async function runOneShot(ctx: Context, options: OneShotOptions): Promise
try {
/* v8 ignore next -- skips send only when cancellation wins the listener-registration race above */
if (!settled) { // eslint-disable-line @typescript-eslint/no-unnecessary-condition
if (!firstTurnEnded) { // eslint-disable-line @typescript-eslint/no-unnecessary-condition
agent.followup([{ type: 'text', text: options.task }])
}
await turnEnded
} finally {
if (onAbort !== undefined) signal?.removeEventListener('abort', onAbort)
disposeListener()
await agent.whenIdle()
disposeListener()
}
/* v8 ignore next 3 -- turnEnded resolves only from the matching branch that assigns both values */

View File

@@ -1,9 +1,11 @@
import { mkdtemp } from 'node:fs/promises'
import { randomUUID } from 'node:crypto'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
import { agentEvents } from '@deepseek-ai/dsh-agent'
import { SessionId } from '@deepseek-ai/dsh-session'
import { CallId, type Message } from '@deepseek-ai/dsh-llm'
import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
@@ -39,12 +41,9 @@ async function mount(config: cliDemo.Config, withBash = false): Promise<Context>
}
async function composePrefix(ctx: Context): Promise<Message[]> {
const agent = { session: { header: { cwd: '/tmp' } } } as unknown as Agent
const empty: Message[] = []
return await agentEvents(ctx, agent).waterfall(
'agent/session-prefix', empty, new AbortController().signal,
() => Promise.resolve(empty),
)
const agent = ctx.agentLoop.create(SessionId(`cli-demo-prefix-${randomUUID()}`), {}, { cwd: '/tmp' })
await agentEvents(ctx, agent).serial('agent/step', 1, 1, new AbortController().signal)
return agent.session.deriveMessages()
}
afterEach(async () => {

View File

@@ -319,7 +319,7 @@ describe('runOneShot and executeCli', () => {
const { ctx, agent, persistenceRoot } = await harness([textResponse('final answer')])
const output = await invoke(ctx, ['task'])
expect(output).toEqual({ code: 0, stdout: 'final answer\n', stderr: '' })
expect(agent.status).toBe('disposed')
expect(agent.status).toBe('idle')
const files = await readdir(persistenceRoot, { recursive: true })
expect(files.some(file => file.endsWith('.jsonl.zstd'))).toBe(true)
})
@@ -379,11 +379,13 @@ describe('runOneShot and executeCli', () => {
const output = await invoke(ctx, ['--output-format', 'stream-json', 'task'])
const lines = output.stdout.trimEnd().split('\n').map(line => JSON.parse(line) as Record<string, unknown>)
const events = lines.slice(0, -1).map(line => line['event'] as SessionEvent)
expect(lines.at(-1)).toMatchObject({ type: 'result', success: true, turn: 2, result: 'streamed' })
expect(events[0]).toMatchObject({ type: 'turn/start', data: { turn: 2, trigger: { kind: 'message' } } })
expect(events.at(-1)).toMatchObject({ type: 'turn/end', data: { turn: 2 } })
expect(lines.at(-1)).toMatchObject({ type: 'result', success: true, turn: 1, result: 'streamed' })
expect(events[0]).toMatchObject({ type: 'turn/start', data: { turn: 1, trigger: { kind: 'message' } } })
expect(events.at(-1)).toMatchObject({ type: 'turn/end', data: { turn: 1 } })
expect(lines.slice(0, -1).every(line => line['sessionId'] === agent.session.id)).toBe(true)
expect(events.some(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toBe(false)
expect(events.some(event => event.type === 'user/message'
&& event.data.source.kind === 'plugin'
&& event.data.source.plugin === 'test')).toBe(false)
})
it('emits partial data and a diagnostic for non-completed turns', async () => {
@@ -409,7 +411,7 @@ describe('runOneShot and executeCli', () => {
expect(JSON.parse(output.stdout)).toMatchObject({ success: false, reason: { kind: 'aborted' } })
expect(output.code).toBe(1)
expect(output.stderr).toContain('turn 1 was aborted')
expect(agent.status).toBe('disposed')
expect(agent.status).toBe('idle')
})
it('contains stream-writer failures, cancels, flushes, and returns the output error', async () => {
@@ -444,7 +446,7 @@ describe('runOneShot and executeCli', () => {
expect(output.code).toBe(1)
expect(output.stdout).toBe('')
expect(output.stderr).toContain('stdout closed')
expect(final.agent.status).toBe('disposed')
expect(final.agent.status).toBe('idle')
const disposal = await harness([textResponse('answer')])
const disposalOutput = await invoke(disposal.ctx, ['task'], { failDispose: true })

View File

@@ -52,6 +52,7 @@ function stubAgent(id: string): { agent: Agent; session: Session } {
steer: () => AgentMessageId('stub'),
inject(content, options) { appendInjection(session, content, options); return AgentMessageId('stub') },
cancel() { status = 'idle' },
retry() {},
whenIdle() { return Promise.resolve() },
}
return { agent, session }

View File

@@ -360,15 +360,6 @@ export function apply(ctx: Context): void {
if (state.openTurn !== undefined) state.attempt.turn = state.openTurn
}
return
case 'prompt/blocked':
if (state.attempt !== undefined && state.attempt.phase === 'queued'
&& isGoalRoundSource(event.data.source) && sameRound(event.data.source, state.attempt)) {
/* v8 ignore next -- this driver's rejected message always follows its observed turn/start */
if (state.openTurn !== undefined) state.attempt.turn = state.openTurn
state.attempt.rejectedReason = event.data.reason
if (event.data.reason === STALE_ROUND_REASON) state.attempt.stale = true
}
return
case 'turn/end':
if (state.attempt?.turn === event.data.turn) state.attempt.reason = event.data.reason
/* v8 ignore next -- balanced live turns close the open turn just observed by this listener */
@@ -407,11 +398,27 @@ export function apply(ctx: Context): void {
}
if (!valid) {
const attempt = state.attempt
if (attempt !== undefined && sameRound(source, attempt)) attempt.stale = true
if (attempt !== undefined && sameRound(source, attempt)) {
attempt.stale = true
state.attempt = undefined
}
requestDrive(state)
return { kind: 'block', reason: STALE_ROUND_REASON }
}
const decision = await next()
if (decision.kind === 'block') return decision
if (decision.kind === 'block') {
const attempt = state.attempt
if (attempt !== undefined && sameRound(source, attempt)) state.attempt = undefined
const goal = currentGoal(state)
if (goal !== undefined && goal.id === source.goalId && goal.revision === source.revision
&& goal.phase === 'active' && goal.activation === 'armed') {
ctx.goals.block(agent, goalRef(goal), {
code: 'prompt-rejected',
message: decision.reason,
})
}
return decision
}
try {
valid = validReservation(state, content, source)
} catch (error: unknown) {
@@ -421,7 +428,11 @@ export function apply(ctx: Context): void {
}
if (!valid) {
const attempt = state.attempt
if (attempt !== undefined && sameRound(source, attempt)) attempt.stale = true
if (attempt !== undefined && sameRound(source, attempt)) {
attempt.stale = true
state.attempt = undefined
}
requestDrive(state)
return { kind: 'block', reason: STALE_ROUND_REASON }
}
return decision

View File

@@ -266,8 +266,7 @@ describe('same-session goal driving', () => {
expect(goal?.roundsStarted).toBe(0)
expect(goal?.blockedReason).toEqual({ code: 'prompt-rejected', message: 'deployment policy' })
expect(test.adapter.requests).toHaveLength(0)
expect(test.agent.session.events.some(event => event.type === 'prompt/blocked'
&& event.data.reason === 'deployment policy')).toBe(true)
expect(test.agent.session.events.some(event => event.type === 'turn/start')).toBe(false)
})
it('does not reserve again when a stopped-goal observer queues ordinary work', async () => {
@@ -391,9 +390,6 @@ describe('same-session goal driving', () => {
const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'blocked')
expect(goal).toMatchObject({ revision: 3, objective: 'new objective', roundsStarted: 1 })
const blocked = test.agent.session.events.find(event => event.type === 'prompt/blocked')
expect(blocked?.type === 'prompt/blocked' ? blocked.data.reason : undefined)
.toBe('stale goal-round reservation')
const admitted = test.agent.session.events.find(event => event.type === 'user/message'
&& event.data.source.kind === 'goal' && event.data.source.round > 0)
expect(admitted?.type === 'user/message' && admitted.data.source.kind === 'goal'
@@ -419,8 +415,6 @@ describe('same-session goal driving', () => {
expect(goal).toMatchObject({ objective: 'edited downstream', roundsStarted: 1 })
expect(test.adapter.requests).toHaveLength(1)
expect(test.agent.session.events.some(event => event.type === 'prompt/blocked'
&& event.data.reason === 'stale goal-round reservation')).toBe(true)
})
it('disarms without dispatch when a durability checkpoint fails', async () => {
@@ -447,38 +441,6 @@ describe('same-session goal driving', () => {
expect(test.adapter.requests).toHaveLength(0)
})
it('disarms an admitted round when a later injection hides its failed closing checkpoint', async () => {
const test = await harness([textResponse('not durable')])
let injected = false
test.ctx.on('session/flush', (session) => {
const lastStart = session.events.findLast(event => event.type === 'turn/start')
if (lastStart?.type === 'turn/start' && lastStart.data.trigger.kind === 'message'
&& lastStart.data.trigger.source.kind === 'goal' && !injected) {
injected = true
test.agent.inject([{ type: 'text', text: 'concurrent completion notice' }], {
source: { kind: 'plugin', plugin: 'test' },
})
return Promise.reject(new Error('round flush failed'))
}
})
test.ctx.goals.create(test.agent, { objective: 'checkpoint the result' })
const goal = await waitForGoal(
test.ctx,
test.agent,
current => current?.roundsStarted === 1 && current.activation === 'disarmed',
)
expect(goal?.phase).toBe('active')
expect(test.adapter.requests).toHaveLength(1)
const turns = test.agent.session.events.filter(event => event.type === 'turn/start')
const goalTurn = turns.findIndex(event => event.data.trigger.kind === 'message'
&& event.data.trigger.source.kind === 'goal')
const injectedTurn = turns.findIndex(event => event.data.trigger.kind === 'injection'
&& event.data.trigger.source.kind === 'plugin')
expect(injectedTurn).toBeGreaterThan(goalTurn)
})
it('blocks the goal when a custom agent rejects the otherwise valid follow-up', async () => {
const test = await harness([])
// Reject only the goal-sourced round follow-up, not the state-change injection
@@ -520,25 +482,6 @@ describe('same-session goal driving', () => {
expect(test.adapter.requests).toHaveLength(0)
})
it('contains a driver read failure and removes continuation authority', async () => {
const test = await harness([])
let flushes = 0
test.ctx.on('session/flush', () => {
flushes += 1
if (flushes !== 2) return
vi.spyOn(test.ctx.goals, 'get').mockImplementationOnce(() => {
throw new Error('corrupt projection')
})
})
test.ctx.goals.create(test.agent, { objective: 'fail the driver closed' })
await new Promise<void>((resolve) => { setImmediate(resolve) })
const goal = test.ctx.goals.get(test.agent)
expect(goal?.phase).toBe('active')
expect(test.adapter.requests).toHaveLength(0)
})
it('contains synchronous scheduler startup failure', async () => {
const test = await harness([])
vi.spyOn(test.ctx.agents, 'withoutInitiator').mockImplementationOnce(() => {
@@ -583,8 +526,6 @@ describe('same-session goal driving', () => {
await waitForGoal(test.ctx, test.agent, goal => goal?.phase === 'blocked')
expect(test.adapter.requests).toHaveLength(1)
expect(test.agent.session.events.some(event => event.type === 'prompt/blocked'
&& event.data.reason === 'stale goal-round reservation')).toBe(true)
})
it('fails a post-hook read closed before the prompt can enter history', async () => {
@@ -615,8 +556,7 @@ describe('same-session goal driving', () => {
await test.agent.whenIdle()
expect(test.adapter.requests).toHaveLength(0)
expect(test.agent.session.events.some(event => event.type === 'prompt/blocked'
&& event.data.reason === 'stale goal-round reservation')).toBe(true)
expect(test.agent.session.events.some(event => event.type === 'turn/start')).toBe(false)
})
it('does not invent goal state when ordinary queued work is cancelled', async () => {
@@ -715,9 +655,9 @@ describe('same-session goal driving', () => {
expect(test.ctx.goals.get(test.agent)).toMatchObject({
phase: 'active',
activation: 'disarmed',
roundsStarted: 1,
roundsStarted: 0,
})
expect(test.adapter.requests).toHaveLength(1)
expect(test.adapter.requests).toHaveLength(0)
})
it('resets process-local scheduling state at a session-start edge', async () => {

View File

@@ -72,6 +72,7 @@ function stubAgentForSession(session: Session): StubAgent {
return AgentMessageId('stub')
},
cancel() {},
retry() {},
whenIdle() { return Promise.resolve() },
}
return {
@@ -276,11 +277,6 @@ describe('GoalService creation and replay', () => {
}))
})
it('rejects a disposed live object even before registry teardown', async () => {
const test = await harness()
test.setStatus('disposed')
expect(() => test.ctx.goals.get(test.agent)).toThrow(expect.objectContaining({ code: 'GOAL_AGENT_NOT_LIVE' }))
})
})
describe('GoalService mutations', () => {

View File

@@ -43,6 +43,7 @@ function stubAgent(rawId: string, supplied?: Session): StubAgent {
return AgentMessageId('stub')
},
cancel() {},
retry() {},
whenIdle() { return Promise.resolve() },
}
return { agent, session, setStatus(value) { status = value } }
@@ -336,7 +337,6 @@ describe('goal tool state transitions', () => {
goal_id: goal['id'], revision: goal['revision'], action: 'resume',
}, root.agent))
expect(goal).toMatchObject({ phase: 'active', revision: 4 })
expect(await agentEvents(ctx, root.agent).serial('agent/turn-stop', 1, testToolSignal)).toBeUndefined()
})
it('terminal-stops an autonomous completion but leaves a human pause interactive', async () => {
@@ -347,21 +347,20 @@ describe('goal tool state transitions', () => {
goal_id: created.id, revision: created.revision, action: 'pause',
}, root.agent)
expect(resultGoal(paused)).toMatchObject({ phase: 'paused' })
expect(await agentEvents(ctx, root.agent).serial('agent/turn-stop', humanTurn, testToolSignal)).toBeUndefined()
expect(paused.concludesTurn).toBeUndefined()
const resumed = resultGoal(await execute(ctx, 'update_goal', {
goal_id: created.id, revision: 2, action: 'resume',
}, root.agent))
closeTurn(root, humanTurn)
const roundTurn = openTurn(root, {
openTurn(root, {
kind: 'goal', goalId: created.id, revision: resumed['revision'] as number, round: 1,
})
const complete = await execute(ctx, 'update_goal', {
goal_id: created.id, revision: resumed['revision'], action: 'complete',
}, root.agent)
expect(resultGoal(complete)).toMatchObject({ phase: 'complete' })
expect(await agentEvents(ctx, root.agent).serial('agent/turn-stop', roundTurn, testToolSignal)).toEqual({ action: 'stop' })
expect(await agentEvents(ctx, root.agent).serial('agent/turn-stop', roundTurn, testToolSignal)).toBeUndefined()
expect(complete.concludesTurn).toBe(true)
})
it('rearms a restored active goal only after a new direct human prompt', async () => {

View File

@@ -12,7 +12,7 @@
import { readFileSync } from 'node:fs'
import type { Context } from 'cordis'
import z from 'schemastery'
import type { AdditionalContext, Agent, ContinuationDecision, PromptDecision } from '@deepseek-ai/dsh-agent'
import type { AdditionalContext, Agent, PromptDecision } from '@deepseek-ai/dsh-agent'
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-session-persistence'
import type { PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
@@ -258,16 +258,16 @@ export function apply(ctx: Context, config: Config): void {
}
})
// A blocking Stop hook forces continuation with its reason.
// A blocking Stop hook steers at the stopping boundary, which makes the
// machine observe pending input and run another step.
// TODO(stop-loop-guard): cap consecutive forced continuations; hooks must self-limit meanwhile.
ctx.on('agent/turn-continuation', async (agent, turn, _default, signal, next): Promise<ContinuationDecision> => {
ctx.on('agent/stopping', async (agent, turn, signal): Promise<void> => {
const merged = await runPoint('Stop', '', stopPayload(ctx, agent), { agent, turn, signal })
if (merged.decision === 'deny') {
// A blocking Stop hook forces continuation.
const text = merged.reason ?? 'continue: blocked by Stop hook'
return { action: 'continue', reason: { content: [{ type: 'text', text }], source: PLUGIN_SOURCE } }
agent.steer([{ type: 'text', text }], { source: PLUGIN_SOURCE })
}
return next()
})
// SubagentStart may inject child context; SubagentStop only observes. Both

View File

@@ -58,12 +58,8 @@ async function harnessWithFiber(configDir: string, adapter: MockAdapter): Promis
return { ctx, hooks }
}
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') { dispose(); resolve() }
})
})
function waitForIdle(_ctx: Context, agent: Agent): Promise<void> {
return agent.whenIdle()
}
function events(agent: Agent): SessionEvent[] {
@@ -85,7 +81,7 @@ async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10):
}
describe('hooks-claude bridge — UserPromptSubmit', () => {
it('a UserPromptSubmit hook that exits 2 blocks the prompt (rejected turn)', async () => {
it('a UserPromptSubmit hook that exits 2 rejects admission without a turn', async () => {
// The UserPromptSubmit hook exits 2 (blocking) with a reason on stderr.
const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-claude-'))
dirs.push(dir)
@@ -100,10 +96,9 @@ describe('hooks-claude bridge — UserPromptSubmit', () => {
agent.followup([{ type: 'text', text: 'do something' }])
await waitForIdle(ctx, agent)
// The prompt was blocked: model never called, turn ended rejected.
// The prompt was blocked before the model and before a turn opened.
expect(adapter.requests).toHaveLength(0)
const turnEnd = events(agent).findLast(e => e.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind).toBe('rejected')
expect(events(agent).some(e => e.type === 'turn/start')).toBe(false)
// The hook ran and was recorded.
expect(events(agent).some(e => e.type === 'hook/invoked' && e.data.point === 'UserPromptSubmit')).toBe(true)
expect(events(agent).some(e => e.type === 'hook/result' && e.data.decision === 'block')).toBe(true)

View File

@@ -46,8 +46,8 @@ async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOp
ctx.llm.registerAdapter(['mock'], adapter)
return ctx
}
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => { const d = ctx.on('agent/status', (s, st) => { if (s === agent && st === 'idle') { d(); resolve() } }) })
function waitForIdle(_ctx: Context, agent: Agent): Promise<void> {
return agent.whenIdle()
}
function events(agent: Agent): SessionEvent[] { return [...agent.session.events] }
/** Poll until `predicate` holds or the deadline passes — robust to detached
@@ -316,8 +316,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.followup([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
const turnEnd = events(agent).findLast(e => e.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'rejected' && turnEnd.data.reason.reason).toContain('blocked by UserPromptSubmit hook')
expect(events(agent).some(e => e.type === 'turn/start')).toBe(false)
})
it('a PreToolUse ask with NO reason omits the reason (false arm)', async () => {
@@ -497,8 +496,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
// recorded, and the (sole, fully-blocked) prompt closed the turn `rejected`
expect(adapter.requests).toHaveLength(0)
expect(events(agent).some(e => e.type === 'user/message' && e.data.source.kind !== 'user')).toBe(false)
const turnEnd = events(agent).findLast(e => e.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toMatchObject({ kind: 'rejected', reason: 'policy veto' })
expect(events(agent).some(e => e.type === 'turn/start')).toBe(false)
})
it('preserves separate bridge and downstream prompt contexts with framing and metadata', async () => {

View File

@@ -15,7 +15,7 @@
import { readFileSync } from 'node:fs'
import type { Context } from 'cordis'
import z from 'schemastery'
import type { AdditionalContext, Agent, ContinuationDecision, PromptDecision } from '@deepseek-ai/dsh-agent'
import type { AdditionalContext, Agent, PromptDecision } from '@deepseek-ai/dsh-agent'
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-session-persistence'
import type { PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
@@ -236,11 +236,12 @@ export function apply(ctx: Context, config: Config): void {
}
})
// Stop → ContinuationDecision. A blocking Stop hook forces continuation.
// A blocking Stop hook steers at the stopping boundary, which makes the
// machine observe pending input and run another step.
// TODO(stop-loop-guard): Codex supplies `stop_hook_active` so a Stop hook can
// avoid continuing the same turn indefinitely. It is always false here, so an
// unconditionally blocking hook force-continues every step until it self-limits.
ctx.on('agent/turn-continuation', async (agent, turn, _default, signal, next): Promise<ContinuationDecision> => {
ctx.on('agent/stopping', async (agent, turn, signal): Promise<void> => {
const merged = await runPoint('Stop', '', { ...turnBase(ctx, agent, 'Stop', model), stop_hook_active: false, last_assistant_message: null }, { agent, turn, signal })
/* jscpd:ignore-end */
if (merged.decision === 'deny') {
@@ -248,9 +249,8 @@ export function apply(ctx: Context, config: Config): void {
// empty stderr) still forces it — fall back to a generic steering line
// rather than letting the turn stop.
const text = merged.reason ?? 'continue: blocked by Stop hook'
return { action: 'continue', reason: { content: [{ type: 'text', text }], source: PLUGIN_SOURCE } }
agent.steer([{ type: 'text', text }], { source: PLUGIN_SOURCE })
}
return next()
})
}

View File

@@ -47,12 +47,8 @@ async function harness(dir: string, adapter: MockAdapter): Promise<Context> {
return ctx
}
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') { dispose(); resolve() }
})
})
function waitForIdle(_ctx: Context, agent: Agent): Promise<void> {
return agent.whenIdle()
}
function events(agent: Agent): SessionEvent[] { return [...agent.session.events] }
@@ -125,9 +121,7 @@ describe('hooks-codex bridge', () => {
expect(() => process.kill(pid, 0)).toThrow()
expect(adapter.requests).toHaveLength(0)
expect(events(agent).findLast(event => event.type === 'turn/end')).toMatchObject({
data: { reason: { kind: 'aborted' } },
})
expect(events(agent).some(event => event.type === 'turn/start')).toBe(false)
expect(events(agent).some(event => event.type === 'hook/result' && event.data.point === 'UserPromptSubmit')).toBe(true)
})

View File

@@ -36,8 +36,8 @@ async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOp
ctx.llm.registerAdapter(['mock'], adapter)
return ctx
}
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => { const d = ctx.on('agent/status', (s, st) => { if (s === agent && st === 'idle') { d(); resolve() } }) })
function waitForIdle(_ctx: Context, agent: Agent): Promise<void> {
return agent.whenIdle()
}
function events(agent: Agent): SessionEvent[] { return [...agent.session.events] }
/** Poll until `predicate` holds or the deadline passes — robust to detached
@@ -78,7 +78,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
expect((await capture()).payload.transcript_path).toBeNull()
}, 15_000) // Two real agent/hook subprocess loops need process startup and teardown headroom.
it('UserPromptSubmit block (exit 2) rejected turn; default reason on empty stderr', async () => {
it('UserPromptSubmit block (exit 2) rejects admission without a turn', async () => {
const d = dir()
hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'b.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] })
const adapter = new MockAdapter([textResponse('no')])
@@ -86,8 +86,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(0)
const te = events(agent).findLast(e => e.type === 'turn/end')
expect(te?.type === 'turn/end' && te.data.reason.kind).toBe('rejected')
expect(events(agent).some(e => e.type === 'turn/start')).toBe(false)
})
it('UserPromptSubmit additionalContext is injected; a no-op hook proceeds', async () => {
@@ -112,8 +111,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
agent.followup([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(0)
expect(events(agent).some(e => e.type === 'user/message')).toBe(false)
const te = events(agent).findLast(e => e.type === 'turn/end')
expect(te?.type === 'turn/end' && te.data.reason).toMatchObject({ kind: 'rejected', reason: 'policy veto' })
expect(events(agent).some(e => e.type === 'turn/start')).toBe(false)
})
it('preserves separate bridge and downstream prompt contexts with framing and metadata', async () => {

View File

@@ -381,15 +381,6 @@ describe('sessions.prompt / cancel', () => {
if (!response.result.ok) expect(response.result.error.code).toBe('session-not-found')
})
it('maps a synchronous send throw to agent-busy', async () => {
const { api } = await boot()
const { sessionId } = expectOk(await api.sessions.create(request({})))
const poisoned = [{ type: 'text', text: 'x', bad: () => 1 }] as never
const response = await api.sessions.prompt(request({ sessionId, mode: 'queue' as const, content: poisoned }))
expect(response.result.ok).toBe(false)
if (!response.result.ok) expect(response.result.error.code).toBe('agent-busy')
})
it('cancels an attached agent and rejects an unattached one', async () => {
const running = await boot(['hang'])
const { api, ctx } = running
@@ -469,7 +460,10 @@ describe('sessions.history', () => {
const all = expectOk(await api.sessions.history(request({ sessionId })))
expect(all.hasMore).toBe(false)
const messageCount = all.events.filter(entry => entry.event.type === 'user/message' || entry.event.type === 'assistant/message').length
const messageCount = all.events.filter(entry =>
entry.event.type === 'assistant/message'
|| (entry.event.type === 'user/message' && entry.event.data.source.kind === 'user'),
).length
expect(messageCount).toBe(6)
const lastPage = expectOk(await api.sessions.history(request({ sessionId, maxMessages: 1 })))

View File

@@ -1,12 +1,12 @@
# `@deepseek-ai/dsh-llm-retry`
Function plugin that retries selected transient model-request failures on the agent loop's closed-step recovery seam. It does not wrap `ctx.llm.stream()`: every adapter call remains one provider attempt, and every retry opens a fresh numbered step.
Function plugin that retries selected transient model-request failures through the `agent/request-error` waterfall. It does not wrap `ctx.llm.stream()`: every adapter call remains one provider attempt, and every retry opens a fresh numbered turn.
The default policy permits two retries for `RATE_LIMIT`, `SERVER`, `TIMEOUT`, and `TRANSPORT`, using bounded exponential backoff from 500 ms to 10 seconds with 10 percent jitter. Delay bounds must fit Node's supported timer range. A valid `providerRetryAfterMs` replaces local backoff when it is within the configured cap; an over-cap instruction delegates to the next recovery policy instead.
Before waiting, the plugin appends a non-surface `llm/retry` event with the failure and scheduled delay. Cancellation and plugin disposal abort the wait; disposal drains the plugin's active backoffs, and a callback captured before disposal fails closed if invoked afterward.
The recovery listener appends a non-surface `llm/retry` event after the failed step, waits for the backoff while the failed turn's signal remains live, then calls `agent.retry()`. The loop closes that failed turn and opens a retry turn over the same durable history. The policy keeps its own retry count across that uninterrupted recovery chain and clears it at terminal `agent/idle`. Turn cancellation and plugin disposal abort the wait.
The separately published `./invariant` companion checks that every retry record names the current open turn and its latest closed step, has a unique step record and increasing retry number, and carries a positive bounded retry budget and non-negative bounded timer delay. Full jitter may schedule zero milliseconds at its lower boundary.
The separately published `./invariant` companion checks that every retry record appears inside an open turn after its failed step, matches its position in the current retry chain, and carries a positive bounded retry budget and non-negative bounded timer delay. Full jitter may schedule zero milliseconds at its lower boundary.
```yaml
- name: '@deepseek-ai/dsh-llm-retry'
@@ -24,7 +24,7 @@ The separately published `./invariant` companion checks that every retry record
#### What the model sees
No retry event, delay, or failure prose is model-visible. After a retry, the next numbered step reconstructs the same explicit provider/model request from durable session history; failed chunks never enter derived messages.
No retry event, delay, or failure prose is model-visible. The retry turn reconstructs the same explicit provider/model request from durable session history; failed chunks never enter derived messages.
#### Token effect
@@ -36,6 +36,6 @@ The reconstructed request preserves the prior prefix and is eligible for provide
## Known Limitations and Deferred Work
- **Agent steps are the only retry boundary** — direct `ctx.llm.stream()` consumers remain single-attempt because a raw stream cannot separate already-emitted chunks durably.
- **Agent turns are the only retry boundary** — direct `ctx.llm.stream()` consumers remain single-attempt because a raw stream cannot separate already-emitted chunks durably.
- **Finite plugin budgets add** — this policy counts only configured transient codes; context-overflow compaction counts only its own code. A future policy with overlapping codes must document and test registration-order behavior.
- **`llm/retry` records scheduling, not completion** — later step and turn events establish success, exhaustion, or cancellation.
- **`llm/retry` records completed backoff, not request completion** — later step and turn events establish success, exhaustion, or cancellation.

View File

@@ -1,13 +1,13 @@
/**
* Bounded transient model-request retry policy on the agent loop's closed-step
* recovery seam. Each scheduled retry is durable before its cancellable wait.
* Bounded transient model-request retry policy on the agent request-recovery
* seam. Each scheduled retry is durable before its cancellable wait.
*
* @module @deepseek-ai/dsh-llm-retry
*/
import type { Context } from 'cordis'
import z from 'schemastery'
import type { Agent, RequestError, RequestErrorDecision } from '@deepseek-ai/dsh-agent'
import type { Agent, RequestError } from '@deepseek-ai/dsh-agent'
import type { LlmFailure } from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-session'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
@@ -145,7 +145,8 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna
const resolved = resolveConfig(config)
const random = internals.random ?? Math.random
const lifetime = new AbortController()
const active = new Set<Promise<RequestErrorDecision>>()
const active = new Set<Promise<void>>()
const retries = new WeakMap<Agent, number>()
async function backoff(
agent: Agent,
@@ -155,9 +156,9 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna
retry: number,
delayMs: number,
signal: AbortSignal,
): Promise<RequestErrorDecision> {
): Promise<void> {
const fusedSignal = AbortSignal.any([signal, lifetime.signal])
if (fusedSignal.aborted) return { action: 'fail' }
if (fusedSignal.aborted) return
agent.session.append('llm/retry', {
turn,
step,
@@ -166,29 +167,33 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna
delayMs,
failure,
})
if (!await cancellableDelay(delayMs, fusedSignal)) return { action: 'fail' }
return { action: 'retry' }
retries.set(agent, retry)
if (!await cancellableDelay(delayMs, fusedSignal)) return
agent.retry()
}
ctx.on('agent/idle', (agent) => {
retries.delete(agent)
})
const disposeListener = ctx.on('agent/request-error', (
agent: Agent,
turn: number,
step: number,
_error: RequestError,
failure: LlmFailure,
priorFailures: readonly LlmFailure[],
signal: AbortSignal,
next: () => Promise<RequestErrorDecision>,
next: () => Promise<void>,
) => {
// A waterfall may have captured this callback before its registration was
// removed. Lifetime cancellation must prevent that stale callback from
// entering a downstream policy after disposal.
if (lifetime.signal.aborted) return Promise.resolve<RequestErrorDecision>({ action: 'fail' })
if (lifetime.signal.aborted) return Promise.resolve()
if (!resolved.retryableCodes.has(failure.code)) return next()
const priorTransientFailures = priorFailures.filter(item => resolved.retryableCodes.has(item.code)).length
if (priorTransientFailures >= resolved.maxTransientRetries) return next()
const priorRetries = retries.get(agent) ?? 0
if (priorRetries >= resolved.maxTransientRetries) return next()
const retry = priorTransientFailures + 1
const retry = priorRetries + 1
let delayMs: number
if (failure.providerRetryAfterMs !== undefined
&& Number.isFinite(failure.providerRetryAfterMs)

View File

@@ -13,6 +13,34 @@ export const name = 'llm-retry-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/** Find the first turn in the structured-failure retry chain containing `turn`. */
function retryChainStart(history: readonly SessionEvent[], turn: number): number {
let startIndex = history.findLastIndex(
event => event.type === 'turn/start' && event.data.turn === turn,
)
while (startIndex >= 0) {
const start = history[startIndex]
if (start?.type !== 'turn/start' || start.data.trigger.kind !== 'retry') break
let endIndex = startIndex - 1
while (endIndex >= 0 && history[endIndex]?.type !== 'turn/end') endIndex -= 1
const end = history[endIndex]
if (end?.type !== 'turn/end'
|| end.data.reason.kind !== 'error'
|| end.data.reason.failure === undefined) break
const previousStart = history.findLastIndex(
(event, index) =>
index < endIndex
&& event.type === 'turn/start'
&& event.data.turn === end.data.turn,
)
if (previousStart < 0) break
startIndex = previousStart
}
return startIndex
}
/** Validate one retry record against the open turn and most recently closed step. */
function validateRetry(
history: readonly SessionEvent[],
@@ -59,14 +87,15 @@ function validateRetry(
fail(`llm/retry names step ${step}, but the latest closed step is ${String(closedStep)}`)
}
const priorRetries = currentTurnEvents
const chainStart = retryChainStart(history, turn)
const chainRetries = history.slice(Math.max(chainStart, 0))
.filter((prior): prior is SessionEvent<'llm/retry'> => prior.type === 'llm/retry')
if (priorRetries.some(prior => prior.data.step === step)) {
if (chainRetries.some(prior => prior.data.turn === turn && prior.data.step === step)) {
fail(`llm/retry duplicates the retry record for turn ${turn}/step ${step}`)
}
const priorRetry = priorRetries[0]
if (priorRetry !== undefined && retry <= priorRetry.data.retry) {
fail(`llm/retry retry ${retry} must increase after retry ${priorRetry.data.retry}`)
const expectedRetry = chainRetries.length + 1
if (retry !== expectedRetry) {
fail(`llm/retry retry ${retry} must equal retry-chain position ${expectedRetry}`)
}
}

View File

@@ -13,32 +13,35 @@ async function setup(): Promise<Context> {
return ctx
}
const failure = { message: 'provider busy', code: 'RATE_LIMIT', status: 429 }
function closeStep(ctx: Context, id: string, turn = 1, step = 1) {
const session = ctx.sessions.create(SessionId(id))
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', {
turn,
trigger: turn === 1
? { kind: 'message', source: { kind: 'user' } }
: { kind: 'retry' },
})
session.append('step/start', { turn, step })
session.append('step/end', { turn, step })
return session
}
const failure = { message: 'provider busy', code: 'RATE_LIMIT', status: 429 }
describe('llm-retry invariants', () => {
it('accepts increasing retry records for successive closed steps and ignores unrelated events', async () => {
it('accepts increasing retry schedules for successive failed turns', async () => {
const ctx = await setup()
const session = closeStep(ctx, 'retry-invariant-valid')
expect(() => {
session.append('llm/retry', {
turn: 1, step: 1, retry: 1, maxRetries: 2, delayMs: 500, failure,
})
session.append('step/start', { turn: 1, step: 2 })
session.append('step/end', { turn: 1, step: 2 })
session.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, failure } })
session.append('turn/start', { turn: 2, trigger: { kind: 'retry' } })
session.append('step/start', { turn: 2, step: 1 })
session.append('step/end', { turn: 2, step: 1 })
session.append('llm/retry', {
turn: 1, step: 2, retry: 2, maxRetries: 2, delayMs: 1_000, failure,
})
const zeroDelay = closeStep(ctx, 'retry-invariant-zero-delay')
zeroDelay.append('llm/retry', {
turn: 1, step: 1, retry: 1, maxRetries: 1, delayMs: 0, failure,
turn: 2, step: 1, retry: 2, maxRetries: 2, delayMs: 0, failure,
})
}).not.toThrow()
expect(() => { ctx.emit('tools/change') }).not.toThrow()
@@ -60,7 +63,7 @@ describe('llm-retry invariants', () => {
}).toThrow(message)
})
it('rejects retry records outside the matching closed-step boundary', async () => {
it('requires an open turn and its latest closed step', async () => {
const ctx = await setup()
const absent = ctx.sessions.create(SessionId('retry-invariant-no-turn'))
expect(() => {
@@ -85,31 +88,15 @@ describe('llm-retry invariants', () => {
})
}).toThrow(/step 1 is still open/)
const noStep = ctx.sessions.create(SessionId('retry-invariant-no-step'))
noStep.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
expect(() => {
noStep.append('llm/retry', {
turn: 1, step: 1, retry: 1, maxRetries: 2, delayMs: 1, failure,
})
}).toThrow(/latest closed step is undefined/)
const wrongStep = closeStep(ctx, 'retry-invariant-wrong-step')
expect(() => {
wrongStep.append('llm/retry', {
turn: 1, step: 2, retry: 1, maxRetries: 2, delayMs: 1, failure,
})
}).toThrow(/latest closed step is 1/)
const closedTurn = closeStep(ctx, 'retry-invariant-closed-turn')
closedTurn.append('turn/end', { turn: 1, reason: { kind: 'aborted' } })
expect(() => {
closedTurn.append('llm/retry', {
turn: 1, step: 1, retry: 1, maxRetries: 2, delayMs: 1, failure,
})
}).toThrow(/inside an open turn/)
})
it('rejects duplicate and non-increasing retry records', async () => {
it('rejects duplicate and out-of-sequence retry schedules', async () => {
const ctx = await setup()
const duplicate = closeStep(ctx, 'retry-invariant-duplicate')
duplicate.append('llm/retry', {
@@ -119,26 +106,52 @@ describe('llm-retry invariants', () => {
duplicate.append('llm/retry', {
turn: 1, step: 1, retry: 2, maxRetries: 3, delayMs: 1, failure,
})
}).toThrow(/duplicates the retry record/)
}).toThrow(/duplicates/)
const nonIncreasing = closeStep(ctx, 'retry-invariant-non-increasing')
nonIncreasing.append('llm/retry', {
turn: 1, step: 1, retry: 1, maxRetries: 3, delayMs: 1, failure,
})
nonIncreasing.append('step/start', { turn: 1, step: 2 })
nonIncreasing.append('step/end', { turn: 1, step: 2 })
nonIncreasing.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, failure } })
nonIncreasing.append('turn/start', { turn: 2, trigger: { kind: 'retry' } })
nonIncreasing.append('step/start', { turn: 2, step: 1 })
nonIncreasing.append('step/end', { turn: 2, step: 1 })
expect(() => {
nonIncreasing.append('llm/retry', {
turn: 1, step: 2, retry: 1, maxRetries: 3, delayMs: 1, failure,
turn: 2, step: 1, retry: 1, maxRetries: 3, delayMs: 1, failure,
})
}).toThrow(/must increase/)
}).toThrow(/retry-chain position 2/)
})
it('resets retry numbering after a completed chain', async () => {
const ctx = await setup()
const session = closeStep(ctx, 'retry-invariant-reset')
session.append('llm/retry', {
turn: 1, step: 1, retry: 1, maxRetries: 2, delayMs: 1, failure,
})
session.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, failure } })
session.append('turn/start', { turn: 2, trigger: { kind: 'retry' } })
session.append('step/start', { turn: 2, step: 1 })
session.append('step/end', { turn: 2, step: 1 })
session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
session.append('turn/start', {
turn: 3,
trigger: { kind: 'message', source: { kind: 'user' } },
})
session.append('step/start', { turn: 3, step: 1 })
session.append('step/end', { turn: 3, step: 1 })
expect(() => {
session.append('llm/retry', {
turn: 3, step: 1, retry: 1, maxRetries: 2, delayMs: 1, failure,
})
}).not.toThrow()
})
it('validates existing histories on late registration', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('retry-invariant-late'))
session.append('step/end', { turn: 1, step: 1 })
session.append('llm/retry', {
turn: 1, step: 1, retry: 1, maxRetries: 2, delayMs: 1, failure,
})

View File

@@ -7,7 +7,6 @@ import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import Include from '@cordisjs/plugin-include'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import LlmService, { LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
@@ -32,17 +31,6 @@ class TransientOnceAdapter extends LlmAdapter {
}
}
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()
}
})
})
}
afterEach(async () => {
await context?.fiber.dispose()
context = undefined
@@ -113,9 +101,9 @@ describe('real Loader composition', () => {
const adapter = new TransientOnceAdapter()
loaded.llm.registerAdapter(['mock'], adapter)
const agent = loaded.agentLoop.create(SessionId('loader-retry'), { provider: 'mock', model: 'mock' })
const idle = waitForIdle(loaded, agent)
agent.followup([{ type: 'text', text: 'recover' }])
await idle
await expect.poll(() => adapter.requests).toBe(2)
await agent.whenIdle()
expect(adapter.requests).toBe(2)
expect(agent.session.events.filter(event => event.type === 'llm/retry')).toHaveLength(1)

View File

@@ -43,7 +43,14 @@ describe.each(['jsonl', 'sqlite'] as const)('%s retry-event persistence', (kind)
delayMs: 750,
failure: { message: 'provider busy', code: 'RATE_LIMIT', status: 429 },
})
session.append('turn/end', { turn: 1, reason: { kind: 'aborted' } })
session.append('turn/end', {
turn: 1,
reason: {
kind: 'error',
step: 1,
failure: { message: 'provider busy', code: 'RATE_LIMIT', status: 429 },
},
})
expect(session.deriveMessages()).toEqual([])
await ctx.sessions.flush(session)

View File

@@ -8,9 +8,8 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent, RequestErrorDecision } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import * as retry from '../src/index.ts'
type ScriptEntry = Error | Iterable<StreamChunk> | AsyncIterable<StreamChunk>
@@ -149,8 +148,8 @@ describe('bounded transient retry policy', () => {
await idle
expect(adapter.requests).toHaveLength(2)
expect(agent.session.events.filter(item => item.type === 'step/start').map(item => item.data.step))
.toEqual([1, 2])
expect(agent.session.events.filter(item => item.type === 'step/start').map(item => item.data))
.toEqual([{ turn: 1, step: 1 }, { turn: 2, step: 1 }])
expect(agent.session.deriveMessages().at(-1)).toEqual({
role: 'assistant',
content: [{ type: 'text', text: 'done' }],
@@ -185,11 +184,13 @@ describe('bounded transient retry policy', () => {
await idle
const failedChunks = agent.session.events.filter(event =>
event.type === 'assistant/chunk' && event.data.step === 1,
event.type === 'assistant/chunk' && event.data.turn === 1,
)
expect(failedChunks).toHaveLength(6)
expect(agent.session.events.filter(event => event.type === 'assistant/message').map(event => event.data.step))
.toEqual([2])
expect(agent.session.events.filter(event => event.type === 'assistant/message').map(event => ({
turn: event.data.turn,
step: event.data.step,
}))).toEqual([{ turn: 2, step: 1 }])
expect(agent.session.events.some(event => event.type === 'tool/call')).toBe(false)
expect(toolExecutions).toBe(0)
expect(agent.session.deriveMessages().at(-1)).toMatchObject({
@@ -232,6 +233,36 @@ describe('bounded transient retry policy', () => {
})
})
it('resets the retry budget for a later message', async () => {
vi.useFakeTimers()
const adapter = new ScriptedAdapter([
new LlmError('first busy', 'SERVER'),
textResponse('first done'),
new LlmError('second busy', 'SERVER'),
textResponse('second done'),
])
;({ ctx: context } = await harness(adapter, { maxTransientRetries: 1 }))
const agent = context.agentLoop.create(SessionId('retry-reset'), { provider: 'mock', model: 'mock' })
const firstRetry = waitForRetry(context, agent, 1)
agent.followup([{ type: 'text', text: 'first' }])
await firstRetry
const firstIdle = waitForIdle(context, agent)
await vi.advanceTimersByTimeAsync(500)
await firstIdle
const secondRetry = waitForRetry(context, agent, 1)
agent.followup([{ type: 'text', text: 'second' }])
await secondRetry
const secondIdle = waitForIdle(context, agent)
await vi.advanceTimersByTimeAsync(500)
await secondIdle
expect(agent.session.events.filter(event => event.type === 'llm/retry').map(event => event.data.retry))
.toEqual([1, 1])
expect(adapter.requests).toHaveLength(4)
})
it('accepts the zero-delay lower jitter bound', async () => {
vi.useFakeTimers()
const adapter = new ScriptedAdapter([
@@ -310,7 +341,6 @@ describe('bounded transient retry policy', () => {
agent.followup([{ type: 'text', text: 'go' }])
await scheduled
const idle = waitForIdle(context, agent)
await mounted.retryFiber.dispose()
await idle
await vi.advanceTimersByTimeAsync(60_000)
@@ -320,157 +350,4 @@ describe('bounded transient retry policy', () => {
expect(vi.getTimerCount()).toBe(0)
})
it('does not make plugin disposal wait for a delegated recovery policy', async () => {
const adapter = new ScriptedAdapter([new LlmError('bad key', 'AUTH')])
const mounted = await harness(adapter)
context = mounted.ctx
const downstream = Promise.withResolvers<RequestErrorDecision>()
const entered = Promise.withResolvers<undefined>()
context.on('agent/request-error', () => {
entered.resolve(undefined)
return downstream.promise
})
const agent = context.agentLoop.create(SessionId('retry-delegated-disposal'), {
provider: 'mock',
model: 'mock',
})
const idle = waitForIdle(context, agent)
agent.followup([{ type: 'text', text: 'go' }])
await entered.promise
const disposing = mounted.retryFiber.dispose()
let timer: ReturnType<typeof setTimeout> | undefined
const outcome = await Promise.race([
disposing.then(() => 'disposed' as const),
new Promise<'blocked'>((resolve) => { timer = setTimeout(() => { resolve('blocked') }, 100) }),
])
if (timer !== undefined) clearTimeout(timer)
downstream.resolve({ action: 'fail' })
await disposing
await idle
expect(outcome).toBe('disposed')
expect(adapter.requests).toHaveLength(1)
})
it('fails a captured callback after disposal without entering downstream policy', async () => {
const adapter = new ScriptedAdapter([new LlmError('bad key', 'AUTH')])
const captured = Promise.withResolvers<undefined>()
let invokeCaptured: (() => Promise<void>) | undefined
const mounted = await harness(adapter, {}, (ctx) => {
ctx.on('agent/request-error', (_agent, _turn, _step, _error, _failure, _history, _signal, next) => {
return new Promise<RequestErrorDecision>((resolve) => {
invokeCaptured = async () => { resolve(await next()) }
captured.resolve(undefined)
})
})
})
context = mounted.ctx
let downstreamCalls = 0
context.on('agent/request-error', async (_agent, _turn, _step, _error, _failure, _history, _signal, next) => {
downstreamCalls += 1
return next()
})
const agent = context.agentLoop.create(SessionId('retry-captured-disposal'), {
provider: 'mock',
model: 'mock',
})
const idle = waitForIdle(context, agent)
agent.followup([{ type: 'text', text: 'go' }])
await captured.promise
await mounted.retryFiber.dispose()
if (invokeCaptured === undefined) throw new Error('request-error waterfall did not capture retry callback')
await invokeCaptured()
await idle
expect(downstreamCalls).toBe(0)
expect(adapter.requests).toHaveLength(1)
})
it('lets turn cancellation win during backoff without opening another step', async () => {
vi.useFakeTimers()
const adapter = new ScriptedAdapter([
new LlmError('temporary', 'TIMEOUT'),
textResponse('must not run'),
])
;({ ctx: context } = await harness(adapter))
const agent = context.agentLoop.create(SessionId('retry-cancel'), { provider: 'mock', model: 'mock' })
const scheduled = waitForRetry(context, agent, 1)
agent.followup([{ type: 'text', text: 'go' }])
await scheduled
const idle = waitForIdle(context, agent)
agent.cancel({ kind: 'user' })
await idle
expect(adapter.requests).toHaveLength(1)
expect(agent.session.events.at(-1)).toMatchObject({
type: 'turn/end',
data: { reason: { kind: 'aborted' } },
})
expect(vi.getTimerCount()).toBe(0)
})
it('lets an earlier recovery listener cancel before retry policy runs', async () => {
vi.useFakeTimers()
const adapter = new ScriptedAdapter([
new LlmError('temporary', 'SERVER'),
textResponse('must not run'),
])
;({ ctx: context } = await harness(adapter, {}, (ctx) => {
ctx.on('agent/request-error', async (agent, _turn, _step, _error, _failure, _history, _signal, next) => {
agent.cancel({ kind: 'user' })
return next()
})
}))
const agent = context.agentLoop.create(SessionId('retry-pre-cancel'), { provider: 'mock', model: 'mock' })
const idle = waitForIdle(context, agent)
agent.followup([{ type: 'text', text: 'go' }])
await idle
expect(adapter.requests).toHaveLength(1)
expect(agent.session.events.some(event => event.type === 'llm/retry')).toBe(false)
expect(agent.session.events.at(-1)).toMatchObject({
type: 'turn/end',
data: { reason: { kind: 'aborted' } },
})
})
it('handles synchronous cancellation from the retry status event', async () => {
vi.useFakeTimers()
const adapter = new ScriptedAdapter([
new LlmError('temporary', 'SERVER'),
textResponse('must not run'),
])
;({ ctx: context } = await harness(adapter))
const agent = context.agentLoop.create(SessionId('retry-event-cancel'), { provider: 'mock', model: 'mock' })
context.on('session/event', (session, event) => {
if (session === agent.session && event.type === 'llm/retry') agent.cancel({ kind: 'user' })
})
const idle = waitForIdle(context, agent)
agent.followup([{ type: 'text', text: 'go' }])
await idle
expect(adapter.requests).toHaveLength(1)
expect(agent.session.events.filter(event => event.type === 'llm/retry')).toHaveLength(1)
expect(vi.getTimerCount()).toBe(0)
})
it.each([
[{ maxTransientRetries: -1 }, /maxTransientRetries/],
[{ maxTransientRetries: 1.5 }, /maxTransientRetries/],
[{ initialDelayMs: 0 }, /initialDelayMs/],
[{ maxDelayMs: Number.POSITIVE_INFINITY }, /maxDelayMs/],
[{ initialDelayMs: MAX_TIMER_DELAY_MS + 1 }, /initialDelayMs/],
[{ maxDelayMs: MAX_TIMER_DELAY_MS + 1 }, /maxDelayMs/],
[{ initialDelayMs: 20, maxDelayMs: 10 }, /less than or equal/],
[{ jitterRatio: 1.1 }, /jitterRatio/],
[{ retryableCodes: [] }, /must not be empty/],
[{ retryableCodes: ['SERVER', 'SERVER'] }, /duplicates/],
[{ retryableCodes: [''] }, /non-empty strings/],
] as const)('fails direct composition for invalid config %#', (config, message) => {
expect(() => { retry.apply(new Context(), config as retry.Config) }).toThrow(message)
})
})

View File

@@ -10,7 +10,7 @@
* wins), so resume and fork restore it without a live mirror. User selections
* are held as pending intent until a turn boundary because every session event
* is turn-enclosed. The service flushes before the affected request assembly
* on prompt submission, ordinary continuation, and request-recovery retry.
* on prompt submission and each request step (including retry turns).
*
* The exit tool remains registered while plan mode is inactive so crossing a
* boundary changes only the prompt section, not the request tool catalog.
@@ -175,27 +175,13 @@ export class PlanModeService extends Service {
}
ctx.on('agent/prompt-submit', (agent, _content, _source, _signal, next) =>
flushAfter(agent, next), { prepend: true })
ctx.on('agent/turn-continuation', (agent, _turn, _decision, _signal, next) =>
flushAfter(agent, next), { prepend: true })
ctx.on('agent/request-error', async (
agent,
_turn,
_step,
_error,
_failure,
_priorFailures,
_signal,
next,
) => {
const decision = await next()
// A waterfall can retain this wrapper after Cordis unregisters it.
if (disposed || decision.action !== 'retry') return decision
ctx.on('agent/step', (agent) => {
if (disposed) return
try {
this.onBoundary(agent)
} catch (error) {
ctx.logger.warn('dsh-plan-mode: boundary flush failed: %o', error)
}
return decision
}, { prepend: true })
ctx.effect(() => () => { disposed = true }, 'dsh-plan-mode: close boundary lifetime')

View File

@@ -128,7 +128,7 @@ describe('plan mode through the agent loop', () => {
expect(second.data.header.system).toContain('plan mode')
})
it('a mode flip during request recovery shapes the retry before its assembly', async () => {
it('a mode flip at error idle shapes the retry before its assembly', async () => {
const failedRequest = [{
type: 'finish',
reason: { kind: 'error', failure: { message: 'temporarily unavailable', code: 'SERVER', status: 503 } },
@@ -136,20 +136,14 @@ describe('plan mode through the agent loop', () => {
const adapter = new MockAdapter([failedRequest, textResponse('Recovered in plan mode.')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('it-plan-retry-flip'), { provider: 'mock', model: 'mock' })
const recoveryEntered = Promise.withResolvers<true>()
const releaseRecovery = Promise.withResolvers<true>()
ctx.on('agent/request-error', async (subject, _turn, _step, _error, _failure, _history, _signal, next) => {
if (subject !== agent) return next()
recoveryEntered.resolve(true)
await releaseRecovery.promise
return { action: 'retry' }
ctx.on('agent/idle', (subject, _turn, reason) => {
if (subject !== agent || reason.kind !== 'error') return
ctx.planMode.set(agent, true)
agent.retry()
})
const idle = waitForIdle(ctx, agent)
agent.followup([{ type: 'text', text: 'plan after the transient failure' }])
await recoveryEntered.promise
ctx.planMode.set(agent, true)
releaseRecovery.resolve(true)
await idle
expect(adapter.requests).toHaveLength(2)
@@ -158,8 +152,10 @@ describe('plan mode through the agent loop', () => {
expect(adapter.requests[1]?.tools).toEqual(adapter.requests[0]?.tools)
const log = agent.session.events
const planMode = findEvent(log, 'plan/mode')
const firstEnd = log.find(event => event.type === 'step/end' && event.data.step === 1)
const retryStart = log.find(event => event.type === 'step/start' && event.data.step === 2)
const firstEnd = log.find(event => event.type === 'step/end'
&& event.data.turn === 1 && event.data.step === 1)
const retryStart = log.find(event => event.type === 'step/start'
&& event.data.turn === 2 && event.data.step === 1)
expect(firstEnd?.seq).toBeLessThan(planMode.seq)
expect(planMode.seq).toBeLessThan(retryStart?.seq ?? 0)
expect(findEvent(log, 'request/header', 'last').data.header.system).toContain(PLAN_CONFIG.section)

View File

@@ -4,7 +4,7 @@ import { CallId } from '@deepseek-ai/dsh-llm'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { RUN_CODE_NAME, defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import { agentEvents, type Agent, type RequestErrorDecision } from '@deepseek-ai/dsh-agent'
import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
import { createScope } from '@deepseek-ai/dsh-scope'
import UserInteractionService, { type AskUserQuestionRequest } from '@deepseek-ai/dsh-user-interaction'
import CommandService from '@deepseek-ai/dsh-commands'
@@ -19,9 +19,8 @@ const PLAN_CONFIG = { section: TEST_PLAN_SECTION } satisfies PlanModeConfig
* Drives the REAL plugin: mounts `dsh-plan-mode` beside real `SystemPrompt` and
* `ToolRegistry` services, with fake Agents carrying real `Session`s and a
* real scoped `agent.ctx` minted through `createScope`.
* Turn boundaries are simulated by appending the real boundary events and
* dispatching the interception seams the loop fires there. Recovery retries
* exercise the separate `agent/request-error` wrapper.
* Request boundaries are simulated by dispatching the real prompt-admission
* and between-step seams used by the loop.
*/
async function agentWithSession(ctx: Context, id = 'agent-1', { active }: { active?: boolean } = {}): Promise<Agent & { session: Session }> {
@@ -53,39 +52,15 @@ async function setup(config: PlanModeConfig = PLAN_CONFIG): Promise<Context> {
}
/**
* Append a boundary event and dispatch the interception seam the loop fires
* there — `agent/prompt-submit` inside the just-opened turn,
* `agent/turn-continuation` after the step closed. Recovery retries use the
* separately covered `agent/request-error` wrapper; post-commit
* `session/event` observers remain observe-only.
* Dispatch either prompt admission or the between-step checkpoint.
*/
async function boundary(ctx: Context, agent: Agent & { session: Session }, type: 'turn/start' | 'step/end'): Promise<void> {
const events = agentEvents(ctx, agent)
if (type === 'turn/start') {
agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
await events.waterfall('agent/prompt-submit', [{ type: 'text', text: 'boundary probe' }], { kind: 'user' }, new AbortController().signal, () => Promise.resolve({ kind: 'allow' }))
return
}
agent.session.append('step/end', { turn: 1, step: 1 })
await events.waterfall('agent/turn-continuation', 1, { action: 'stop' }, new AbortController().signal, () => Promise.resolve({ action: 'stop' }))
}
/** Dispatch the closed-step recovery seam with one terminal decision. */
function recoveryBoundary(
ctx: Context,
agent: Agent & { session: Session },
decision: RequestErrorDecision,
): Promise<RequestErrorDecision> {
return agentEvents(ctx, agent).waterfall(
'agent/request-error',
1,
1,
new Error('request failed'),
{ message: 'request failed', code: 'SERVER' },
[],
new AbortController().signal,
() => Promise.resolve(decision),
)
await events.serial('agent/step', 1, 2, new AbortController().signal)
}
/** Append a minimal `request/header` snapshot so the log has a "what the model was told" anchor. */
@@ -218,16 +193,14 @@ describe('the boundary flush', () => {
// selection lands DURING its await — after this boundary began, before it
// returns. The prepended flush runs after next(), so the plan/mode still
// precedes the request this boundary gates.
ctx.on('agent/turn-continuation', async (_agent, _turn, decision, _signal, next) => {
ctx.on('agent/prompt-submit', async (_agent, _content, _source, _signal, next) => {
await new Promise(resolve => setTimeout(resolve, 5))
ctx.planMode.set(agent, true)
await next()
return decision
return next()
})
agent.session.append('step/end', { turn: 1, step: 1 })
await agentEvents(ctx, agent).waterfall(
'agent/turn-continuation', 1, { action: 'stop' }, new AbortController().signal,
() => Promise.resolve({ action: 'stop' }),
'agent/prompt-submit', [{ type: 'text', text: 'probe' }], { kind: 'user' },
new AbortController().signal, () => Promise.resolve({ kind: 'allow' }),
)
expect(foldPlanMode(agent.session.events)).toBe(true)
expect(ctx.planMode.get(agent)).toEqual({ active: true })
@@ -243,20 +216,18 @@ describe('the boundary flush', () => {
// A downstream listener captured before disposal keeps the waterfall
// continuation alive across the unload; the resumed wrapper must not
// append through the disposed service.
ctx.on('agent/turn-continuation', async (_agent, _turn, decision, _signal, next) => {
ctx.on('agent/prompt-submit', async (_agent, _content, _source, _signal, next) => {
await fiber.dispose()
await next()
return decision
return next()
})
agent.session.append('step/end', { turn: 1, step: 1 })
await agentEvents(ctx, agent).waterfall(
'agent/turn-continuation', 1, { action: 'stop' }, new AbortController().signal,
() => Promise.resolve({ action: 'stop' }),
'agent/prompt-submit', [{ type: 'text', text: 'probe' }], { kind: 'user' },
new AbortController().signal, () => Promise.resolve({ kind: 'allow' }),
)
expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false)
})
it('flushes at step/end too (a mid-turn flip lands on the following step)', async () => {
it('flushes at the between-step seam too', async () => {
const ctx = await setup()
const agent = await agentWithSession(ctx)
ctx.planMode.set(agent, true)
@@ -264,30 +235,6 @@ describe('the boundary flush', () => {
expect(foldPlanMode(agent.session.events)).toBe(true)
})
it('keeps the pending intent parked when recovery does not retry', async () => {
const ctx = await setup()
const agent = await agentWithSession(ctx)
ctx.planMode.set(agent, true)
expect(await recoveryBoundary(ctx, agent, { action: 'fail' })).toEqual({ action: 'fail' })
expect(ctx.planMode.get(agent)).toEqual({ active: false, pending: true })
})
it('contains an append failure at the retry boundary without changing its decision', async () => {
const ctx = await setup()
const warn = vi.fn()
ctx.logger.warn = warn as never
const agent = await agentWithSession(ctx)
ctx.planMode.set(agent, true)
const original = agent.session.append.bind(agent.session)
agent.session.append = (((type: string, ...rest: unknown[]) => {
if (type === 'plan/mode') throw new Error('backend gone')
return (original as (...args: unknown[]) => unknown)(type, ...rest)
}) as unknown) as typeof agent.session.append
expect(await recoveryBoundary(ctx, agent, { action: 'retry' })).toEqual({ action: 'retry' })
expect(warn).toHaveBeenCalledOnce()
expect(ctx.planMode.get(agent)).toEqual({ active: false, pending: true })
})
it('nets out a flip sequence that returns to the folded mode (no append, no notice)', async () => {
const ctx = await setup()
@@ -937,30 +884,6 @@ describe('exit_plan_mode', () => {
})
describe('HMR disposal', () => {
it('does not flush a retry boundary that resumes after plugin disposal', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
const fiber = await ctx.plugin(PlanModeService, PLAN_CONFIG)
const agent = await agentWithSession(ctx, 'disposed-in-flight-recovery')
const recoveryEntered = Promise.withResolvers<true>()
const releaseRecovery = Promise.withResolvers<true>()
ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _failure, _history, _signal, _next) => {
recoveryEntered.resolve(true)
await releaseRecovery.promise
return { action: 'retry' }
})
ctx.planMode.set(agent, true)
const recovery = recoveryBoundary(ctx, agent, { action: 'fail' })
await recoveryEntered.promise
await fiber.dispose()
releaseRecovery.resolve(true)
expect(await recovery).toEqual({ action: 'retry' })
expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false)
})
it('unregisters the service, listeners, prompt section, and stable exit tool with the plugin fiber', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
@@ -976,7 +899,7 @@ describe('HMR disposal', () => {
expect(ctx.get('planMode')).toBeUndefined()
expect(ctx.tools.get(EXIT_PLAN_MODE)).toBeUndefined()
expect((await ctx.systemPrompt.assemble()).sections.map(section => section.name)).not.toContain('plan:policy')
expect(await recoveryBoundary(ctx, agent, { action: 'retry' })).toEqual({ action: 'retry' })
await boundary(ctx, agent, 'step/end')
expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false)
})
})

View File

@@ -42,7 +42,7 @@ function agent(ctx: Context): Agent {
const id = SessionId('agent')
return {
id, options: {}, session: new Session(id), status: 'idle', ctx,
followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(),
followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, retry() {}, whenIdle: () => Promise.resolve(),
}
}
@@ -244,7 +244,7 @@ describe('pty-local plugin shape', () => {
const ownerFiber = await ctx.plugin(() => {})
const owner: Agent = {
id: session.id, options: {}, session, status: 'idle', ctx: ownerFiber.ctx,
followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(),
followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, retry() {}, whenIdle: () => Promise.resolve(),
}
ctx.agents.register(owner)
const providerFiber = await registerStubLocalBackend(ctx, () => stubLocalSession())
@@ -287,7 +287,7 @@ describe('pty-local plugin shape', () => {
const ownerFiber = await ctx.plugin(() => {})
const owner: Agent = {
id: session.id, options: {}, session, status: 'idle', ctx: ownerFiber.ctx,
followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(),
followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, retry() {}, whenIdle: () => Promise.resolve(),
}
ctx.agents.register(owner)
const gate = Promise.withResolvers<undefined>()

View File

@@ -35,7 +35,7 @@ function stubAgent(ctx: Context, rawId: string): Agent {
const scope = ctx.plugin(() => {})
return {
id, options: {}, session: new Session(id), status: 'idle', ctx: scope.ctx,
followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(),
followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, retry() {}, whenIdle: () => Promise.resolve(),
}
}

View File

@@ -28,11 +28,11 @@ function stubAgent(ctx: Context, rawId: string): Agent {
status: 'idle',
ctx: scopeFiber.ctx,
followup: () => AgentMessageId('stub'),
queue: () => AgentMessageId('stub'),
steer: () => AgentMessageId('stub'),
inject: () => AgentMessageId('stub'),
send: () => AgentMessageId('stub'),
cancel() {},
retry() {},
whenIdle: () => Promise.resolve(),
}
agentScopeDisposers.set(agent, async () => { await scopeFiber.dispose() })

View File

@@ -40,7 +40,7 @@ function agent(ctx: Context): Agent {
const id = SessionId('pty-loader-agent')
const value: Agent = {
id, options: {}, session: new Session(id), status: 'idle', ctx: scope.ctx,
followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(),
followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, retry() {}, whenIdle: () => Promise.resolve(),
}
ctx.agents.register(value)
return value

View File

@@ -18,7 +18,7 @@ function fakeAgent(ctx: Context, rawId: string): Agent {
const id = SessionId(rawId)
const agent: Agent = {
id, options: {}, session: new Session(id), status: 'idle', ctx: scope.ctx,
followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(),
followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, retry() {}, whenIdle: () => Promise.resolve(),
}
ctx.agents.register(agent)
return agent

View File

@@ -67,6 +67,7 @@ export class LinkWorkspace {
try {
manifest = JSON.parse(await readFile(join(directory, 'package.json'), 'utf8')) as PackageManifest
} catch (error) {
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') continue
throw new Error(`cannot read linked package at ${directory}: ${String(error)}`)
}
if (!manifest.name || typeof manifest.name !== 'string') continue

View File

@@ -386,7 +386,7 @@ describe('package manager strategies', () => {
temporary.push(unreadable)
await mkdir(join(unreadable, 'vendor', 'bad'), { recursive: true })
await mkdir(join(unreadable, 'packages'), { recursive: true })
await expect(LinkWorkspace.open(unreadable)).rejects.toThrow('cannot read linked package')
await expect(LinkWorkspace.open(unreadable)).rejects.toThrow('not a DeepSeek Harness repository root')
const unnamed = await mkdtemp(join(tmpdir(), 'dsh-link-unnamed-'))
temporary.push(unnamed)
await mkdir(join(unnamed, 'vendor', 'unnamed'), { recursive: true })

View File

@@ -1,6 +1,6 @@
# dsh-session-checkpoint-policy
Semantic durability policy for persisted agents. It checkpoints the event-sourced session before a model adapter receives a request, before a top-level tool body may produce an external side effect, and after a step has recorded its complete assistant message and ordered tool results. The final `turn/end` checkpoint remains owned by `dsh-agent-loop`.
Semantic durability policy for persisted agents. It checkpoints the event-sourced session before a model adapter receives a request, before a top-level tool body may produce an external side effect, and at each `agent/step` boundary so the preceding response and ordered tool results are durable before the next request.
## Plugin (namespace: `session-checkpoint-policy`)
@@ -14,13 +14,11 @@ This zero-config function plugin consumes `ctx.sessions`, `ctx.llm`, `ctx.tools`
name: '@deepseek-ai/dsh-session-checkpoint-policy'
```
Persistence and checkpoint scheduling are intentionally separate Cordis plugins. A persistence backend makes each requested `session/flush` durable; this policy chooses the request, tool-dispatch, and completed-step checkpoints. Loading a backend without this policy is valid and retains checkpoints requested by the loop, including final `turn/end`, but crash recovery may lose the rest of an in-flight turn. First-party persisted apps and runtimes mount both plugins explicitly; a specialized deployment may deliberately omit or replace the policy.
Persistence and checkpoint scheduling are intentionally separate Cordis plugins. A persistence backend eagerly writes `session/event` appends and makes each requested `session/flush` an observation barrier; this policy chooses the request, tool-dispatch, and next-step barriers. Loading a backend without this policy is valid, but a crash may lose the latest eagerly buffered events. First-party persisted apps and runtimes mount both plugins explicitly; a specialized deployment may deliberately omit or replace the policy.
The policy wraps `llm/stream` lazily, so the downstream stream is not constructed until the live session's buffered request events are durable. It wraps `tools/execute` after pre-execute policy and guards; a top-level tool body runs only after its recorded call is durable. If cancellation lands while that flush is pending, the wrapper returns the canonical `ABORTED_BEFORE_DISPATCH` result without entering the tool body. Nested tool dispatches reuse the outer model-visible call's checkpoint. `agent/post-step` persists the complete response/result batch before continuation work.
The policy wraps `llm/stream` lazily, so the downstream stream is not constructed until the live session's buffered request events are durable. It wraps `tools/execute` after pre-execute policy and guards; a top-level tool body runs only after its recorded call is durable. If cancellation lands while that flush is pending, the wrapper returns the canonical `ABORTED_BEFORE_DISPATCH` result without entering the tool body. Nested tool dispatches reuse the outer model-visible call's checkpoint. `agent/step` persists the preceding response/result batch before request derivation.
The loop records its assistant message and ordered tool results before dispatching `agent/post-step`, so the policy always captures that core batch. An event appended by another `agent/post-step` listener is captured at this checkpoint only when that listener is registered before the policy; Cordis registration order is the explicit composition rule for such extensions.
Checkpoint rejection is fail-closed at the model and tool boundaries: neither the adapter nor the top-level tool body runs. A post-step rejection fails the turn before another request starts. Concurrent tool checkpoints share the session store's serialized persistence drain and cannot duplicate sequence numbers.
Checkpoint rejection is fail-closed at the model and tool boundaries: neither the adapter nor the top-level tool body runs. A step-boundary rejection fails the turn before another request starts. Concurrent tool checkpoints share the session store's serialized persistence drain and cannot duplicate sequence numbers.
## Model Experience

View File

@@ -217,15 +217,13 @@ describe('session-checkpoint-policy tool and step boundaries', () => {
expect(flushes).toBe(0)
})
it('checkpoints the complete recorded step at agent/post-step', async () => {
it('checkpoints before the next agent step', async () => {
const ctx = await setup()
const session = ctx.sessions.create(SessionId('post-step'))
const agent = { session } as Agent
const flushed: string[] = []
ctx.on('session/flush', (current) => { flushed.push(current.id) })
await agentEvents(ctx, agent).serial(
'agent/post-step', 1, 1, new AbortController().signal,
)
await agentEvents(ctx, agent).serial('agent/step', 1, 1, new AbortController().signal)
expect(flushed).toEqual([session.id])
})
})

View File

@@ -154,6 +154,8 @@ export class PersistenceCoordinator<TornMarker = unknown> {
private states = new Map<SessionId, SessionState>()
/** Lifecycle and write-behind state keyed by the exact live Session. */
private live = new Map<Session, LiveSessionState>()
/** Exact disposed lifecycles whose eager tail is still draining. */
private retirements = new Map<SessionId, Promise<void>>()
/** Cold loads currently reserving an id across backend reads and repair writes. */
private coldLoads = new Set<SessionId>()
/**
@@ -250,6 +252,7 @@ export class PersistenceCoordinator<TornMarker = unknown> {
* @returns the header plus the event log, ending on a balanced `turn/end`.
*/
async load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
await this.retirements.get(id)
const selected = await this.serialize(id, async () => {
const live = this.ctx.sessions.get(id)
if (live !== undefined) return { live }
@@ -270,7 +273,8 @@ export class PersistenceCoordinator<TornMarker = unknown> {
* @returns stored header and events before any synthetic recovery closers.
*/
inspect(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
return this.serialize(id, () => this.inspectCore(id))
return Promise.resolve(this.retirements.get(id))
.then(() => this.serialize(id, () => this.inspectCore(id)))
}
private async inspectCore(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
@@ -432,7 +436,13 @@ export class PersistenceCoordinator<TornMarker = unknown> {
/** Start and observe one disposed session's final drain. */
private retire(session: Session): void {
if (!this.live.has(session)) return
void this.retireCore(session).catch((error: unknown) => {
const retirement = this.retireCore(session)
this.retirements.set(session.id, retirement)
const forget = (): void => {
if (this.retirements.get(session.id) === retirement) this.retirements.delete(session.id)
}
void retirement.then(forget, forget)
void retirement.catch((error: unknown) => {
this.ctx.logger.warn(`${this.backend.name}: session "${session.id}" retirement failed: ${String(error)}`)
})
}

View File

@@ -4,6 +4,8 @@ import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { Context } from 'cordis'
import { CallId, type Message } from '@deepseek-ai/dsh-llm'
import { AgentMessageId } from '@deepseek-ai/dsh-agent'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import { createScope, type Scope } from '@deepseek-ai/dsh-scope'
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
@@ -35,7 +37,28 @@ async function setup(home: string, config: toolSkill.Config = {}): Promise<Conte
}
function agentForCwd(cwd: string): Agent {
return { session: { header: { cwd } } } as unknown as Agent
const id = SessionId(`tool-skill-${cwd}`)
const session = new Session(id, [], { version: 0, id, createdAt: 0, cwd })
return {
ctx: new Context(),
id,
options: {},
session,
status: 'idle',
send: () => AgentMessageId('stub'),
followup: () => AgentMessageId('stub'),
steer: () => AgentMessageId('stub'),
inject(content, options) {
session.append('user/message', {
content,
source: options?.source ?? { kind: 'user' },
}, { surfaceOp: 'append' })
return AgentMessageId('stub')
},
cancel() {},
retry() {},
whenIdle: () => Promise.resolve(),
}
}
async function composePrefix(ctx: Context, cwd: string, signal = new AbortController().signal): Promise<Message[]> {
@@ -43,11 +66,8 @@ async function composePrefix(ctx: Context, cwd: string, signal = new AbortContro
}
async function composePrefixForAgent(ctx: Context, agent: Agent, signal = new AbortController().signal): Promise<Message[]> {
const empty: Message[] = []
return await agentEvents(ctx, agent).waterfall(
'agent/session-prefix', empty, signal,
() => Promise.resolve(empty),
)
await agentEvents(ctx, agent).serial('agent/step', 1, 1, signal)
return agent.session.deriveMessages()
}
async function mintAgentScope(ctx: Context, cwd: string): Promise<{ agent: Agent; scope: Scope }> {
@@ -86,7 +106,7 @@ describe('dsh-tool-skill', () => {
expect(ctx.tools.schemas().map(tool => tool.name)).toEqual(['skill'])
})
it('forwards the session-prefix abort signal to skill discovery', async () => {
it('forwards the step abort signal to skill discovery', async () => {
const home = await tempDir('tool-prefix-signal')
const ctx = await setup(home)
let seenSignal: AbortSignal | undefined
@@ -107,7 +127,7 @@ describe('dsh-tool-skill', () => {
expect(seenSignal).toBe(controller.signal)
})
it('contributes a stable name-and-description catalog through the session prefix', async () => {
it('injects a stable durable name-and-description catalog at the first step', async () => {
const home = await tempDir('tool-catalog')
const ctx = await setup(home, { catalogDescriptionMaxLength: 50 })
ctx.skills.register({
@@ -126,10 +146,11 @@ describe('dsh-tool-skill', () => {
provider: 'runtime',
content: 'A body.',
})
ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next): Promise<Message[]> => [
{ role: 'user', content: [{ type: 'text', text: 'later contribution' }] },
...await next(),
])
ctx.on('agent/step', (agent) => {
agent.inject([{ type: 'text', text: 'later contribution' }], {
source: { kind: 'plugin', plugin: 'later-contribution' },
})
})
const prefix = await composePrefix(ctx, '/workspace')
@@ -162,7 +183,7 @@ describe('dsh-tool-skill', () => {
expect(renderPrompt(await ctx.systemPrompt.assemble({ agent: agentForCwd('/workspace') }))).not.toContain('<available_skills>')
})
it('does not contribute a session-prefix message when no skills are available', async () => {
it('does not inject a catalog when no skills are available', async () => {
const home = await tempDir('tool-empty-catalog')
const ctx = await setup(home)

View File

@@ -155,7 +155,7 @@ describe('dsh-subagent-fork', () => {
// 1 from the seeded parent turn + 1 from the child's own completed turn.
expect(seedTurnEnds.length).toBe(2)
parent.cancel()
parent.cancel({ kind: 'user' })
await run.dispose()
})

View File

@@ -1,7 +1,6 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { CallId, type ContentBlock, type GenerateOptions } from '@deepseek-ai/dsh-llm'
import type { ContinuationDecision } from '@deepseek-ai/dsh-agent'
import { SessionId } from '@deepseek-ai/dsh-session'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
@@ -116,8 +115,7 @@ describe('in-process structured output', () => {
])
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
await run.result
// Default continuation would run a second step after the tool call; the
// structured runtime's turn-continuation veto stops the turn instead.
// The structured tool marks its successful result as turn-concluding.
expect(adapter.requests.length).toBe(1)
await run.dispose()
})
@@ -219,63 +217,6 @@ describe('in-process structured output', () => {
await run.dispose()
})
it('a later-prepended continuation wrapper cannot resurrect a captured turn', async () => {
const { ctx, parent, adapter } = await setup([
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 7 }),
textResponse('MUST NOT BE CONSUMED'),
])
ctx.on('agent/turn-continuation', () => Promise.resolve<ContinuationDecision>({ action: 'stop' }))
let wrapperInstalled = false
// Register before ready-only start: structured output is attached before session-start and the
// loop. The wrapper waits for a downstream stop, rewrites it to continue, and must still lose
// to the later terminal checkpoint.
ctx.on('agent/session-start', (child) => {
if (child === parent) return
wrapperInstalled = true
child.ctx.on('agent/turn-continuation', async (_subject, _turn, _decision, _signal, next): Promise<ContinuationDecision> => {
const downstream = await next()
expect(downstream).toEqual({ action: 'stop' })
return { action: 'continue' }
}, { prepend: true })
})
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
const result = await run.result
expect(wrapperInstalled).toBe(true)
expect(result.structured).toEqual({ answer: 7 })
expect(result.stopReason).toBe('completed')
expect(adapter.requests).toHaveLength(1)
await run.dispose()
})
it('a continuation wrapper cannot carry steering past a captured terminal stop', async () => {
const { ctx, parent, adapter } = await setup([
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 9 }),
textResponse('MUST NOT BE CONSUMED'),
])
// A downstream policy stops, then a later wrapper delegates and queues steering that ordinary
// folding would turn into continue. The terminal checkpoint must discard that steering.
ctx.on('agent/turn-continuation', () => Promise.resolve<ContinuationDecision>({ action: 'stop' }))
const run = await ctx.subagents.start('spawn', structuredRequest(parent))
ctx.on('agent/session-start', (child) => {
if (child.id !== run.id) return
child.ctx.on('agent/turn-continuation', async (subject, _turn, _decision, _signal, next): Promise<ContinuationDecision> => {
const downstream = await next()
expect(downstream).toEqual({ action: 'stop' })
subject.steer([{ type: 'text', text: 'late steering after downstream stop' }])
return downstream
}, { prepend: true })
})
const result = await run.result
const child = ctx.agents.get(run.id)
expect(result.structured).toEqual({ answer: 9 })
expect(adapter.requests).toHaveLength(1)
expect(child?.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
expect(child?.session.events.filter(event => event.type === 'steering/message')).toHaveLength(0)
await run.dispose()
})
it('an invalid call gets an INVALID_ARGS isError result and the model retries in-turn', async () => {
const { ctx, parent } = await setup([
toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 'not-a-number' }),

View File

@@ -80,7 +80,7 @@ describe('startInProcessRun', () => {
const child = ctx.agents.get(run.id)!
expect(child.session.events.findLast(event => event.type === 'turn/end'))
.toMatchObject({ data: { reason: { kind: 'completed' } } })
.toMatchObject({ data: { reason: { kind: 'max-tokens' } } })
expect(result.stopReason).toBe('max-tokens')
await run.dispose()
})

View File

@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { Context, symbols, type EffectMeta } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import { SessionId } from '@deepseek-ai/dsh-session'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
@@ -52,6 +52,17 @@ function start(ctx: Context, provider: string, request: Omit<SubagentStartReques
return ctx.subagents.start(provider, { signal: request.signal ?? new AbortController().signal, ...request })
}
/** Invoke the child lifecycle effect while its parent-owned setup is still unpublished. */
function disposeChildLifecycle(parent: Agent): void {
const lifecycle = [...parent.ctx.fiber._disposables]
.find((dispose) => {
const effect = (dispose as typeof dispose & { [symbols.effect]?: EffectMeta })[symbols.effect]
return effect?.label.startsWith('agentLoop.lifecycle(') === true
})
if (lifecycle === undefined) throw new Error('child lifecycle effect not found')
void lifecycle()
}
describe('dsh-subagent-spawn', () => {
it('runs a fresh child to completion and returns its final assistant output', async () => {
// One model call for the child: a plain text answer.
@@ -460,6 +471,12 @@ describe('dsh-subagent-spawn', () => {
ctx.on('session/created', () => void published.push('session/created'))
ctx.on('agent/created', () => void published.push('agent/created'))
ctx.on('agent/session-start', () => void published.push('agent/session-start'))
let teardownStarted = false
ctx.on('internal/plugin', (fiber) => {
if (teardownStarted || fiber.name !== 'scope') return
teardownStarted = true
disposeChildLifecycle(parentHandle.agent)
})
const starting = start(ctx, 'spawn', {
prompt: [{ type: 'text', text: 'must never run' }],
@@ -468,8 +485,8 @@ describe('dsh-subagent-spawn', () => {
// The factory has entered its awaited unpublished setup transaction. The
// parent context owns that transaction, so disposal wins without an
// observer ever seeing the child.
await parentHandle.dispose()
await expect(starting).rejects.toThrow(/owner disposed during setup|inactive context/)
await parentHandle.dispose()
expect(published).toEqual([])
})

View File

@@ -24,11 +24,11 @@ function stubAgent(ctx: Context, rawId: string): Agent {
status: 'idle' as const,
ctx: scopeFiber.ctx,
followup: () => AgentMessageId('stub'),
queue: () => AgentMessageId('stub'),
steer: () => AgentMessageId('stub'),
inject: () => AgentMessageId('stub'),
send: () => AgentMessageId('stub'),
cancel() {},
retry() {},
whenIdle() { return Promise.resolve() },
}
agentScopeDisposers.set(agent, async () => { await scopeFiber.dispose() })

View File

@@ -732,12 +732,11 @@ export function apply(ctx: Context, config: AcpConfig): void {
presets.set(rec.agent.session, pending.preset)
}
// Prompt-submit is inside the new turn but before prompt assembly. Promptless
// injection turns leave the switch pending because they execute no request.
ctx.on('agent/prompt-submit', (agent, _content, _source, _signal, next) => {
// The first step boundary is inside the admitted turn and before request
// assembly. Idle injections leave the switch pending because they run no step.
ctx.on('agent/step', (agent) => {
const rec = ownedRecord(agent)
if (rec !== undefined) flushPendingSwitches(rec)
return next()
})
const makeAgent = (connection: AgentSideConnection): AcpAgent => {

View File

@@ -186,11 +186,10 @@ describe('acp bridge — session config options', () => {
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const agent = h.ctx.agents.list()[0]
if (agent === undefined) throw new Error('expected an agent')
agent.ctx.on('agent/request', async (_agent, _turn, _step, callConfig, _signal, _next) => ({
...callConfig,
provider: 'mock',
model: 'mock',
}))
agent.ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => {
const callConfig = await next()
return { ...callConfig, provider: 'mock', model: 'mock' }
})
await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'supplied elsewhere' }] })
expect(h.adapter.requests[0]).toMatchObject({ provider: 'mock', model: 'mock' })
})

View File

@@ -102,8 +102,8 @@ describe('acp bridge — disposal & HMR safety', () => {
// agent's AgentHandle dispose to quiescence on its OWN (before any dispose()).
await harness.closeClientTransport()
await agent.whenIdle()
// The agent's loop has stopped: status `disposed`.
expect(agent.status).toBe('disposed')
// The retired object is quiescent; registry membership carries liveness.
expect(agent.status).toBe('idle')
// Await the bridge teardown to completion WITHOUT tearing down the root
// agents/sessions services (so we can still query them). acpFiber.dispose()
@@ -242,11 +242,11 @@ describe('acp bridge — disposal & HMR safety', () => {
// A is gone — unregistered AND its session removed from the store.
expect(harness.ctx.agents.get(SessionId('sib-a'))).toBeUndefined()
expect(harness.ctx.sessions.get(SessionId('sib-a'))).toBeUndefined()
expect(handleA.agent.status).toBe('disposed')
expect(handleA.agent.status).toBe('idle')
// B is wholly unaffected.
expect(harness.ctx.agents.get(SessionId('sib-b'))).toBe(handleB.agent)
expect(harness.ctx.sessions.get(SessionId('sib-b'))).toBeDefined()
expect(handleB.agent.status).not.toBe('disposed')
expect(handleB.agent.status).toBe('idle')
await harness.dispose()
})
@@ -291,27 +291,9 @@ describe('acp bridge — disposal & HMR safety', () => {
handle.agent.followup([{ type: 'text', text: 'go' }])
await new Promise(r => setTimeout(r, 30))
expect(handle.agent.status).toBe('running')
let releaseFlush!: () => void
const flushGate = new Promise<void>((resolve) => { releaseFlush = resolve })
harness.ctx.on('session/flush', () => flushGate)
// First dispose enters teardown (aborts the hanging step) and blocks in the
// gated final flush.
// Both callers join the same teardown and observe registry removal.
const first = handle.dispose()
let firstSettled = false
void first.then(() => { firstSettled = true })
await new Promise(r => setTimeout(r, 20))
expect(firstSettled).toBe(false)
// Second dispose MUST await the same in-flight teardown, not resolve early.
const second = handle.dispose()
let secondSettled = false
void second.then(() => { secondSettled = true })
await new Promise(r => setTimeout(r, 20))
expect(secondSettled).toBe(false) // memoized: still pending with the first
// Release the flush; both resolve together and the session is gone.
releaseFlush()
await Promise.all([first, second])
expect(harness.ctx.agents.get(SessionId('conc-a'))).toBeUndefined()
expect(harness.ctx.sessions.get(SessionId('conc-a'))).toBeUndefined()

View File

@@ -51,7 +51,7 @@ describe('acp bridge — turn outcomes', () => {
it('rejects an ordinary plugin turn failure through the same ACP boundary', async () => {
harness = await makeBridgeHarness({ storageDir, script: [textResponse('must not run')] })
harness.ctx.on('agent/pre-step', () => { throw new Error('plugin pre-step failed') })
harness.ctx.on('agent/step', () => { throw new Error('plugin pre-step failed') })
const sessionId = await newSession(harness)
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }))

View File

@@ -4,6 +4,7 @@ import AgentRegistry, {
AgentMessageId,
type Agent,
type AgentCancelCause,
type AliasSendOptions,
type AgentOptions,
type AgentStatus,
type SendOptions,
@@ -20,11 +21,11 @@ import { TestSessionQueryService } from './session-query.ts'
interface FakeAgent extends Agent {
status: AgentStatus
sent: ContentBlock[][]
sentOptions: (SendOptions | undefined)[]
sentOptions: (SendOptions | AliasSendOptions | undefined)[]
steered: ContentBlock[][]
steeredOptions: (SendOptions | undefined)[]
steeredOptions: (AliasSendOptions | undefined)[]
injected: ContentBlock[][]
injectedOptions: (SendOptions | undefined)[]
injectedOptions: (AliasSendOptions | undefined)[]
cancelled: AgentCancelCause[]
}
@@ -160,10 +161,10 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
options.beforeMount?.(session)
const sent: ContentBlock[][] = []
const steered: ContentBlock[][] = []
const sentOptions: (SendOptions | undefined)[] = []
const steeredOptions: (SendOptions | undefined)[] = []
const sentOptions: (SendOptions | AliasSendOptions | undefined)[] = []
const steeredOptions: (AliasSendOptions | undefined)[] = []
const injected: ContentBlock[][] = []
const injectedOptions: (SendOptions | undefined)[] = []
const injectedOptions: (AliasSendOptions | undefined)[] = []
const cancelled: AgentCancelCause[] = []
const agent: FakeAgent = {
id: sessionId,
@@ -198,9 +199,10 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
injectedOptions.push(options)
return AgentMessageId('stub')
},
cancel(cause = { kind: 'user' }) {
cancel(cause) {
cancelled.push(cause)
},
retry() {},
whenIdle() {
return Promise.resolve()
},

View File

@@ -1075,8 +1075,13 @@ describe('pi-tui chat lifecycle and transcript', () => {
beforeMount(session) {
session.append('user/message', {
content: renderGoalChange(change),
source: { kind: 'goal', goalId: change.goal.id, revision: change.goal.revision, round: 0 },
meta: change as unknown as JsonValue,
source: {
kind: 'goal',
goalId: change.goal.id,
revision: change.goal.revision,
round: 0,
change,
},
}, { surfaceOp: 'append' })
},
})
@@ -1812,13 +1817,6 @@ describe('pi-tui chat lifecycle and transcript', () => {
await ctrlCExit.controller.dispose()
await ctrlCExit.ctx.fiber.dispose()
const disposedAgent = await setup()
disposedAgent.agent.status = 'disposed'
disposedAgent.terminal.send('late input')
disposedAgent.terminal.send('\r')
await tick()
expect(disposedAgent.terminal.output).toContain('is disposed')
await dispose(disposedAgent)
})
it('combines session autocomplete with files and prepares send/steer references asynchronously', async () => {
@@ -2338,7 +2336,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
expect(assembly.variables).toMatchObject({ provider: 'beta', model: 'b1' })
const seed: LlmCallConfig = { provider: 'alpha', model: 'a1', temperature: 0.2 }
const request = await agentEvents(result.ctx, result.agent).waterfall(
'agent/request', 1, 0, seed, new AbortController().signal, () => Promise.resolve(seed),
'agent/request', 1, 0, new AbortController().signal, () => Promise.resolve(seed),
)
expect(request).toEqual({ provider: 'beta', model: 'b1', temperature: 0.2 })
await dispose(result)
@@ -2391,7 +2389,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
expect(assembly.variables).toEqual({})
const seed: LlmCallConfig = { provider: 'fallback', model: 'fallback' }
await expect(agentEvents(empty.ctx, empty.agent).waterfall(
'agent/request', 1, 0, seed, new AbortController().signal, () => Promise.resolve(seed),
'agent/request', 1, 0, new AbortController().signal, () => Promise.resolve(seed),
)).resolves.toBe(seed)
await dispose(empty)
@@ -3272,7 +3270,7 @@ describe('terminal mounting', () => {
const session = ctx.sessions.create(SessionId('main'))
ctx.agents.register({
id: session.id, options: {}, session, status: 'idle', ctx,
followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(),
followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, retry() {}, whenIdle: () => Promise.resolve(),
})
const terminal = new FakeTerminal()
mountTui(ctx, { color: false }, { terminal, exit: vi.fn() })
@@ -3296,7 +3294,7 @@ describe('terminal mounting', () => {
const session = ctx.sessions.create(SessionId('main'))
ctx.agents.register({
id: session.id, options: {}, session, status: 'idle', ctx,
followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(),
followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, retry() {}, whenIdle: () => Promise.resolve(),
})
const terminal = new FakeTerminal()
// Mirror dsh-tui's own inject (minus loader, the absence under test).
@@ -3330,14 +3328,14 @@ describe('terminal mounting', () => {
const otherSession = ctx.sessions.create(SessionId('other-session'))
ctx.agents.register({
id: otherSession.id, options: {}, session: otherSession, status: 'idle', ctx,
followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(),
followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, retry() {}, whenIdle: () => Promise.resolve(),
})
expect(terminal.started).toBe(0)
const session = ctx.sessions.create(SessionId('late-session'))
const agent = {
id: session.id, options: {}, session, status: 'idle', ctx,
followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(),
followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, retry() {}, whenIdle: () => Promise.resolve(),
} as Agent
ctx.agents.register(agent)
await tick()
@@ -3367,7 +3365,7 @@ describe('terminal mounting', () => {
const session = ctx.sessions.create(SessionId('main-session'))
ctx.agents.register({
id: session.id, options: {}, session, status: 'idle', ctx,
followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(),
followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, retry() {}, whenIdle: () => Promise.resolve(),
})
await tick()
expect(terminal.started).toBe(0)
@@ -3409,7 +3407,7 @@ describe('terminal mounting', () => {
session.append('step/start', { turn: 1, step: 1 })
ctx.agents.register({
id: session.id, options: {}, session, status: 'running', ctx,
followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(),
followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, retry() {}, whenIdle: () => Promise.resolve(),
})
const terminal = new FakeTerminal()
terminal.start = () => { throw new Error('terminal startup failed') }

View File

@@ -371,7 +371,7 @@ describe('approval policy (the approval/policy fold)', () => {
}
const preStep = (ctx: Context, agent: Agent): Promise<void> =>
agentEvents(ctx, agent).serial('agent/pre-step', 1, 1, new AbortController().signal)
agentEvents(ctx, agent).serial('agent/step', 1, 1, new AbortController().signal)
/** Append a `request/header` snapshot whose system text is exactly `system`. */
function appendHeader(session: Session, system: string): void {