Răsfoiți Sursa

Fix architecture-review findings in the loop and service packages

High (loop pipeline): agent/step-result now runs before the
assistant/message append so the session log records what tool dispatch
actually uses; abort is honored between tool calls, not just
mid-stream; steering drains at step start, pending steering overrides
a negative turn-continuation decision (/goal pattern), and leftover
steering is re-enqueued as queued messages so it is never stranded;
exceptions from turn-continuation listeners and session/flush are
contained to the turn (error event + agent/error) instead of killing
the driver loop.

Medium: disposal emits agent/status('disposed') and mid-turn disposal
records reason 'disposed'; duplicate LLM adapter registration throws
(all-or-nothing); SessionEvent is a real discriminated union (casts
removed); model-less agents fail with a clear actionable error unless
agent/request supplies a model.

Low: agent/queued and agent/steering carry the resolved MessageSource;
streamBlocks() yields strictly in stream order and flushes delta-only
blocks (matches generate()); BlockAssembler freezes blocks on
block-end and ignores stragglers from malformed streams; turn
numbering is a counter seeded from the log (fork-safe); LoopAgent's
stop disposer is infallible (a throwing status listener cannot skip
registry cleanup); AgentLoop.create uses a generator effect so stop
and unregister are independent disposables; SessionStore wires
onAppend inside its effect.

21 regression tests added (review-fixes.spec.ts), organized by
finding. Docs updated: loop pseudocode (status emissions, ordering,
error containment, steering guarantees) and waterfall composition
caveat in docs/architecture.md; AGENTS.md notes that excessive tests
are welcome.
Tianyi Cui 3 luni în urmă
părinte
comite
217b8ec0e2

+ 1 - 0
.gitignore

