feat(events): interception seams — the typed-Decision surface for hooks
Reshape the agent's interception surface so every seam returns a small, typed
Decision union, and the set covers the hook points a CC/Codex bridge (and a
native plugin) needs. "Native hooks" are not a package — a native hook is just a
cordis plugin on these canonical events; the bridges (a later PR) only translate
an external protocol onto the same surface.
dsh-agent:
- NEW agent/session-start(agent, source) emit (once before turn 1; SessionStartSource
startup|resume|clear|compact) — a pure notification, seeds context via inject().
- NEW agent/prompt-submit waterfall → PromptDecision (allow, optionally rewriting the
prompt or attaching additionalContext, or block).
- RESHAPE agent/turn-continuation boolean → ContinuationDecision ({action:'stop'} |
{action:'continue', reason?}; a continue reason is recorded as next-step steering).
- New HookContext envelope (required source — inject() would mislabel a missing one).
dsh-tools: split the single tools/execute waterfall into tools/pre-execute
(PreToolDecision allow/deny/ask gate) and tools/post-execute (PostToolDecision
accept/block, optionally replacing content or attaching additionalContext). Core
dispatch sits between as plain code; the tool body keeps its inner try/catch so a
thrown tool still reaches post-execute as an isError. ToolExecutionResult gains
additionalContext (ferried to the loop's per-step buffer). Input rewrite is
deliberately NOT offered (a proposed RFC designs it consistently).
dsh-session: new `rejected` TurnEndReason — a turn whose whole prompt batch was
blocked by prompt-submit.
agent-loop firing points: session-start emitted at create (source threaded —
startup for create/fork, resume for resume()); prompt-submit per drained message
with the always-open-turn rule (a fully-blocked batch is a zero-step rejected
turn); the continuation reshape; post-tool additionalContext buffered and appended
after all tool/results (adjacency). ACP codec maps rejected→cancelled.
A worked native-plugin example (interception.spec.ts) proves all four seams compose
end-to-end through the real loop with NO hook/* events (those belong to the bridge
lib). All existing tools/execute + turn-continuation tests migrated. The
tool-subagent abort test now aborts after a microtask so it still exercises the
live onAbort bridge (execute() awaits pre-execute before the body runs).
RFCs: implemented/feature/2026-06-30-interception-seams.md (the reshape) +
proposed/feature/2026-06-30-pre-tool-input-rewrite.md (the deferred rewrite design).
This commit is contained in:
@@ -45,10 +45,14 @@ Agents listed in config are auto-created at startup.
|
||||
One invocation of `runLoop()` drives one agent for its whole lifetime:
|
||||
|
||||
```
|
||||
create agent → emit agent/session-start(source) ⟵ once, before turn 1
|
||||
forever:
|
||||
wait for queued messages (idle)
|
||||
TURN (error-contained):
|
||||
drain queued → 'turn/start' → session('user/message')
|
||||
'turn/start'
|
||||
each queued: waterfall agent/prompt-submit → allow (→ session('user/message'),
|
||||
inject additionalContext) | block (drop)
|
||||
if every prompt blocked: 'turn/end'(rejected), no step ⟵ zero-step turn
|
||||
STEP loop:
|
||||
drain steering
|
||||
assembly = systemPrompt.assemble()
|
||||
@@ -56,10 +60,14 @@ forever:
|
||||
stream llm.stream(request) → session('assistant/chunk')
|
||||
message = waterfall agent/step-result
|
||||
session('assistant/message')
|
||||
each tool-call: session('tool/call') → tools.execute() → session('tool/result')
|
||||
each tool-call: session('tool/call')
|
||||
→ tools.execute() [waterfall tools/pre-execute → dispatch → tools/post-execute]
|
||||
→ session('tool/result')
|
||||
append buffered post-execute additionalContext as session('context/message')(s)
|
||||
drain steering → session('steering/message')
|
||||
cont = waterfall agent/turn-continuation
|
||||
if !cont: break
|
||||
cont = waterfall agent/turn-continuation → ContinuationDecision
|
||||
({action:'continue', reason?} records reason as next-step steering)
|
||||
if action==stop (and no pending steering): break
|
||||
session('turn/end')
|
||||
await session/flush
|
||||
re-enqueue leftover steering as queued
|
||||
@@ -73,9 +81,9 @@ Cancellation: `agent.cancel()` is the single public stop primitive — it clears
|
||||
### What is NOT here
|
||||
|
||||
Everything that goes beyond "call the model, run the tools, repeat" belongs to plugins listening on the event taxonomy:
|
||||
- Hooks: `agent/request`, `agent/step-result`, `tools/execute`, `agent/turn-continuation`
|
||||
- Hooks: `agent/session-start`, `agent/prompt-submit`, `agent/request`, `agent/step-result`, `tools/pre-execute`, `tools/post-execute`, `agent/turn-continuation`
|
||||
- Compaction: `agent/request`
|
||||
- Sandbox, permission, plan mode: `tools/execute`
|
||||
- Sandbox, permission, plan mode: `tools/pre-execute` (deny/ask gate), `tools/post-execute`
|
||||
- Sub-agents: TODO seam on `AgentLoop.create()`
|
||||
- Persistence: `session/event` + `session/flush`
|
||||
- UI: `agent/stream-chunk` + `agent/*` events
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
import { Context, Service } from 'cordis'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import z from 'schemastery'
|
||||
import type { AgentFactory, AgentHandle, AgentId, AgentOptions, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent'
|
||||
import type { AgentFactory, AgentHandle, AgentId, AgentOptions, CreateAgentOptions, ResumeAgentOptions, SessionStartSource } from '@deepseek-ai/dsh-agent'
|
||||
import type {} from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
@@ -129,7 +129,7 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
// session + agent down as one ordered chain, capturing the loop's closing
|
||||
// flush). The whole effect is owned by THIS fiber; no AgentHandle is needed.
|
||||
const session = this.ctx.sessions.prepare(SessionId(`${id}-session-${randomUUID()}`), { meta: {} })
|
||||
const { agent } = this.start(id, options, session)
|
||||
const { agent } = this.start(id, options, session, 'startup')
|
||||
return agent
|
||||
}
|
||||
|
||||
@@ -152,7 +152,9 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
...options.seed !== undefined ? { seed: options.seed } : {},
|
||||
meta: options.meta ?? {},
|
||||
})
|
||||
return this.startOwned(options.agentId, options.agentOptions ?? {}, session)
|
||||
// A seeded (forked) create is still a fresh start, NOT a resume — `resume`
|
||||
// is reserved for reloading a PERSISTED session via resume()/resumeWith().
|
||||
return this.startOwned(options.agentId, options.agentOptions ?? {}, session, 'startup')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -224,7 +226,7 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
...meta.seedLength !== undefined ? { seedLength: meta.seedLength } : {},
|
||||
},
|
||||
})
|
||||
return this.startOwned(options.agentId, options.agentOptions ?? {}, session)
|
||||
return this.startOwned(options.agentId, options.agentOptions ?? {}, session, 'resume')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -261,14 +263,33 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
* so a throwing `session/created`/`agent/created` listener unwinds the
|
||||
* already-yielded disposers instead of leaking.
|
||||
*
|
||||
* `source` says why the session began ({@link SessionStartSource}); it is
|
||||
* emitted as `agent/session-start` once, AFTER the agent is registered (so a
|
||||
* listener can resolve the agent via `ctx.agents.get(id)` and `inject()` into
|
||||
* it) and BEFORE the loop starts its first turn. The emit is contained: a
|
||||
* throwing session-start listener must not abort agent construction — it is
|
||||
* logged, and the agent still starts. (Unlike a turn-boundary throw, there is
|
||||
* no open turn here to balance; the durable evidence of a session-start hook
|
||||
* is whatever it `inject()`ed.)
|
||||
*
|
||||
* Returns the agent plus the composite effect's disposer (`disposeAgent`).
|
||||
*/
|
||||
private start(id: AgentId, options: AgentOptions, session: Session): { agent: ReactLoopAgent; disposeAgent: () => Promise<void> } {
|
||||
private start(
|
||||
id: AgentId, options: AgentOptions, session: Session, source: SessionStartSource,
|
||||
): { agent: ReactLoopAgent; disposeAgent: () => Promise<void> } {
|
||||
const agent = new ReactLoopAgent(this.ctx, id, options, session)
|
||||
const dispose = this.ctx.effect(function* (this: AgentLoop) {
|
||||
yield this.ctx.sessions.enter(session)
|
||||
this.ctx.sessions.announce(session)
|
||||
yield this.ctx.agents.register(agent)
|
||||
// Fire AFTER register (a listener can ctx.agents.get(id) + inject()) and
|
||||
// BEFORE the loop's first turn. Contained: a throwing listener is logged,
|
||||
// never aborts construction (no open turn to balance here).
|
||||
try {
|
||||
this.ctx.emit('agent/session-start', agent, source)
|
||||
} catch (error: unknown) {
|
||||
this.ctx.logger.warn(`agent "${id}": agent/session-start listener threw: ${String(error)}`)
|
||||
}
|
||||
const stop = agent.start()
|
||||
// Disposed FIRST (LIFO): request loop stop (sync), then AWAIT the loop's
|
||||
// actual exit so its closing flush lands while onAppend (yielded above,
|
||||
@@ -295,8 +316,8 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
* `AgentHandle.dispose(): Promise<void>` contract (mirrors the ACP `quiesce()`
|
||||
* helper).
|
||||
*/
|
||||
private startOwned(id: AgentId, options: AgentOptions, session: Session): AgentHandle {
|
||||
const { agent, disposeAgent } = this.start(id, options, session)
|
||||
private startOwned(id: AgentId, options: AgentOptions, session: Session, source: SessionStartSource): AgentHandle {
|
||||
const { agent, disposeAgent } = this.start(id, options, session, source)
|
||||
let disposing: Promise<void> | undefined
|
||||
return { agent, dispose: () => (disposing ??= disposeAgent()) }
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import type { Context } from 'cordis'
|
||||
import type { FinishReason, GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
|
||||
import { BlockAssembler, HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContinuationDecision, HookContext, PromptDecision } from '@deepseek-ai/dsh-agent'
|
||||
import type { Session, TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session'
|
||||
import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
@@ -367,15 +368,48 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle,
|
||||
// decides "owed" from the log via isTurnOpen, so even a throwing turn/start
|
||||
// listener — append pushes before notifying — still gets its turn/end).
|
||||
session.append('turn/start', { turn, trigger })
|
||||
// Record the queued user messages INSIDE the turn (after turn/start), so
|
||||
// every event in the log is turn-enclosed. turn/end is now owed, so a throw
|
||||
// while appending these is caught below and the turn is still closed.
|
||||
// Each drained queued message runs the `agent/prompt-submit` waterfall before
|
||||
// it becomes a `user/message` — a hook can rewrite the prompt or block it.
|
||||
// Recorded INSIDE the turn (after turn/start) so every event is turn-enclosed;
|
||||
// turn/end is now owed, so a throwing prompt-submit listener (the waterfall
|
||||
// throws) is caught below and the turn still closes.
|
||||
let anyAllowed = false
|
||||
// Seeded with a floor (only observable if the batch were empty, which
|
||||
// runTurn never allows — it is called with ≥1 queued message); each `block`
|
||||
// decision carries a required `reason` and overwrites it, so a fully-blocked
|
||||
// batch always reports the last vetoing reason.
|
||||
let lastBlockReason = 'prompt blocked by hook'
|
||||
for (const message of queued) {
|
||||
session.append('user/message', { content: message.content, source: message.source }, { surfaceOp: 'append' })
|
||||
const decision = await ctx.waterfall(
|
||||
'agent/prompt-submit', agent, message.content, message.source,
|
||||
() => Promise.resolve<PromptDecision>({ kind: 'allow' }),
|
||||
)
|
||||
if (decision.kind === 'block') {
|
||||
lastBlockReason = decision.reason
|
||||
continue
|
||||
}
|
||||
anyAllowed = true
|
||||
// `allow.content` REPLACES the prompt bytes (a rewrite); absent keeps them.
|
||||
const content = decision.content ?? message.content
|
||||
session.append('user/message', { content, source: message.source }, { surfaceOp: 'append' })
|
||||
// `allow.additionalContext` is a SEPARATE context/message the next request
|
||||
// also sees. The turn is open, so inject() appends it into THIS turn.
|
||||
if (decision.additionalContext) {
|
||||
agent.inject(decision.additionalContext.content, { source: decision.additionalContext.source })
|
||||
}
|
||||
}
|
||||
ctx.emit('agent/turn-start', agent, turn)
|
||||
|
||||
while (true) {
|
||||
// A fully-blocked batch (every prompt vetoed by prompt-submit) opens a
|
||||
// zero-step turn that ends `rejected`: break BEFORE the first step so the
|
||||
// boundary stays balanced (turn/start → turn/end) and the block is a
|
||||
// durable in-turn fact. `anyAllowed` never changes inside the loop, so this
|
||||
// only ever fires on the first iteration.
|
||||
if (!anyAllowed) {
|
||||
reason = { kind: 'rejected', reason: lastBlockReason }
|
||||
break
|
||||
}
|
||||
step += 1
|
||||
|
||||
// Steering from the previous round's continuation listeners (or
|
||||
@@ -448,10 +482,10 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle,
|
||||
|
||||
if (closeStep()) break
|
||||
|
||||
const defaultDecision = stepOutcome.hadToolCalls || steered
|
||||
let shouldContinue: boolean
|
||||
const defaultDecision: ContinuationDecision = { action: stepOutcome.hadToolCalls || steered ? 'continue' : 'stop' }
|
||||
let decision: ContinuationDecision
|
||||
try {
|
||||
shouldContinue = await ctx.waterfall(
|
||||
decision = await ctx.waterfall(
|
||||
'agent/turn-continuation', agent, turn, defaultDecision,
|
||||
() => Promise.resolve(defaultDecision),
|
||||
)
|
||||
@@ -461,9 +495,18 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle,
|
||||
break
|
||||
}
|
||||
|
||||
// A forced `continue` may carry model-facing context: record it as
|
||||
// next-STEP steering (the steering channel), so the continued turn's next
|
||||
// iteration drains it before its request — the typed twin of the /goal
|
||||
// step/end-steer pattern.
|
||||
if (decision.action === 'continue' && decision.reason) {
|
||||
agent.inbox.steer({ content: decision.reason.content, source: decision.reason.source })
|
||||
}
|
||||
let shouldContinue = decision.action === 'continue'
|
||||
|
||||
// Steering from step/end session-event or continuation listeners (the
|
||||
// /goal pattern) demands the model see it — it overrides a negative
|
||||
// decision; the next iteration's drain records it.
|
||||
// /goal pattern) demands the model see it — it overrides a stop decision;
|
||||
// the next iteration's drain records it.
|
||||
if (!shouldContinue && agent.inbox.hasSteering) shouldContinue = true
|
||||
|
||||
// A cancel that landed during the continuation window — after the step's
|
||||
@@ -645,6 +688,12 @@ async function runStep(
|
||||
// ToolRegistry.execute converts tool failures (including aborts) into
|
||||
// isError results, so abort is re-checked around every call here.
|
||||
const toolCalls = message.content.filter(block => block.type === 'tool-call')
|
||||
// Per-step buffer of `additionalContext` attached by tools/post-execute
|
||||
// listeners. Appended as context/message(s) only AFTER every tool/result for
|
||||
// the step, so a multi-call step keeps tool-call/result adjacency
|
||||
// (interleaving context between a call's result and the next call's would
|
||||
// break the pairing the next model request relies on).
|
||||
const pendingContext: HookContext[] = []
|
||||
for (const call of toolCalls) {
|
||||
/* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */
|
||||
if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted'))
|
||||
@@ -655,6 +704,12 @@ async function runStep(
|
||||
} catch {
|
||||
parsedArguments = call.arguments
|
||||
}
|
||||
// TODO(pre-tool-input-rewrite): tools/pre-execute deliberately cannot rewrite
|
||||
// `arguments` — tool/call (the audit record) and assistant/message (the
|
||||
// model-history source) are logged BEFORE execute, and live consumers (ACP,
|
||||
// tool-bash presentation) read the pre-execution args, so an execution-only
|
||||
// rewrite would desync the UI from what ran. Designing that consistently is
|
||||
// its own proposed RFC (docs/rfc/proposed/feature/…-pre-tool-input-rewrite.md).
|
||||
const result = await ctx.tools.execute({
|
||||
callId: call.id,
|
||||
name: call.name,
|
||||
@@ -666,7 +721,7 @@ async function runStep(
|
||||
turn, step,
|
||||
// The correlation id MUST be the loop's authoritative call.id (the
|
||||
// model-transcript id that deriveMessages turns into toolCallId), NOT
|
||||
// result.callId — a tools/execute waterfall listener returning a
|
||||
// result.callId — a post-execute waterfall listener returning a
|
||||
// mismatched id would otherwise orphan the call↔result pairing in the
|
||||
// next model request. A listener-internal id, if ever needed, belongs in
|
||||
// a separate diagnostic field, never overloaded onto callId.
|
||||
@@ -675,6 +730,8 @@ async function runStep(
|
||||
isError: result.isError,
|
||||
...result.error ? { error: result.error } : {},
|
||||
}, { surfaceOp: 'append', sourceEventSeqs: [callEvent.seq] })
|
||||
// Buffer (don't append yet) any post-execute additionalContext for this call.
|
||||
if (result.additionalContext) pendingContext.push(result.additionalContext)
|
||||
// signal CAN flip during the await above (abort() inside a tool);
|
||||
// the analyzer can't see through the await boundary.
|
||||
/* v8 ignore start -- signal.reason default unreachable: cancel()/disposal always set it */
|
||||
@@ -683,6 +740,13 @@ async function runStep(
|
||||
/* v8 ignore stop */
|
||||
}
|
||||
|
||||
// Append buffered post-execute context AFTER every tool/result, preserving
|
||||
// tool-call/result adjacency across the whole batch. inject() appends into the
|
||||
// open turn (a context/message at its chronological position).
|
||||
for (const context of pendingContext) {
|
||||
agent.inject(context.content, { source: context.source })
|
||||
}
|
||||
|
||||
return { hadToolCalls: toolCalls.length > 0, finish: assembler.finish }
|
||||
}
|
||||
|
||||
|
||||
@@ -213,7 +213,7 @@ describe('Agent.cancel()', () => {
|
||||
if (subject === agent && !continued) {
|
||||
continued = true
|
||||
agent.cancel('from continuation')
|
||||
return true // vote to continue — the post-waterfall marker check must override
|
||||
return { action: 'continue' as const } // vote to continue — the post-waterfall marker check must override
|
||||
}
|
||||
return next()
|
||||
})
|
||||
|
||||
446
packages/core/agent-loop/tests/interception.spec.ts
Normal file
446
packages/core/agent-loop/tests/interception.spec.ts
Normal file
@@ -0,0 +1,446 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService, { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineTool, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, {
|
||||
AgentId,
|
||||
type ContinuationDecision,
|
||||
type PromptDecision,
|
||||
type SessionStartSource,
|
||||
} from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop, { type ReactLoopAgent } 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`
|
||||
* split with `additionalContext` 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.
|
||||
*/
|
||||
|
||||
async function harness(adapter: MockAdapter) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
return ctx
|
||||
}
|
||||
|
||||
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === 'idle') {
|
||||
dispose()
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function send(agent: ReactLoopAgent, text: string) {
|
||||
agent.send([{ type: 'text', text }])
|
||||
}
|
||||
|
||||
function events(agent: ReactLoopAgent): SessionEvent[] {
|
||||
return [...agent.session.events]
|
||||
}
|
||||
|
||||
describe('agent/prompt-submit', () => {
|
||||
it('allow (default via next) records the user/message unchanged', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
const seen: string[] = []
|
||||
ctx.on('agent/prompt-submit', async (_agent, content, _source, next) => {
|
||||
seen.push(content.map(b => (b.type === 'text' ? b.text : '')).join(''))
|
||||
return next()
|
||||
})
|
||||
|
||||
send(agent, 'hello')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(seen).toEqual(['hello'])
|
||||
const userMsg = events(agent).find(e => e.type === 'user/message')
|
||||
expect(userMsg?.type === 'user/message' && userMsg.data.content).toEqual([{ type: 'text', text: 'hello' }])
|
||||
})
|
||||
|
||||
it('allow with content REWRITES the prompt before it is recorded', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
ctx.on('agent/prompt-submit', async (): Promise<PromptDecision> =>
|
||||
({ kind: 'allow', content: [{ type: 'text', text: 'REWRITTEN' }] }))
|
||||
|
||||
send(agent, 'original')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const userMsg = events(agent).find(e => e.type === 'user/message')
|
||||
expect(userMsg?.type === 'user/message' && userMsg.data.content).toEqual([{ type: 'text', text: 'REWRITTEN' }])
|
||||
// the rewritten prompt is what reached the model
|
||||
expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('REWRITTEN')
|
||||
expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('original')
|
||||
})
|
||||
|
||||
it('allow with additionalContext injects a separate context/message into the turn', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
ctx.on('agent/prompt-submit', async (): Promise<PromptDecision> =>
|
||||
({
|
||||
kind: 'allow',
|
||||
additionalContext: { content: [{ type: 'text', text: 'extra ctx' }], source: { kind: 'plugin', plugin: 'test' } },
|
||||
}))
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const log = events(agent)
|
||||
const userMsg = log.find(e => e.type === 'user/message')
|
||||
const ctxMsg = log.find(e => e.type === 'context/message')
|
||||
expect(userMsg).toBeDefined()
|
||||
expect(ctxMsg?.type === 'context/message' && ctxMsg.data.content).toEqual([{ type: 'text', text: 'extra ctx' }])
|
||||
expect(ctxMsg?.type === 'context/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'test' })
|
||||
// both the prompt and the injected context reach the model
|
||||
const sent = JSON.stringify(adapter.requests[0]!.messages)
|
||||
expect(sent).toContain('extra ctx')
|
||||
})
|
||||
|
||||
it('block drops the (only) prompt → zero-step turn ends rejected, model never called', async () => {
|
||||
const adapter = new MockAdapter([textResponse('should not run')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
ctx.on('agent/prompt-submit', async (): Promise<PromptDecision> =>
|
||||
({ kind: 'block', reason: 'blocked by policy' }))
|
||||
|
||||
const reasons: TurnEndReason[] = []
|
||||
ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason))
|
||||
|
||||
send(agent, 'do something')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// 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 === 'user/message')).toBe(false)
|
||||
expect(log.some(e => e.type === 'step/start')).toBe(false)
|
||||
// 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' })
|
||||
})
|
||||
|
||||
it('a throwing prompt-submit listener ends the turn balanced (error), loop survives', async () => {
|
||||
const adapter = new MockAdapter([textResponse('after')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
let threw = false
|
||||
ctx.on('agent/prompt-submit', async () => {
|
||||
if (!threw) { threw = true; throw new Error('prompt hook broke') }
|
||||
return { kind: 'allow' as const }
|
||||
})
|
||||
const errors: Error[] = []
|
||||
ctx.on('agent/error', (_a, _t, _s, error) => void errors.push(error))
|
||||
|
||||
send(agent, 'first')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(errors.map(e => e.message)).toEqual(['prompt hook broke'])
|
||||
// turn balanced
|
||||
const log = events(agent)
|
||||
expect(log.filter(e => e.type === 'turn/start')).toHaveLength(1)
|
||||
expect(log.filter(e => e.type === 'turn/end')).toHaveLength(1)
|
||||
|
||||
// loop survives: a second prompt runs normally
|
||||
send(agent, 'second')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(adapter.requests.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('agent/session-start', () => {
|
||||
it('fires once with source "startup" for a fresh create, before the first turn', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
|
||||
const sources: SessionStartSource[] = []
|
||||
ctx.on('agent/session-start', (_agent, source) => void sources.push(source))
|
||||
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
// fires synchronously at create, before any turn
|
||||
expect(sources).toEqual(['startup'])
|
||||
expect(events(agent).some(e => e.type === 'turn/start')).toBe(false)
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
// still only one session-start
|
||||
expect(sources).toEqual(['startup'])
|
||||
})
|
||||
|
||||
it('a session-start listener can inject context the first request sees', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
|
||||
ctx.on('agent/session-start', (agent) => {
|
||||
agent.inject([{ type: 'text', text: 'session preamble' }], { source: { kind: 'plugin', plugin: 'test' } })
|
||||
})
|
||||
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// the injected context reached the model on the first (only) request
|
||||
expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('session preamble')
|
||||
// and is recorded with the plugin source, never mislabeled as a user prompt
|
||||
const ctxMsg = events(agent).find(e => e.type === 'context/message')
|
||||
expect(ctxMsg?.type === 'context/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'test' })
|
||||
})
|
||||
|
||||
it('a throwing session-start listener does not abort agent construction', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
|
||||
ctx.on('agent/session-start', () => { throw new Error('session-start hook broke') })
|
||||
|
||||
// create must not throw — the listener error is contained/logged
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
expect(agent.id).toBe(AgentId('a1'))
|
||||
|
||||
// and the agent still runs
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(adapter.requests).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(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
let forced = false
|
||||
ctx.on('agent/turn-continuation', async (_agent, _turn, _default, 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)
|
||||
// same turn, two steps
|
||||
expect(log.filter(e => e.type === 'turn/start')).toHaveLength(1)
|
||||
expect(log.filter(e => e.type === 'step/start')).toHaveLength(2)
|
||||
// the reason was recorded as steering BEFORE step 2, with its plugin source
|
||||
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' })
|
||||
// and reached the next request
|
||||
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(defineTool({
|
||||
name: 'echo', description: 'echo', parameters: { text: { type: 'string' } },
|
||||
async execute(args) { return [{ type: 'text', text: String(args.text) }] },
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { 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('tools/post-execute additionalContext buffering across a multi-call step', () => {
|
||||
it('appends each call\'s additionalContext only AFTER all tool/results, preserving adjacency', async () => {
|
||||
// One assistant step with TWO tool calls; the second model response stops.
|
||||
const twoCalls = [
|
||||
{ type: 'block-start' as const, index: 0, blockType: 'tool-call' as const },
|
||||
{ type: 'block-end' as const, index: 0, block: { type: 'tool-call' as const, id: CallId('c1'), name: 'echo', arguments: '{"text":"a"}' } },
|
||||
{ type: 'block-start' as const, index: 1, blockType: 'tool-call' as const },
|
||||
{ type: 'block-end' as const, index: 1, block: { type: 'tool-call' as const, id: CallId('c2'), name: 'echo', arguments: '{"text":"b"}' } },
|
||||
{ type: 'usage' as const, usage: { inputTokens: 5, outputTokens: 5 } },
|
||||
{ type: 'finish' as const, reason: { kind: 'tool-calls' as const } },
|
||||
]
|
||||
const adapter = new MockAdapter([twoCalls, textResponse('done')])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'echo', description: 'echo', parameters: { text: { type: 'string' } },
|
||||
async execute(args) { return [{ type: 'text', text: String(args.text) }] },
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
// Each call attaches additionalContext naming itself.
|
||||
ctx.on('tools/post-execute', async (exec, _result): Promise<PostToolDecision> =>
|
||||
({ kind: 'accept', additionalContext: { content: [{ type: 'text', text: `ctx-${exec.callId}` }], source: { kind: 'plugin', plugin: 'p' } } }))
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// Event order in the log: both tool/results, THEN both context/messages —
|
||||
// never interleaved (which would break tool-call/result adjacency).
|
||||
const types = events(agent).map(e => e.type)
|
||||
const firstResult = types.indexOf('tool/result')
|
||||
const lastResult = types.lastIndexOf('tool/result')
|
||||
const firstCtx = types.indexOf('context/message')
|
||||
expect(firstResult).toBeGreaterThanOrEqual(0)
|
||||
expect(lastResult).toBeGreaterThan(firstResult) // two results
|
||||
expect(firstCtx).toBeGreaterThan(lastResult) // context only after ALL results
|
||||
// both contexts present
|
||||
const ctxTexts = events(agent)
|
||||
.filter(e => e.type === 'context/message')
|
||||
.flatMap(e => (e.type === 'context/message' ? e.data.content : []))
|
||||
.map(b => (b.type === 'text' ? b.text : ''))
|
||||
expect(ctxTexts).toEqual(['ctx-c1', 'ctx-c2'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('tools/pre-execute gate (native-plugin permission pattern, end-to-end through the loop)', () => {
|
||||
it('deny short-circuits dispatch into an isError result the model sees', async () => {
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'danger', {}), textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
let ran = false
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'danger', description: 'danger', parameters: {},
|
||||
async execute() { ran = true; return [{ type: 'text', text: 'should not run' }] },
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
|
||||
if (exec.name === 'danger') return { kind: 'deny', reason: 'blocked dangerous tool' }
|
||||
return next()
|
||||
})
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(ran).toBe(false)
|
||||
const result = events(agent).find(e => e.type === 'tool/result')
|
||||
expect(result?.type === 'tool/result' && result.data.isError).toBe(true)
|
||||
expect(result?.type === 'tool/result'
|
||||
&& result.data.content.some(b => b.type === 'text' && b.text.includes('blocked dangerous tool'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('worked example: a native hook plugin is just a cordis plugin on the seams', () => {
|
||||
// The whole point of the interception taxonomy: a "native hook" needs no
|
||||
// dsh-hook-protocol, no external command, no hook/* log — it is an ordinary
|
||||
// cordis plugin subscribing to the canonical events and returning typed
|
||||
// decisions. This proves all four seams compose end-to-end through the REAL
|
||||
// loop, with NO hook/* SessionEvents involved (those belong to the bridge lib).
|
||||
const NativeGuard = {
|
||||
name: 'native-guard',
|
||||
apply(ctx: Context) {
|
||||
// 1. SessionStart: seed a standing instruction.
|
||||
ctx.on('agent/session-start', (agent, source) => {
|
||||
agent.inject(
|
||||
[{ type: 'text', text: `policy active (started: ${source})` }],
|
||||
{ source: { kind: 'plugin', plugin: 'native-guard' } },
|
||||
)
|
||||
})
|
||||
// 2. PromptSubmit: block a forbidden prompt, annotate the rest.
|
||||
ctx.on('agent/prompt-submit', async (_agent, content, _source, next): Promise<PromptDecision> => {
|
||||
const text = content.map(b => (b.type === 'text' ? b.text : '')).join('')
|
||||
if (text.includes('rm -rf')) return { kind: 'block', reason: 'destructive prompt blocked' }
|
||||
return next()
|
||||
})
|
||||
// 3. PreToolUse: deny a dangerous tool by name.
|
||||
ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
|
||||
if (exec.name === 'danger') return { kind: 'deny', reason: 'danger tool denied' }
|
||||
return next()
|
||||
})
|
||||
// 4. PostToolUse: attach context after a tool runs.
|
||||
ctx.on('tools/post-execute', async (_exec, _result, next): Promise<PostToolDecision> => {
|
||||
const decision = await next()
|
||||
if (decision.kind === 'accept') {
|
||||
return { kind: 'accept', additionalContext: { content: [{ type: 'text', text: 'audited' }], source: { kind: 'plugin', plugin: 'native-guard' } } }
|
||||
}
|
||||
return decision
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
it('all four seams fire for a real allowed turn with a tool call', async () => {
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', { text: 'hi' }), textResponse('done')])
|
||||
const ctx = await harness(adapter)
|
||||
await ctx.plugin(NativeGuard)
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'echo', description: 'echo', parameters: { text: { type: 'string' } },
|
||||
async execute(args) { return [{ type: 'text', text: String(args.text) }] },
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
send(agent, 'please echo hi')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const log = events(agent)
|
||||
// session-start preamble injected
|
||||
expect(log.some(e => e.type === 'context/message'
|
||||
&& e.data.content.some(b => b.type === 'text' && b.text.includes('policy active (started: startup)')))).toBe(true)
|
||||
// prompt allowed → user/message recorded
|
||||
expect(log.some(e => e.type === 'user/message')).toBe(true)
|
||||
// tool ran (echo allowed) and post-execute attached "audited" context
|
||||
expect(log.some(e => e.type === 'tool/result' && !e.data.isError)).toBe(true)
|
||||
expect(log.some(e => e.type === 'context/message'
|
||||
&& e.data.content.some(b => b.type === 'text' && b.text === 'audited'))).toBe(true)
|
||||
// NO hook/* events — a native plugin needs none
|
||||
expect(log.some(e => e.type.startsWith('hook/'))).toBe(false)
|
||||
})
|
||||
|
||||
it('the same plugin blocks a destructive prompt → rejected turn, model never called', async () => {
|
||||
const adapter = new MockAdapter([textResponse('should not run')])
|
||||
const ctx = await harness(adapter)
|
||||
await ctx.plugin(NativeGuard)
|
||||
const agent = ctx.agentLoop.create(AgentId('a2'), { model: 'mock' })
|
||||
|
||||
const reasons: TurnEndReason[] = []
|
||||
ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason))
|
||||
|
||||
send(agent, 'run rm -rf /')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
expect(reasons).toEqual([{ kind: 'rejected', reason: 'destructive prompt blocked' }])
|
||||
})
|
||||
|
||||
it('HMR-safety: disposing the plugin fiber removes all four listeners', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const fiber = await ctx.plugin(NativeGuard)
|
||||
await fiber.dispose()
|
||||
|
||||
// After disposal, a destructive prompt is NOT blocked (the listener is gone).
|
||||
const agent = ctx.agentLoop.create(AgentId('a3'), { model: 'mock' })
|
||||
send(agent, 'run rm -rf /')
|
||||
await waitForIdle(ctx, agent)
|
||||
// the prompt ran (not rejected) — proving the prompt-submit listener was disposed
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(events(agent).some(e => e.type === 'user/message')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -277,7 +277,7 @@ 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, next) => {
|
||||
if (steps < 3) return true
|
||||
if (steps < 3) return { action: 'continue' as const }
|
||||
return next()
|
||||
})
|
||||
|
||||
@@ -300,7 +300,7 @@ describe('agent loop', () => {
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
ctx.on('agent/turn-continuation', async () => false as const)
|
||||
ctx.on('agent/turn-continuation', async () => ({ action: 'stop' }) as const)
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
@@ -381,7 +381,7 @@ describe('agent loop', () => {
|
||||
// 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, next) => {
|
||||
if (steps < 2) return true
|
||||
if (steps < 2) return { action: 'continue' as const }
|
||||
return next()
|
||||
})
|
||||
|
||||
|
||||
@@ -94,6 +94,36 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
await ctx2.fiber.dispose()
|
||||
})
|
||||
|
||||
it('agent/session-start fires "startup" for createAgent and "resume" for resume()', async () => {
|
||||
// Lifecycle 1: a fresh createAgent emits session-start with source 'startup'.
|
||||
const adapter1 = new MockAdapter([textResponse('a')])
|
||||
const { ctx: ctx1, root } = await persistentHarness(adapter1)
|
||||
const sources1: string[] = []
|
||||
ctx1.on('agent/session-start', (_agent, source) => void sources1.push(source))
|
||||
const a1 = ctx1.agents.create({ agentId: AgentId('s'), sessionId: SessionId('start-sess') }).agent as ReactLoopAgent
|
||||
expect(sources1).toEqual(['startup'])
|
||||
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
|
||||
await waitForIdle(ctx1, a1)
|
||||
await ctx1.fiber.dispose()
|
||||
|
||||
// Lifecycle 2: resuming the persisted session emits session-start 'resume'.
|
||||
const adapter2 = new MockAdapter([textResponse('b')])
|
||||
const ctx2 = new Context()
|
||||
await ctx2.plugin(LlmService)
|
||||
await ctx2.plugin(SessionStore)
|
||||
await ctx2.plugin(SystemPrompt)
|
||||
await ctx2.plugin(ToolRegistry)
|
||||
await ctx2.plugin(AgentRegistry)
|
||||
await ctx2.plugin(AgentLoop, { agents: [] })
|
||||
await ctx2.plugin(SessionPersistenceJsonl, { root })
|
||||
ctx2.llm.registerAdapter(['mock'], adapter2)
|
||||
const sources2: string[] = []
|
||||
ctx2.on('agent/session-start', (_agent, source) => void sources2.push(source))
|
||||
await ctx2.agents.resume({ agentId: AgentId('s'), resumeSessionId: SessionId('start-sess') })
|
||||
expect(sources2).toEqual(['resume'])
|
||||
await ctx2.fiber.dispose()
|
||||
})
|
||||
|
||||
it('resume of a forked session preserves the parentSession lineage and seed boundary in the header', async () => {
|
||||
// Lifecycle 1: persist a FORKED session (carries parentSession + seedLength
|
||||
// in its header) by creating it with a complete-turn seed — the write path
|
||||
|
||||
@@ -4,7 +4,7 @@ import LlmService, { CallId, MessageSource, StreamChunk } from '@deepseek-ai/dsh
|
||||
import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import AgentRegistry, { AgentId, type ContinuationDecision } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import * as Invariants from '@deepseek-ai/dsh-invariants'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
|
||||
@@ -271,12 +271,12 @@ describe('HIGH: plugin exceptions are contained', () => {
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
let threwOnce = false
|
||||
ctx.on('agent/turn-continuation', async (): Promise<boolean> => {
|
||||
ctx.on('agent/turn-continuation', async (): Promise<ContinuationDecision> => {
|
||||
if (!threwOnce) {
|
||||
threwOnce = true
|
||||
throw new Error('broken continuation plugin')
|
||||
}
|
||||
return false
|
||||
return { action: 'stop' }
|
||||
})
|
||||
|
||||
const errors: Error[] = []
|
||||
@@ -1005,7 +1005,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
|
||||
})
|
||||
|
||||
describe('P1-7: tool/result is logged under the originating call.id, not result.callId', () => {
|
||||
it('a tools/execute listener returning a mismatched callId cannot orphan the call↔result pairing', async () => {
|
||||
it('the loop records tool/result under the model call.id even when a post-execute listener replaces content', async () => {
|
||||
// Model emits a tool-call with id "c1", then a final text turn.
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('c1', 'echo', { x: 1 }),
|
||||
@@ -1019,12 +1019,13 @@ describe('P1-7: tool/result is logged under the originating call.id, not result.
|
||||
async execute() { return [{ type: 'text', text: 'ok' }] },
|
||||
}))
|
||||
|
||||
// A waterfall listener short-circuits with a result carrying the WRONG
|
||||
// callId (a listener-internal/proxy id). The loop must still record the
|
||||
// tool/result under the model's authoritative call.id.
|
||||
ctx.on('tools/execute', (exec) => {
|
||||
// A post-execute listener transforms the result (accept-with-replacement).
|
||||
// The loop must still record the tool/result under the model's authoritative
|
||||
// call.id (the loop ignores result.callId — which the registry always sets to
|
||||
// exec.callId anyway — and uses call.id, the model-transcript id).
|
||||
ctx.on('tools/post-execute', (exec, _result) => {
|
||||
expect(exec.callId).toBe(CallId('c1')) // the loop passed the real id in
|
||||
return Promise.resolve({ callId: CallId('wrong-proxy-id'), content: [{ type: 'text', text: 'ok' }], isError: false })
|
||||
return Promise.resolve({ kind: 'accept', content: [{ type: 'text', text: 'ok' }] })
|
||||
}, { prepend: true })
|
||||
|
||||
const agent = ctx.agentLoop.create(AgentId('a-callid'), { model: 'mock' })
|
||||
|
||||
Reference in New Issue
Block a user