@@ -5,3 +5,4 @@ lib/
 .yarn/
 yarn-error.log
 examples/*/*.jsonl
+.claude/

+ 4 - 1
AGENTS.md

@@ -73,7 +73,10 @@ only needed for publishing/consumption outside the repo.
   docs/architecture.md). Changing `agent-loop` requires updating that doc.
 - **Tests**: vitest, colocated under `packages/<name>/tests/*.spec.ts`. Every
   registry needs an HMR-safety test (dispose the contributing fiber, assert
-  cleanup).
+  cleanup). **Excessive tests are welcome** — when in doubt, write the test;
+  err on the side of covering edge cases, error paths, event ordering, and
+  concurrency races even if they seem unlikely. Review findings get regression
+  tests (see `packages/agent-loop/tests/review-fixes.spec.ts`).
 
 ## Vendoring Policy
 

+ 23 - 8
docs/architecture.md

@@ -154,28 +154,40 @@ deferred.
 ```
 forever:
   wait for queued messages (idle)
-  TURN: drain queued → session('user/message'…) → 'turn/start' → emit agent/turn-start
+  emit agent/status(running)
+  TURN (error-contained — a throwing plugin ends the turn, never the loop):
+    drain queued → session('user/message'…) → 'turn/start' → emit agent/turn-start
     STEP loop:
+      drain steering (late steering from previous step's listeners)
       emit agent/step-start
       assembly = ctx.systemPrompt.assemble()          ⟵ waterfall system-prompt/assemble
       req = {model, system, tools, messages: session.deriveMessages(), signal}
       req = waterfall agent/request                   ⟵ hooks, compaction, model switch
       stream ctx.llm.stream(req)                      ⟵ waterfall llm/stream (raw chunks)
         session('assistant/chunk'); emit agent/stream-chunk
-      session('assistant/message', 'usage')
-      msg = waterfall agent/step-result               ⟵ post-process before tool dispatch
-      each tool-call (sequential):
+      msg = waterfall agent/step-result               ⟵ runs BEFORE the log append, so the
+      session('assistant/message', 'usage')              log records what tool dispatch uses
+      each tool-call (sequential, abort-checked between calls):
         session('tool/call'); ctx.tools.execute()     ⟵ waterfall tools/execute
         session('tool/result')
       drain steering → session('steering/message'); emit agent/steering
       emit agent/step-end
       cont = waterfall agent/turn-continuation(default = hadToolCalls || steered)
+      steering pending from step-end/continuation listeners forces cont = true
       if !cont: break
   session('turn/end'); emit agent/turn-end
-  await ctx.parallel('session/flush', session)        ⟵ durability checkpoint
-  idle (emit agent/status) unless more queued
+  await ctx.parallel('session/flush', session)        ⟵ durability checkpoint (failure
+                                                         reported via agent/error, not fatal)
+  leftover steering re-enqueued as queued messages    ⟵ steering is never stranded
+  emit agent/status(idle) unless more queued
 ```
 
+Error containment: a throwing `agent/turn-continuation` listener or a
+rejecting `session/flush` ends the **turn** with an `error` event — never the
+driver loop. `abort()` is honored mid-stream **and** between tool calls;
+disposal mid-turn ends the turn with reason `disposed` and emits
+`agent/status('disposed')`.
+
 ### Event taxonomy
 
 Declared in `@deepseek-ai/dsh-agent` (so nothing depends on the loop package).
@@ -206,8 +218,11 @@ receives `(...args, next)`:
 - return a value **without** calling `next()` to short-circuit (veto);
 - listeners run in registration order; `prepend: true` jumps the queue.
 
-Mutate the passed-in object (e.g. `options.model = '…'`) or return a
-replacement value — both compose.
+Composition caveat: values propagate through `next()`'s **return value**.
+Mutating the passed-in object works when later listeners receive the same
+reference, but a listener that returns a *new* object makes earlier mutations
+invisible downstream. Prefer mutate-then-`next()` for cooperative middleware;
+return a replacement only when you mean to take over the result.
 
 ## Plugin sanity checklist
 

+ 22 - 7
packages/agent-loop/src/agent.ts

@@ -1,7 +1,7 @@
 import type { Context } from 'cordis'
 import type { AgentOptions, AgentStatus, SendOptions } from '@deepseek-ai/dsh-agent'
 import type { Agent } from '@deepseek-ai/dsh-agent'
-import type { ContentBlock } from '@deepseek-ai/dsh-llm'
+import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
 import type { Session } from '@deepseek-ai/dsh-session'
 import { Inbox } from './inbox.ts'
 import { runLoop } from './loop.ts'
@@ -44,25 +44,28 @@ export class LoopAgent implements Agent {
     this.ctx.emit('agent/status', this, status)
   }
 
+  private resolveSource(options?: SendOptions): MessageSource {
+    return options?.source ?? { kind: 'user' }
+  }
+
   send(content: ContentBlock[], options?: SendOptions): void {
     if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`)
-    const source = options?.source ?? { kind: 'user' as const }
+    const source = this.resolveSource(options)
     this.inbox.enqueue({ content, source })
-    this.ctx.emit('agent/queued', this, content, { ...options, steering: false })
+    this.ctx.emit('agent/queued', this, content, { source, steering: false })
   }
 
   steer(content: ContentBlock[], options?: SendOptions): void {
     if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`)
     if (this._status !== 'running') return this.send(content, options)
-    const source = options?.source ?? { kind: 'user' as const }
+    const source = this.resolveSource(options)
     this.inbox.steer({ content, source })
-    this.ctx.emit('agent/queued', this, content, { ...options, steering: true })
+    this.ctx.emit('agent/queued', this, content, { source, steering: true })
   }
 
   inject(content: ContentBlock[], options?: SendOptions): void {
     if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`)
-    const source = options?.source ?? { kind: 'user' as const }
-    this.session.append('context/message', { content, source })
+    this.session.append('context/message', { content, source: this.resolveSource(options) })
   }
 
   abort(reason?: string): void {
@@ -77,10 +80,22 @@ export class LoopAgent implements Agent {
       disposed: this.disposed,
       isDisposed: () => this._status === 'disposed',
     })
+    // The disposer must be infallible: it runs inside the fiber's LIFO
+    // disposal chain, where a throw would skip later disposers (e.g. the
+    // registry unregistration) and leave `done` pending forever.
     return () => {
+      if (this._status === 'disposed') return
       this._status = 'disposed'
       this.resolveDisposed()
       this.currentAbort?.abort('disposed')
+      // setStatus refuses transitions out of 'disposed', so emit directly —
+      // 'disposed' is part of the agent/status contract. Guarded: a throwing
+      // listener must not break the disposal chain.
+      try {
+        this.ctx.emit('agent/status', this, 'disposed')
+      } catch {
+        // listener error during disposal — nothing safe left to do with it
+      }
     }
   }
 }

+ 6 - 8
packages/agent-loop/src/index.ts

@@ -59,14 +59,12 @@ export class AgentLoop extends Service {
   create(id: string, options: AgentOptions = {}): LoopAgent {
     const session = this.ctx.sessions.create(`${id}-session`)
     const agent = new LoopAgent(this.ctx, id, options, session)
-    this.ctx.effect(() => {
-      const stop = agent.start()
-      const unregister = this.ctx.agents.register(agent)
-      return () => {
-        stop()
-        unregister()
-      }
-    }, 'agentLoop.create()')
+    // Generator effect: stop and unregister are independent disposables
+    // (LIFO), so a throwing stop() cannot leak the registry entry.
+    this.ctx.effect(function* (this: AgentLoop) {
+      yield this.ctx.agents.register(agent)
+      yield agent.start()
+    }.bind(this), 'agentLoop.create()')
     return agent
   }
 }

+ 147 - 70
packages/agent-loop/src/loop.ts

@@ -1,7 +1,7 @@
 import type { Context } from 'cordis'
 import type { GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
 import { BlockAssembler } from '@deepseek-ai/dsh-llm'
-import type { TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session'
+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'
 import type { LoopAgent } from './agent.ts'
@@ -20,108 +20,176 @@ export interface LoopHandle {
  * ```
  * forever:
  *   wait for queued messages (idle)
- *   TURN: drain queued → session('turn/start') → emit agent/turn-start
+ *   TURN (error-contained — a throwing plugin ends the turn, never the loop):
+ *     drain queued → session('user/message'…) → 'turn/start' → emit agent/turn-start
  *     STEP loop:
+ *       drain steering → session('steering/message')  ⟵ catches late steering
  *       emit agent/step-start
- *       assembly = ctx.systemPrompt.assemble()          ⟵ waterfall system-prompt/assemble
+ *       assembly = ctx.systemPrompt.assemble()        ⟵ waterfall system-prompt/assemble
  *       req = {model, system, tools, messages: session.deriveMessages(), signal}
- *       req = waterfall agent/request                   ⟵ hooks/compaction/model-switch
- *       stream ctx.llm.stream(req)                      ⟵ waterfall llm/stream (raw chunks)
- *         session('assistant/chunk'); emit agent/stream-chunk; assembler.push
- *       session('assistant/message','usage')
- *       msg = waterfall agent/step-result               ⟵ post-process before tool dispatch
- *       each tool-call (sequential):
- *         session('tool/call'); ctx.tools.execute()     ⟵ waterfall tools/execute
+ *       req = waterfall agent/request                 ⟵ hooks/compaction/model-switch
+ *       stream ctx.llm.stream(req)                    ⟵ waterfall llm/stream (raw chunks)
+ *         session('assistant/chunk'); emit agent/stream-chunk
+ *       msg = waterfall agent/step-result             ⟵ BEFORE the log append, so the
+ *       session('assistant/message','usage')             session records what actually ran
+ *       each tool-call in msg (sequential, abort-checked):
+ *         session('tool/call'); ctx.tools.execute()   ⟵ waterfall tools/execute
  *         session('tool/result')
  *       drain steering → session('steering/message'); emit agent/steering
  *       emit agent/step-end
  *       cont = waterfall agent/turn-continuation(default = hadToolCalls || steered)
+ *       if !cont && steering arrived from step-end/continuation listeners: cont = true
+ *       if !cont: break
  *     session('turn/end'); emit agent/turn-end
- *     await ctx.parallel('session/flush', session)      ⟵ durability checkpoint
+ *     await ctx.parallel('session/flush', session)    ⟵ durability checkpoint
+ *     re-enqueue leftover steering as queued          ⟵ steering is never stranded
+ *   idle (emit agent/status) unless more queued
  * ```
  */
 export async function runLoop(ctx: Context, agent: LoopAgent, handle: LoopHandle): Promise<void> {
   const { session } = agent
+  let turn = lastTurnNumber(session) // seeded/forked sessions continue numbering
 
   while (!handle.isDisposed()) {
     await agent.inbox.waitForQueued(handle.disposed)
     if (handle.isDisposed()) break
 
     handle.setStatus('running')
-    const turn = nextTurnNumber(session)
+    turn += 1
+    try {
+      await runTurn(ctx, agent, handle, turn)
+    } catch (error: any) {
+      // Backstop: a throwing emit listener (turn boundaries) or a broken
+      // finalizer must not kill the driver. Record what we can and move on.
+      try {
+        const err = error instanceof Error ? error : new Error(String(error))
+        session.append('error', { turn, step: 0, message: err.message, code: (err as any).code })
+        ctx.emit('agent/error', agent, turn, 0, err)
+      } catch { /* the error path itself is broken; nothing left to do */ }
+    }
 
-    // Drain queued messages into the session — they trigger this turn.
-    const queued = agent.inbox.drainQueued()
-    const trigger: TurnTrigger = { kind: 'message', source: queued[0]!.source }
-    for (const message of queued) {
-      session.append('user/message', { content: message.content, source: message.source })
+    // Steering that arrived too late to join this turn (turn-end listeners,
+    // flush) becomes a queued message — it must never be stranded.
+    for (const message of agent.inbox.drainSteering()) {
+      agent.inbox.enqueue(message)
     }
 
-    session.append('turn/start', { turn, trigger })
-    ctx.emit('agent/turn-start', agent, turn)
+    if (!agent.inbox.hasQueued) handle.setStatus('idle')
+  }
+}
 
-    let reason: TurnEndReason = { kind: 'completed' }
-    let step = 0
+async function runTurn(ctx: Context, agent: LoopAgent, handle: LoopHandle, turn: number): Promise<void> {
+  const { session } = agent
 
-    while (true) {
-      step += 1
-      ctx.emit('agent/step-start', agent, turn, step)
-      session.append('step/start', { turn, step })
+  // Drain queued messages into the session — they trigger this turn.
+  const queued = agent.inbox.drainQueued()
+  const trigger: TurnTrigger = { kind: 'message', source: queued[0]!.source }
+  for (const message of queued) {
+    session.append('user/message', { content: message.content, source: message.source })
+  }
 
-      const abort = new AbortController()
-      handle.setAbort(abort)
+  session.append('turn/start', { turn, trigger })
+  ctx.emit('agent/turn-start', agent, turn)
 
-      let stepOutcome: { hadToolCalls: boolean } | { error: Error }
-      try {
-        stepOutcome = await runStep(ctx, agent, turn, step, abort.signal)
-      } catch (error: any) {
-        stepOutcome = { error: error instanceof Error ? error : new Error(String(error)) }
-      } finally {
-        handle.setAbort(undefined)
-      }
+  let reason: TurnEndReason = { kind: 'completed' }
+  let step = 0
 
-      // Steering arrives between steps: drain before deciding continuation
-      // so the decision (and the next request) sees it.
-      const steered = agent.inbox.drainSteering()
-      for (const message of steered) {
-        session.append('steering/message', { turn, content: message.content, source: message.source })
-        ctx.emit('agent/steering', agent, turn, message.content)
-      }
+  while (true) {
+    step += 1
+
+    // Steering from the previous round's step-end/continuation listeners
+    // (or turn-start listeners on the first step) joins before the request.
+    drainSteering(ctx, agent, turn)
+
+    ctx.emit('agent/step-start', agent, turn, step)
+    session.append('step/start', { turn, step })
+
+    const abort = new AbortController()
+    handle.setAbort(abort)
 
+    let stepOutcome: { hadToolCalls: boolean } | { error: Error }
+    try {
+      stepOutcome = await runStep(ctx, agent, turn, step, abort.signal)
+    } catch (error: any) {
+      stepOutcome = { error: error instanceof Error ? error : new Error(String(error)) }
+    } finally {
+      handle.setAbort(undefined)
+    }
+
+    if ('error' in stepOutcome) {
+      // Steering that arrived during the failed step stays in the inbox —
+      // runLoop re-enqueues it as a queued message, so an abort-then-steer
+      // starts a fresh turn instead of being silently consumed.
       session.append('step/end', { turn, step })
       ctx.emit('agent/step-end', agent, turn, step)
-
-      if ('error' in stepOutcome) {
-        const { error } = stepOutcome
-        if (abort.signal.aborted || handle.isDisposed()) {
-          reason = { kind: 'aborted', reason: String(abort.signal.reason ?? 'aborted') }
-        } else {
-          session.append('error', { turn, step, message: error.message, code: (error as any).code })
-          ctx.emit('agent/error', agent, turn, step, error)
-          reason = { kind: 'error', message: error.message, code: (error as any).code }
-        }
-        break
+      const { error } = stepOutcome
+      if (handle.isDisposed()) {
+        reason = { kind: 'disposed' }
+      } else if (abort.signal.aborted) {
+        reason = { kind: 'aborted', reason: String(abort.signal.reason ?? 'aborted') }
+      } else {
+        session.append('error', { turn, step, message: error.message, code: (error as any).code })
+        ctx.emit('agent/error', agent, turn, step, error)
+        reason = { kind: 'error', message: error.message, code: (error as any).code }
       }
+      break
+    }
+
+    // Steering that arrived during streaming/tool execution.
+    const steered = drainSteering(ctx, agent, turn)
+
+    session.append('step/end', { turn, step })
+    ctx.emit('agent/step-end', agent, turn, step)
 
-      const defaultDecision = stepOutcome.hadToolCalls || steered.length > 0
-      const shouldContinue = await ctx.waterfall(
+    const defaultDecision = stepOutcome.hadToolCalls || steered
+    let shouldContinue: boolean
+    try {
+      shouldContinue = await ctx.waterfall(
         'agent/turn-continuation', agent, turn, defaultDecision,
         async () => defaultDecision,
       )
-      if (!shouldContinue || handle.isDisposed()) break
+    } catch (error: any) {
+      // A broken continuation plugin ends the turn, not the loop.
+      const err = error instanceof Error ? error : new Error(String(error))
+      session.append('error', { turn, step, message: err.message, code: (err as any).code })
+      ctx.emit('agent/error', agent, turn, step, err)
+      reason = { kind: 'error', message: err.message, code: (err as any).code }
+      break
     }
 
-    if (handle.isDisposed() && reason.kind === 'completed') {
-      reason = { kind: 'disposed' }
+    // Steering from step-end/continuation listeners (the /goal pattern)
+    // demands the model see it — it overrides a negative decision; the
+    // next iteration's drain records it.
+    if (!shouldContinue && agent.inbox.hasSteering) shouldContinue = true
+
+    if (!shouldContinue || handle.isDisposed()) {
+      if (handle.isDisposed()) reason = { kind: 'disposed' }
+      break
     }
-    session.append('turn/end', { turn, reason })
-    ctx.emit('agent/turn-end', agent, turn, reason)
+  }
+
+  session.append('turn/end', { turn, reason })
+  ctx.emit('agent/turn-end', agent, turn, reason)
 
-    // Durability checkpoint: persistence plugins drain write-behind buffers.
+  // Durability checkpoint: persistence plugins drain write-behind buffers.
+  // A failing persistence plugin is reported but doesn't kill the agent.
+  try {
     await ctx.parallel('session/flush', session)
+  } catch (error: any) {
+    const err = error instanceof Error ? error : new Error(String(error))
+    session.append('error', { turn, step, message: err.message, code: (err as any).code })
+    ctx.emit('agent/error', agent, turn, step, err)
+  }
+}
 
-    if (!agent.inbox.hasQueued) handle.setStatus('idle')
+/** Drain the steering queue into the session. Returns whether any arrived. */
+function drainSteering(ctx: Context, agent: LoopAgent, turn: number): boolean {
+  const messages = agent.inbox.drainSteering()
+  for (const message of messages) {
+    agent.session.append('steering/message', { turn, content: message.content, source: message.source })
+    ctx.emit('agent/steering', agent, turn, message.content, message.source)
   }
+  return messages.length > 0
 }
 
 /** One step: assemble request → stream model → record → execute tools. */
@@ -141,13 +209,16 @@ async function runStep(
     .join('\n\n')
 
   let request: GenerateOptions = {
-    model: options.model ?? 'default',
+    model: options.model ?? '',
     messages: session.deriveMessages(),
     system: system || undefined,
     tools: assembly.tools.length > 0 ? assembly.tools : undefined,
     signal,
   }
   request = await ctx.waterfall('agent/request', agent, turn, step, request, async () => request)
+  if (!request.model) {
+    throw new Error(`agent "${agent.id}" has no model: set AgentOptions.model or supply one via the agent/request waterfall`)
+  }
 
   // --- Model call (streaming-first; raw chunks are the replay record) ---
   const assembler = new BlockAssembler()
@@ -158,17 +229,23 @@ async function runStep(
     assembler.push(chunk)
   }
 
+  // The step-result waterfall runs BEFORE the session append so the log (the
+  // source of truth for derived history and replay) records the message that
+  // tool dispatch actually uses.
   let message: Message = assembler.message()
+  message = await ctx.waterfall('agent/step-result', agent, turn, step, message, async () => message)
+
   session.append('assistant/message', { turn, step, content: message.content })
   if (assembler.usage) {
     session.append('usage', { turn, step, usage: assembler.usage })
   }
 
-  message = await ctx.waterfall('agent/step-result', agent, turn, step, message, async () => message)
-
   // --- Tool execution (sequential; parallel execution is a TODO) ---
+  // 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')
   for (const call of toolCalls) {
+    if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted'))
     session.append('tool/call', { turn, step, callId: call.id, name: call.name, arguments: call.arguments })
     let parsedArguments: unknown
     try {
@@ -189,17 +266,17 @@ async function runStep(
       content: result.content,
       isError: result.isError,
     })
+    if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted'))
   }
 
   return { hadToolCalls: toolCalls.length > 0 }
 }
 
-function nextTurnNumber(session: LoopAgent['session']): number {
+/** The last turn number in a (possibly seeded) session log, or 0. */
+function lastTurnNumber(session: Session): number {
   for (let index = session.events.length - 1; index >= 0; index--) {
     const event = session.events[index]
-    if (event.type === 'turn/start') {
-      return (event.data as { turn: number }).turn + 1
-    }
+    if (event.type === 'turn/start') return event.data.turn
   }
-  return 1
+  return 0
 }

+ 547 - 0
packages/agent-loop/tests/review-fixes.spec.ts

@@ -0,0 +1,547 @@
+import { describe, expect, it } from 'vitest'
+import { Context } from 'cordis'
+import LlmService, { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm'
+import SessionStore, { Session, SessionEvent } from '@deepseek-ai/dsh-session'
+import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
+import ToolRegistry from '@deepseek-ai/dsh-tools'
+import AgentRegistry from '@deepseek-ai/dsh-agent'
+import AgentLoop, { LoopAgent } from '@deepseek-ai/dsh-agent-loop'
+import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
+
+/**
+ * Regression tests for the findings of the first architecture review
+ * (Codex + sub-agent, post phase-1). Each describe block names the finding.
+ */
+
+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: LoopAgent): Promise<void> {
+  return new Promise((resolve) => {
+    const dispose = ctx.on('agent/status', (subject, status) => {
+      if (subject === agent && status === 'idle') {
+        dispose()
+        resolve()
+      }
+    })
+  })
+}
+
+function send(agent: LoopAgent, text: string) {
+  agent.send([{ type: 'text', text }])
+}
+
+describe('HIGH: 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 adapter = new MockAdapter([textResponse('original'), textResponse('done')])
+    const ctx = await harness(adapter)
+    const executed: string[] = []
+    ctx.tools.register({
+      name: 'injected-tool',
+      description: '',
+      parameters: { type: 'object' },
+      async execute() {
+        executed.push('injected-tool')
+        return [{ type: 'text', text: 'ran' }]
+      },
+    })
+    const agent = ctx.agentLoop.create('a1', { 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, 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: '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')
+    // tool/call + tool/result correlate with the injected call id
+    const callEvent = agent.session.events.find(e => e.type === 'tool/call')!
+    expect((callEvent.data as any).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')
+  })
+})
+
+describe('HIGH: abort during tool execution ends the turn', () => {
+  it('abort() inside a tool prevents both remaining tools and the next model step', 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: 'c1', name: 'aborter', arguments: '{}' } },
+        { type: 'block-start', index: 1, blockType: 'tool-call' },
+        { type: 'block-end', index: 1, block: { type: 'tool-call', id: '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('a1', { model: 'mock' })
+    ctx.tools.register({
+      name: 'aborter',
+      description: '',
+      parameters: { type: 'object' },
+      async execute() {
+        executed.push('aborter')
+        agent.abort('user interrupt')
+        return [{ type: 'text', text: 'done' }]
+      },
+    })
+    ctx.tools.register({
+      name: 'second',
+      description: '',
+      parameters: { type: 'object' },
+      async execute() {
+        executed.push('second')
+        return [{ type: 'text', text: 'done' }]
+      },
+    })
+
+    const reasons: any[] = []
+    ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason))
+
+    send(agent, 'go')
+    await waitForIdle(ctx, agent)
+
+    expect(executed).toEqual(['aborter'])           // second tool never ran
+    expect(adapter.requests).toHaveLength(1)        // no follow-up model call
+    expect(reasons).toEqual([{ kind: 'aborted', reason: 'user interrupt' }])
+  })
+})
+
+describe('HIGH: steering from late extension points is never stranded', () => {
+  it('steer() from an agent/step-end listener reaches the next request (/goal pattern)', async () => {
+    const adapter = new MockAdapter([
+      toolCallResponse('c1', 'echo', { text: 'x' }),
+      textResponse('after steering'),
+    ])
+    const ctx = await harness(adapter)
+    ctx.tools.register({
+      name: 'echo',
+      description: '',
+      parameters: { type: 'object' },
+      async execute(args: any) {
+        return [{ type: 'text', text: String(args.text) }]
+      },
+    })
+    const agent = ctx.agentLoop.create('a1', { model: 'mock' })
+
+    let steeredOnce = false
+    ctx.on('agent/step-end', () => {
+      if (steeredOnce) return
+      steeredOnce = true
+      agent.steer([{ type: 'text', text: 'goal reminder from step-end' }])
+    })
+
+    send(agent, 'go')
+    await waitForIdle(ctx, agent)
+
+    expect(adapter.requests).toHaveLength(2)
+    expect(JSON.stringify(adapter.requests[1].messages)).toContain('goal reminder from step-end')
+  })
+
+  it('steer() from an agent/turn-continuation listener overrides a stop decision', async () => {
+    const adapter = new MockAdapter([
+      textResponse('no tools, would stop here'),
+      textResponse('continued because of steering'),
+    ])
+    const ctx = await harness(adapter)
+    const agent = ctx.agentLoop.create('a1', { model: 'mock' })
+
+    let steeredOnce = false
+    ctx.on('agent/turn-continuation', async (_agent, _turn, _decision, next) => {
+      if (!steeredOnce) {
+        steeredOnce = true
+        agent.steer([{ type: 'text', text: 'one more thing' }])
+      }
+      return next()
+    })
+
+    send(agent, 'go')
+    await waitForIdle(ctx, agent)
+
+    // the default decision was false (no tools), but steering forced step 2
+    expect(adapter.requests).toHaveLength(2)
+    expect(JSON.stringify(adapter.requests[1].messages)).toContain('one more thing')
+  })
+
+  it('steer() from an agent/turn-end listener becomes a queued message for the next turn', async () => {
+    const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')])
+    const ctx = await harness(adapter)
+    const agent = ctx.agentLoop.create('a1', { model: 'mock' })
+
+    let steeredOnce = false
+    ctx.on('agent/turn-end', () => {
+      if (steeredOnce) return
+      steeredOnce = true
+      agent.steer([{ type: 'text', text: 'too late for this turn' }])
+    })
+
+    const turns: number[] = []
+    ctx.on('agent/turn-start', (_agent, turn) => void turns.push(turn))
+
+    send(agent, 'go')
+    await waitForIdle(ctx, agent)
+    // the loop chains directly into turn 2 (status never returns to idle in
+    // between), so the first idle transition means both turns are complete
+
+    expect(turns).toEqual([1, 2])
+    expect(adapter.requests).toHaveLength(2)
+    expect(JSON.stringify(adapter.requests[1].messages)).toContain('too late for this turn')
+  })
+
+  it('steering queued during an aborted step is re-delivered, not silently consumed', async () => {
+    const adapter = new MockAdapter(['hang', textResponse('recovered')])
+    const ctx = await harness(adapter)
+    const agent = ctx.agentLoop.create('a1', { model: 'mock' })
+
+    send(agent, 'go')
+    await new Promise(r => setTimeout(r, 30))
+    agent.steer([{ type: 'text', text: 'redirect' }])
+    agent.abort('user interrupt')
+    await waitForIdle(ctx, agent)
+
+    // a new turn ran with the steering content delivered as a message
+    expect(adapter.requests).toHaveLength(2)
+    expect(JSON.stringify(adapter.requests[1].messages)).toContain('redirect')
+  })
+})
+
+describe('HIGH: plugin exceptions are contained', () => {
+  it('a throwing agent/turn-continuation 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('a1', { model: 'mock' })
+
+    let threwOnce = false
+    ctx.on('agent/turn-continuation', async (): Promise<boolean> => {
+      if (!threwOnce) {
+        threwOnce = true
+        throw new Error('broken continuation plugin')
+      }
+      return false
+    })
+
+    const errors: Error[] = []
+    ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
+
+    send(agent, 'first')
+    await waitForIdle(ctx, agent)
+    expect(errors.map(e => e.message)).toEqual(['broken continuation plugin'])
+
+    // the loop is still alive: a second send works normally
+    send(agent, 'second')
+    await waitForIdle(ctx, agent)
+    expect(adapter.requests).toHaveLength(2)
+    expect(agent.status).toBe('idle')
+  })
+
+  it('a rejecting session/flush listener is reported but does not kill the agent', async () => {
+    const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
+    const ctx = await harness(adapter)
+    const agent = ctx.agentLoop.create('a1', { model: 'mock' })
+
+    let rejectedOnce = false
+    ctx.on('session/flush', async () => {
+      if (!rejectedOnce) {
+        rejectedOnce = true
+        throw new Error('disk full')
+      }
+    })
+
+    const errors: Error[] = []
+    ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
+
+    send(agent, 'first')
+    await waitForIdle(ctx, agent)
+    expect(errors.map(e => e.message)).toEqual(['disk full'])
+
+    send(agent, 'second')
+    await waitForIdle(ctx, agent)
+    expect(adapter.requests).toHaveLength(2)
+  })
+})
+
+describe('MEDIUM: disposed status is part of the agent/status contract', () => {
+  it('disposing the fiber emits agent/status(disposed) and ends the turn with reason disposed', async () => {
+    const adapter = new MockAdapter(['hang'])
+    const ctx = await harness(adapter)
+
+    let agent!: LoopAgent
+    const fiber = await ctx.plugin(Object.assign((inner: Context) => {
+      agent = inner.agentLoop.create('scoped', { model: 'mock' })
+    }, { inject: ['agentLoop'] }))
+
+    const statuses: string[] = []
+    const reasons: any[] = []
+    ctx.on('agent/status', (_agent, status) => void statuses.push(status))
+    ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason))
+
+    send(agent, 'go')
+    await new Promise(r => setTimeout(r, 30))
+    await fiber.dispose()
+    await agent.done
+
+    expect(statuses).toEqual(['running', 'disposed'])
+    expect(reasons).toEqual([{ kind: 'disposed' }])
+  })
+
+  it('a throwing agent/status listener cannot break disposal or leak the registry entry', async () => {
+    const adapter = new MockAdapter(['hang'])
+    const ctx = await harness(adapter)
+
+    let agent!: LoopAgent
+    const fiber = await ctx.plugin(Object.assign((inner: Context) => {
+      agent = inner.agentLoop.create('scoped', { model: 'mock' })
+    }, { inject: ['agentLoop'] }))
+
+    ctx.on('agent/status', (_agent, status) => {
+      if (status === 'disposed') throw new Error('broken status listener')
+    })
+
+    send(agent, 'go')
+    await new Promise(r => setTimeout(r, 30))
+    await fiber.dispose()
+    await agent.done // must not hang
+
+    expect(agent.status).toBe('disposed')
+    expect(ctx.agents.get('scoped')).toBeUndefined() // unregistered despite the throw
+  })
+})
+
+describe('MEDIUM: misc registry and config fixes', () => {
+  it('duplicate adapter registration is rejected', async () => {
+    const ctx = new Context()
+    await ctx.plugin(LlmService)
+    const adapter = new MockAdapter([])
+    ctx.llm.registerAdapter(['m1'], adapter)
+    expect(() => ctx.llm.registerAdapter(['m1'], new MockAdapter([])))
+      .toThrow('already registered')
+    // the original registration survives the failed attempt
+    expect(ctx.llm.models()).toEqual(['m1'])
+  })
+
+  it('an agent without a model fails the step with a clear error (not NO_ADAPTER for "default")', async () => {
+    const adapter = new MockAdapter([textResponse('never')])
+    const ctx = await harness(adapter)
+    const agent = ctx.agentLoop.create('a1', {}) // no model
+
+    const errors: Error[] = []
+    ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
+
+    send(agent, 'go')
+    await waitForIdle(ctx, agent)
+    expect(errors).toHaveLength(1)
+    expect(errors[0].message).toContain('has no model')
+    expect(errors[0].message).toContain('agent/request')
+  })
+
+  it('the agent/request waterfall can supply the model for a model-less agent', async () => {
+    const adapter = new MockAdapter([textResponse('routed')])
+    const ctx = await harness(adapter)
+    const agent = ctx.agentLoop.create('a1', {}) // no model — router plugin decides
+
+    ctx.on('agent/request', async (_agent, _turn, _step, options, next) => {
+      options.model = 'mock'
+      return next()
+    })
+
+    send(agent, 'go')
+    await waitForIdle(ctx, agent)
+    expect(adapter.requests).toHaveLength(1)
+    expect(agent.session.deriveMessages().at(-1)?.content).toEqual([{ type: 'text', text: 'routed' }])
+  })
+
+  it('agent/queued carries the resolved source; agent/steering carries its source', async () => {
+    const adapter = new MockAdapter([toolCallResponse('c1', 'noop', {}), textResponse('done')])
+    const ctx = await harness(adapter)
+    const agent = ctx.agentLoop.create('a1', { model: 'mock' })
+    ctx.tools.register({
+      name: 'noop',
+      description: '',
+      parameters: { type: 'object' },
+      async execute() {
+        agent.steer([{ type: 'text', text: 's' }], { source: { kind: 'plugin', plugin: 'goal' } })
+        return []
+      },
+    })
+
+    const queuedSources: any[] = []
+    const steeringSources: any[] = []
+    ctx.on('agent/queued', (_agent, _content, info) => void queuedSources.push(info))
+    ctx.on('agent/steering', (_agent, _turn, _content, source) => void steeringSources.push(source))
+
+    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(steeringSources).toEqual([{ kind: 'plugin', plugin: 'goal' }])
+  })
+})
+
+describe('MEDIUM: turn numbering continues across seeded (forked) sessions', () => {
+  it('a forked agent continues turn numbers after the seed log', async () => {
+    const first = new MockAdapter([textResponse('turn one')])
+    const ctx = await harness(first)
+    const agent = ctx.agentLoop.create('a1', { model: 'mock' })
+    send(agent, 'first')
+    await waitForIdle(ctx, agent)
+
+    // fork: seed a second context's agent with the first session's log
+    const second = new MockAdapter([textResponse('turn two')])
+    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: [] })
+    ctx2.llm.registerAdapter(['mock'], second)
+
+    const seeded = ctx2.sessions.create('forked', [...agent.session.events])
+    const forked = new LoopAgent(ctx2, 'forked-agent', { model: 'mock' }, seeded)
+    ctx2.effect(() => forked.start())
+
+    const turns: number[] = []
+    ctx2.on('agent/turn-start', (_agent, turn) => void turns.push(turn))
+    forked.send([{ type: 'text', text: 'continue' }])
+    await new Promise<void>((resolve) => {
+      ctx2.on('agent/status', (subject, status) => {
+        if (subject === forked && status === 'idle') resolve()
+      })
+    })
+
+    expect(turns).toEqual([2])
+  })
+})
+
+describe('LOW: BlockAssembler and streamBlocks edge cases', () => {
+  it('ignores deltas arriving after block-end for the same index (malformed stream)', async () => {
+    const { BlockAssembler } = await import('@deepseek-ai/dsh-llm')
+    const assembler = new BlockAssembler()
+    assembler.push({ type: 'block-start', index: 0, blockType: 'text' })
+    assembler.push({ type: 'text-delta', index: 0, text: 'good' })
+    assembler.push({ type: 'block-end', index: 0, block: { type: 'text', text: 'good' } })
+    assembler.push({ type: 'text-delta', index: 0, text: ' straggler' })
+    expect(assembler.blocks()).toEqual([{ type: 'text', text: 'good' }])
+  })
+
+  it('assembles tool-call blocks from deltas without block-end', async () => {
+    const { BlockAssembler } = await import('@deepseek-ai/dsh-llm')
+    const assembler = new BlockAssembler()
+    assembler.push({ type: 'tool-call-delta', index: 0, id: 'c9', name: 'echo', argumentsDelta: '{"a"' })
+    assembler.push({ type: 'tool-call-delta', index: 0, id: 'c9', argumentsDelta: ':1}' })
+    expect(assembler.blocks()).toEqual([
+      { type: 'tool-call', id: 'c9', name: 'echo', arguments: '{"a":1}' },
+    ])
+  })
+
+  it('streamBlocks flushes delta-only blocks at end of stream (matches generate())', async () => {
+    const ctx = new Context()
+    await ctx.plugin(LlmService)
+    const deltaOnly: StreamChunk[] = [
+      { type: 'text-delta', index: 0, text: 'no ' },
+      { type: 'text-delta', index: 0, text: 'block-end' },
+      { type: 'finish', reason: { kind: 'stop' } },
+    ]
+    ctx.llm.registerAdapter(['m'], new MockAdapter([deltaOnly, deltaOnly]))
+
+    const blocks: ContentBlock[] = []
+    for await (const block of ctx.llm.streamBlocks({ model: 'm', messages: [] })) blocks.push(block)
+    expect(blocks).toEqual([{ type: 'text', text: 'no block-end' }])
+
+    const generated = await ctx.llm.generate({ model: 'm', messages: [] })
+    expect(generated.message.content).toEqual(blocks)
+  })
+
+  it('streamBlocks preserves stream order when an open block precedes a closed one', async () => {
+    const ctx = new Context()
+    await ctx.plugin(LlmService)
+    // index 0 never gets block-end (delta-only); index 1 closes mid-stream.
+    const interleaved: StreamChunk[] = [
+      { type: 'text-delta', index: 0, text: 'first, open' },
+      { type: 'block-start', index: 1, blockType: 'text' },
+      { type: 'text-delta', index: 1, text: 'second, closed' },
+      { type: 'block-end', index: 1, block: { type: 'text', text: 'second, closed' } },
+      { type: 'finish', reason: { kind: 'stop' } },
+    ]
+    ctx.llm.registerAdapter(['m'], new MockAdapter([interleaved, interleaved]))
+
+    const blocks: ContentBlock[] = []
+    for await (const block of ctx.llm.streamBlocks({ model: 'm', messages: [] })) blocks.push(block)
+    expect(blocks).toEqual([
+      { type: 'text', text: 'first, open' },
+      { type: 'text', text: 'second, closed' },
+    ])
+
+    // identical to generate()'s assembled order
+    const generated = await ctx.llm.generate({ model: 'm', messages: [] })
+    expect(generated.message.content).toEqual(blocks)
+  })
+
+  it('streamBlocks yields closed blocks incrementally once preceding blocks close', async () => {
+    const ctx = new Context()
+    await ctx.plugin(LlmService)
+    const script: StreamChunk[] = [
+      { type: 'block-start', index: 0, blockType: 'text' },
+      { type: 'text-delta', index: 0, text: 'a' },
+      { type: 'block-end', index: 0, block: { type: 'text', text: 'a' } },
+      { type: 'block-start', index: 1, blockType: 'text' },
+      { type: 'text-delta', index: 1, text: 'b' },
+      { type: 'block-end', index: 1, block: { type: 'text', text: 'b' } },
+      { type: 'finish', reason: { kind: 'stop' } },
+    ]
+    ctx.llm.registerAdapter(['m'], new MockAdapter([script]))
+
+    const blocks: ContentBlock[] = []
+    for await (const block of ctx.llm.streamBlocks({ model: 'm', messages: [] })) blocks.push(block)
+    expect(blocks).toEqual([{ type: 'text', text: 'a' }, { type: 'text', text: 'b' }])
+  })
+})
+
+describe('LOW: discriminated SessionEvent narrows without casts', () => {
+  it('narrows event.data from event.type', () => {
+    const session = new Session('s')
+    const appended: SessionEvent = session.append('tool/call', {
+      turn: 1, step: 1, callId: 'c1', name: 'echo', arguments: '{}',
+    })
+    // compile-time: this switch narrows; runtime: values flow through
+    switch (appended.type) {
+      case 'tool/call': {
+        expect(appended.data.callId).toBe('c1')
+        expect(appended.data.name).toBe('echo')
+        break
+      }
+      default: throw new Error('wrong narrow')
+    }
+  })
+})

+ 6 - 3
packages/agent/src/types.ts

@@ -69,8 +69,11 @@ declare module 'cordis' {
     'agent/disposed'(agent: Agent): void
     /** Agent status changed (idle/running/disposed). */
     'agent/status'(agent: Agent, status: AgentStatus): void
-    /** A message entered the agent's inbox (queued or steering). */
-    'agent/queued'(agent: Agent, content: ContentBlock[], options: SendOptions & { steering: boolean }): void
+    /**
+     * A message entered the agent's inbox (queued or steering). `source` is
+     * the resolved source (defaults applied), not the caller's raw options.
+     */
+    'agent/queued'(agent: Agent, content: ContentBlock[], info: { source: MessageSource; steering: boolean }): void
 
     // ---- turn/step boundaries (emit) ----
     'agent/turn-start'(agent: Agent, turn: number): void
@@ -100,7 +103,7 @@ declare module 'cordis' {
     /** A raw stream chunk arrived (token-level UI/log feed). */
     'agent/stream-chunk'(agent: Agent, turn: number, step: number, chunk: StreamChunk): void
     /** Steering content was injected into a running turn. */
-    'agent/steering'(agent: Agent, turn: number, content: ContentBlock[]): void
+    'agent/steering'(agent: Agent, turn: number, content: ContentBlock[], source: MessageSource): void
     /** A step or turn errored. */
     'agent/error'(agent: Agent, turn: number, step: number, error: Error): void
   }

+ 77 - 32
packages/llm/src/assembler.ts

@@ -1,5 +1,15 @@
 import type { ContentBlock, FinishReason, GenerateResult, Message, StreamChunk, TokenUsage } from './types.ts'
 
+interface PartialBlock {
+  blockType: string
+  text: string
+  toolCallId?: string
+  toolCallName?: string
+  toolCallArguments: string
+  /** Set by `block-end` — authoritative, and freezes the partial. */
+  block?: ContentBlock
+}
+
 /**
  * Incrementally assembles raw {@link StreamChunk}s into complete
  * {@link ContentBlock}s and a final assistant {@link Message}.
@@ -7,44 +17,45 @@ import type { ContentBlock, FinishReason, GenerateResult, Message, StreamChunk,
  * This is the single shared assembly implementation: the agent loop feeds it
  * while logging raw chunks for replay fidelity, and `LlmService.generate()` /
  * `streamBlocks()` use it to offer assembled views of the same stream.
+ *
+ * Tolerant of delta-only protocols (no block-start/end); deltas arriving for
+ * an index already closed by `block-end` are ignored (malformed stream) so a
+ * misbehaving adapter cannot grow memory or corrupt a completed block.
  */
 export class BlockAssembler {
-  private partials = new Map<number, {
-    blockType: string
-    text: string
-    toolCallId?: string
-    toolCallName?: string
-    toolCallArguments: string
-    block?: ContentBlock
-  }>()
-
+  private partials = new Map<number, PartialBlock>()
   private order: number[] = []
+  private flushed = 0
   private _usage: TokenUsage | undefined
   private _finish: FinishReason | undefined
 
   /**
    * Feed one chunk. Returns the completed block when the chunk closes one
-   * (either an explicit `block-end` or an implicit close), otherwise undefined.
+   * (an explicit `block-end`), otherwise undefined.
    */
   push(chunk: StreamChunk): ContentBlock | undefined {
     switch (chunk.type) {
       case 'block-start': {
-        if (!this.partials.has(chunk.index)) this.order.push(chunk.index)
-        this.partials.set(chunk.index, {
-          blockType: chunk.blockType,
-          text: '',
-          toolCallArguments: '',
-        })
+        if (!this.partials.has(chunk.index)) {
+          this.order.push(chunk.index)
+          this.partials.set(chunk.index, {
+            blockType: chunk.blockType,
+            text: '',
+            toolCallArguments: '',
+          })
+        }
         return
       }
       case 'text-delta':
       case 'reasoning-delta': {
         const partial = this.ensure(chunk.index, chunk.type === 'text-delta' ? 'text' : 'reasoning')
+        if (partial.block) return // closed by block-end; ignore stragglers
         partial.text += chunk.text
         return
       }
       case 'tool-call-delta': {
         const partial = this.ensure(chunk.index, 'tool-call')
+        if (partial.block) return // closed by block-end; ignore stragglers
         partial.toolCallId = chunk.id
         if (chunk.name) partial.toolCallName = chunk.name
         partial.toolCallArguments += chunk.argumentsDelta
@@ -66,7 +77,7 @@ export class BlockAssembler {
     }
   }
 
-  private ensure(index: number, blockType: string) {
+  private ensure(index: number, blockType: string): PartialBlock {
     let partial = this.partials.get(index)
     if (!partial) {
       partial = { blockType, text: '', toolCallArguments: '' }
@@ -76,23 +87,57 @@ export class BlockAssembler {
     return partial
   }
 
+  private assemble(partial: PartialBlock, index: number): ContentBlock {
+    if (partial.block) return partial.block
+    switch (partial.blockType) {
+      case 'text': return { type: 'text', text: partial.text }
+      case 'reasoning': return { type: 'reasoning', text: partial.text }
+      case 'tool-call': return {
+        type: 'tool-call',
+        id: partial.toolCallId ?? `call-${index}`,
+        name: partial.toolCallName ?? '',
+        arguments: partial.toolCallArguments,
+      }
+      default: throw new Error(`cannot assemble incomplete block of type "${partial.blockType}"`)
+    }
+  }
+
   /** Assemble all blocks seen so far, in stream order. */
   blocks(): ContentBlock[] {
-    return this.order.map((index) => {
-      const partial = this.partials.get(index)!
-      if (partial.block) return partial.block
-      switch (partial.blockType) {
-        case 'text': return { type: 'text', text: partial.text }
-        case 'reasoning': return { type: 'reasoning', text: partial.text }
-        case 'tool-call': return {
-          type: 'tool-call',
-          id: partial.toolCallId ?? `call-${index}`,
-          name: partial.toolCallName ?? '',
-          arguments: partial.toolCallArguments,
-        }
-        default: throw new Error(`cannot assemble incomplete block of type "${partial.blockType}"`)
-      }
-    })
+    return this.order.map(index => this.assemble(this.partials.get(index)!, index))
+  }
+
+  /**
+   * Streaming flush: returns (once) every block that is complete AND has no
+   * incomplete block before it in stream order. Call after each `push()`;
+   * blocks come out strictly in stream order, so a streaming consumer sees
+   * exactly the sequence `blocks()` would produce.
+   */
+  flushReady(): ContentBlock[] {
+    const ready: ContentBlock[] = []
+    while (this.flushed < this.order.length) {
+      const partial = this.partials.get(this.order[this.flushed])!
+      if (!partial.block) break
+      ready.push(partial.block)
+      this.flushed += 1
+    }
+    return ready
+  }
+
+  /**
+   * End-of-stream flush: returns (once) all not-yet-flushed blocks, in stream
+   * order, assembling still-open ones from their deltas (delta-only
+   * protocols). After this, `flushReady()` + `flushRemaining()` together have
+   * yielded exactly `blocks()`.
+   */
+  flushRemaining(): ContentBlock[] {
+    const remaining: ContentBlock[] = []
+    while (this.flushed < this.order.length) {
+      const index = this.order[this.flushed]
+      remaining.push(this.assemble(this.partials.get(index)!, index))
+      this.flushed += 1
+    }
+    return remaining
   }
 
   get usage(): TokenUsage | undefined {

+ 13 - 3
packages/llm/src/index.ts

@@ -56,6 +56,11 @@ export class LlmService extends Service {
   /** Register an adapter for the given model names. Disposed with the fiber. */
   registerAdapter(models: string[], adapter: LlmAdapter): () => void {
     return this.ctx.effect(() => {
+      for (const model of models) {
+        if (this.adapters.has(model)) {
+          throw new LlmError(`an adapter for model "${model}" is already registered`, 'DUPLICATE_ADAPTER')
+        }
+      }
       for (const model of models) this.adapters.set(model, adapter)
       this.ctx.emit('llm/adapter-change')
       return () => {
@@ -85,14 +90,19 @@ export class LlmService extends Service {
 
   /**
    * Stream one model call as completed content blocks — a convenience view
-   * for consumers that don't care about token-level deltas.
+   * for consumers that don't care about token-level deltas. Blocks are
+   * yielded strictly in stream order as soon as they (and everything before
+   * them) complete; blocks left open at end of stream (delta-only protocols)
+   * are assembled and flushed last, so the sequence always equals
+   * `generate()`'s `message.content`.
    */
   async * streamBlocks(options: GenerateOptions): AsyncIterable<ContentBlock> {
     const assembler = new BlockAssembler()
     for await (const chunk of this.stream(options)) {
-      const block = assembler.push(chunk)
-      if (block) yield block
+      assembler.push(chunk)
+      yield * assembler.flushReady()
     }
+    yield * assembler.flushRemaining()
   }
 
   /** One model call, fully assembled (drains the chunk stream). */

+ 9 - 11
packages/session/src/index.ts

@@ -66,9 +66,9 @@ export class Session {
 
   /** Append one event. Synchronous — the hot path never blocks on I/O. */
   append<T extends SessionEventType>(type: T, data: SessionEventMap[T]): SessionEvent<T> {
-    const event: SessionEvent<T> = { type, seq: this.log.length, time: Date.now(), data }
-    this.log.push(event as SessionEvent)
-    this.onAppend?.(event as SessionEvent)
+    const event = { type, seq: this.log.length, time: Date.now(), data } as SessionEvent<T>
+    this.log.push(event)
+    this.onAppend?.(event)
     return event
   }
 
@@ -87,17 +87,15 @@ export class Session {
     for (const event of this.log) {
       switch (event.type) {
         case 'user/message': {
-          const { content } = event.data as SessionEventMap['user/message']
-          messages.push({ role: 'user', content })
+          messages.push({ role: 'user', content: event.data.content })
           break
         }
         case 'assistant/message': {
-          const { content } = event.data as SessionEventMap['assistant/message']
-          messages.push({ role: 'assistant', content })
+          messages.push({ role: 'assistant', content: event.data.content })
           break
         }
         case 'tool/result': {
-          const { callId, content, isError } = event.data as SessionEventMap['tool/result']
+          const { callId, content, isError } = event.data
           messages.push({
             role: 'user',
             content: [{ type: 'tool-result', toolCallId: callId, content, isError }],
@@ -105,12 +103,12 @@ export class Session {
           break
         }
         case 'context/message': {
-          const { content, source } = event.data as SessionEventMap['context/message']
+          const { content, source } = event.data
           messages.push({ role: 'user', content: renderTagged('context', content, source) })
           break
         }
         case 'steering/message': {
-          const { content, source } = event.data as SessionEventMap['steering/message']
+          const { content, source } = event.data
           messages.push({ role: 'user', content: renderTagged('steering', content, source) })
           break
         }
@@ -139,8 +137,8 @@ export class SessionStore extends Service {
     id ??= `session-${++this.counter}`
     if (this.store.has(id)) throw new Error(`session "${id}" already exists`)
     const session = new Session(id, seed)
-    session.onAppend = (event) => this.ctx.emit('session/event', session, event)
     this.ctx.effect(() => {
+      session.onAppend = (event) => this.ctx.emit('session/event', session, event)
       this.store.set(id, session)
       this.ctx.emit('session/created', session)
       return () => {

+ 16 - 9
packages/session/src/types.ts

@@ -63,12 +63,19 @@ export interface SessionEventMap {
 
 export type SessionEventType = keyof SessionEventMap
 
-/** One immutable entry in the session log. */
-export interface SessionEvent<T extends SessionEventType = SessionEventType> {
-  type: T
-  /** Monotonic sequence number within the session. */
-  seq: number
-  /** Unix epoch milliseconds. */
-  time: number
-  data: SessionEventMap[T]
-}
+/**
+ * One immutable entry in the session log.
+ *
+ * A proper discriminated union over `type` (not independent `type`/`data`
+ * unions), so `switch (event.type)` narrows `event.data` without casts.
+ */
+export type SessionEvent<T extends SessionEventType = SessionEventType> = {
+  [K in SessionEventType]: {
+    type: K
+    /** Monotonic sequence number within the session. */
+    seq: number
+    /** Unix epoch milliseconds. */
+    time: number
+    data: SessionEventMap[K]
+  }
+}[T]