瀏覽代碼

refactor(events): remove the agent/stream-chunk mirror of assistant/chunk

The loop recorded every model token delta as a durable `assistant/chunk`
session event AND emitted an identical live `agent/stream-chunk` Cordis event
one line later. Same StreamChunk, same turn/step; the emit added only the live
Agent handle, which the sole consumer discarded. This is the boundary-mirror
duplication the event-domain work removed for turn/step boundaries, applied to
the token stream — a follow-up the boundary RFC explicitly deferred.

The premise is settled: chunk persistence is authoritative (the proposal to
stop persisting chunks was rejected — replay/snapshots depend on it), so
`assistant/chunk` on `session/event` is the load-bearing token stream and
`agent/stream-chunk` is pure redundancy.

- Remove the `agent/stream-chunk` declaration + emit; drop the now-unused
  StreamChunk import from dsh-agent's types.
- Migrate `dsh-ui-stdio` (the only live consumer; ACP already reads
  assistant/chunk off session/event) to render assistant/chunk in its existing
  session/event listener. Consolidating to one listener also makes the
  inReasoning dim-SGR flag deterministic across chunk/boundary events (they no
  longer race across two listeners).
- Repoint the agent-loop tests (cancel/loop) and ui-stdio tests to the
  session/event assistant/chunk feed.
- New RFC (implemented/simplification/2026-07-02-remove-stream-chunk-mirror);
  amend the boundary RFC's retained-list entry to cross-link; update
  architecture, cookbook, event-domain-semantics, the ACP proposal, and the
  regenerated cordis catalog.

Snapshot goldens unchanged (ACP never used the mirror), confirming no
editor-facing transcript change.
Tianyi Cui 2 月之前
父節點
當前提交
b84d4828a8

+ 2 - 2
docs/architecture.md

@@ -147,7 +147,7 @@ forever:
       req = {model, system, tools, messages: session.deriveMessages(), signal}
       req = waterfall agent/request                   ⟵ hooks, model switch
       stream ctx.llm.stream(req)                      ⟵ waterfall llm/stream (raw chunks)
-        session('assistant/chunk'); emit agent/stream-chunk
+        session('assistant/chunk')
       if assembler.finish is error/aborted: throw      ⟵ adapter's in-band error path →
                                                          step error (turn ends error/aborted,
                                                          not a normal completed message)
@@ -221,7 +221,7 @@ Every MVP feature (including the TODO-marked ones), with the mechanism that impl
 | Skills | section + tool registration; `inject()` skill content on invocation |
 | Memory | section provider + tool |
 | Scheduled tasks (cron) | plugin registers model-callable scheduling tools; timer fires → `send(…, {source: {kind: 'cron', …}})` when idle / `inject()` notification when busy |
-| UI (GUI; CLI emits JSONL) | listen `agent/stream-chunk` + `session/event`; input → `send()` |
+| UI (GUI; CLI emits JSONL) | listen `session/event` (assistant chunks, boundaries, tool activity); input → `send()` |
 | Telemetry / replayable trace | `session/event` → JSONL; replay = `sessions.create(id, { seed })` |
 | DeepSeek V4 (and other) models | `LlmAdapter` subclass via `registerAdapter`. **Implemented twice**: `dsh-llm-deepseek` (hand-rolled) and `dsh-llm-pi-ai` (pi-ai-backed) |
 | Plugin hot-reload | every registration is a `ctx.effect` → vendored HMR just works |

+ 5 - 3
docs/cookbook/extension-cookbook.md

@@ -30,7 +30,7 @@ export function apply(ctx: Context) {
 
 ## A UI plugin
 
-A UI plugin consumes `agent/stream-chunk` and session events for rendering, and drives input back in via `agent.send()` / `agent.steer()`.
+A UI plugin renders from the `session/event` feed (the assistant token stream as `assistant/chunk`, plus turn/step boundaries and tool activity), and drives input back in via `agent.send()` / `agent.steer()`.
 
 ```ts
 import type { Context } from 'cordis'
@@ -43,8 +43,10 @@ export const name = 'my-ui'
 export const inject = ['agents']
 
 export function apply(ctx: Context) {
-  ctx.on('agent/stream-chunk', (agent, turn, step, chunk) => {
-    if (chunk.type === 'text-delta') render(chunk.text)
+  ctx.on('session/event', (_session, event) => {
+    if (event.type === 'assistant/chunk' && event.data.chunk.type === 'text-delta') {
+      render(event.data.chunk.text)
+    }
   })
   onUserInput(text => ctx.agents.get(AgentId('main'))?.send([{ type: 'text', text }]))
 }

+ 2 - 14
docs/cordis-catalog/events-and-services.md

@@ -49,7 +49,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w
 
 Types: [Agent](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:358`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:353`](../../packages/core/agent/src/types.ts)
 
 #### `agent/pre-step` — serial
 
@@ -135,7 +135,7 @@ Steering content was injected into a running turn.
 
 Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:352`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:347`](../../packages/core/agent/src/types.ts)
 
 #### `agent/step-result` — waterfall
 
@@ -149,18 +149,6 @@ Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-struct
 
 Source: [`packages/core/agent/src/types.ts:330`](../../packages/core/agent/src/types.ts)
 
-#### `agent/stream-chunk` — emit
-
-A raw StreamChunk arrived from the model (token-level UI/log feed).
-
-```ts cordis-catalog
-'agent/stream-chunk'(agent: Agent, turn: number, step: number, chunk: StreamChunk): void
-```
-
-Types: [Agent](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md)
-
-Source: [`packages/core/agent/src/types.ts:347`](../../packages/core/agent/src/types.ts)
-
 #### `agent/turn-continuation` — waterfall
 
 Waterfall: override the turn-continuation decision via a typed ContinuationDecision. The loop's `defaultDecision` is `continue` when the step had tool calls or steering was injected, else `stop`. Listeners force-continue (`/goal`, `/loop` — optionally attaching a `reason` recorded as next-step steering) or force-stop (budget guards). Call `next()` to delegate to the default, or return a decision to override.

+ 1 - 0
docs/rfc/README.md

@@ -102,6 +102,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r
 | [Keep one public stop primitive](implemented/simplification/2026-06-20-public-agent-stop-surface.md) | 2026-06-20 |
 | [Fold trace-only session facts into load-bearing events](implemented/simplification/2026-06-20-collapse-trace-only-session-events.md) | 2026-06-20 |
 | [Stop mirroring durable boundaries as agent events](implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md) | 2026-06-20 |
+| [Stop mirroring the token stream as an agent event](implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md) | 2026-07-02 |
 
 ### Architecture
 

+ 1 - 1
docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md

@@ -19,7 +19,7 @@ This is the foundational change in a stack that adds a Hooks subsystem; it estab
 **Three domains, one job each, with a single boundary rule.**
 
 - **`session/*` — the durable, replayable FACT log.** Owns `SessionEventMap`; every entry is JSON-only (no live objects). One `session/event` emit per append, plus the `session/flush` parallel durability checkpoint. It is also the live transcript feed: a consumer that wants to render or react to what happened subscribes here, so live rendering and `session/load` replay share one path.
-- **`agent/*` — the LIVE runtime surface.** Always carries the live `Agent`. Two shapes: INTERCEPTION waterfalls (`agent/request`, `agent/step-result`, `agent/turn-continuation`) that mutate or veto, and TRANSIENT emits (`agent/status`, `agent/stream-chunk`, `agent/error`, `agent/created`/`agent/disposed`, `agent/queued`, `agent/steering`) that notify with the `Agent` in hand. Turn and step BOUNDARIES are NOT here — they are durable session events read off `session/event`.
+- **`agent/*` — the LIVE runtime surface.** Always carries the live `Agent`. Two shapes: INTERCEPTION waterfalls (`agent/request`, `agent/step-result`, `agent/turn-continuation`) that mutate or veto, and TRANSIENT emits (`agent/status`, `agent/error`, `agent/created`/`agent/disposed`, `agent/queued`, `agent/steering`) that notify with the `Agent` in hand. Turn and step BOUNDARIES are NOT here — they are durable session events read off `session/event`, and so is the token stream (`assistant/chunk`).
 - **`tools/*` — the tool registry + execution seam.**
 
 **The boundary rule:** a durable, replayable fact is a `SessionEvent`; a live interception or a transient/live-object signal is an `agent`/`tools` Cordis event. A turn or step boundary is a durable fact, so it lives in the session log and is read off the `session/event` feed — it is NOT mirrored as an `agent/*` emit.

+ 7 - 5
docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md

@@ -3,10 +3,12 @@
 Status: implemented (accepted 2026-07-01)
 
 <!-- Shipped in AMENDED, narrowed form: the four turn/step BOUNDARY mirrors are
-     removed; `agent/steering` and `agent/stream-chunk` are RETAINED (they are
-     not durable-boundary mirrors — see "Scope: what is and isn't removed"). The
-     original proposal bundled `agent/steering` into the removal; validating
-     against the code showed it is a distinct live-only signal, so it stayed. -->
+     removed; `agent/steering` and `agent/stream-chunk` were RETAINED here (they
+     are not durable-boundary mirrors — see "Scope: what is and isn't removed").
+     The original proposal bundled `agent/steering` into the removal; validating
+     against the code showed it is a distinct live-only signal, so it stayed.
+     `agent/stream-chunk` was later removed by its own decision — see
+     [Stop mirroring the token stream as an agent event](2026-07-02-remove-stream-chunk-mirror.md). -->
 
 ## Problem
 
@@ -29,7 +31,7 @@ Removed (durable-boundary mirrors — the session log is authoritative for each)
 RETAINED — NOT durable-boundary mirrors, so out of scope for this decision:
 
 - `agent/steering` — a live control signal, not a boundary. (The original proposal bundled it into the removal; validating against the code, it is not a duplicate of a durable boundary, so removing it here would have been scope creep. Its fate is a separate future decision.)
-- `agent/stream-chunk` — the live token stream. `assistant/chunk` persistence remains load-bearing, so the chunk stream could later be evaluated as a mirror, but that is a separate decision.
+- `agent/stream-chunk` — the live token stream. Out of scope for THIS decision (a mirror of the durable `assistant/chunk`, not a boundary), it was removed by its own follow-up: [Stop mirroring the token stream as an agent event](2026-07-02-remove-stream-chunk-mirror.md).
 - `agent/created`, `agent/disposed`, `agent/status`, `agent/error`, `agent/queued` — lifecycle/control events that are not transcript data. `agent/queued` in particular is an inbox acknowledgement that fires before any durable event exists (cancelled queued work may never enter the log), so it is deliberately live-only.
 
 ## What we give up

+ 41 - 0
docs/rfc/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md

@@ -0,0 +1,41 @@
+# RFC: Stop mirroring the token stream as an agent event
+
+Status: implemented (accepted 2026-07-02)
+
+## Problem
+
+The loop records every model token delta as a durable `assistant/chunk` session event AND emitted a parallel live `agent/stream-chunk` Cordis event carrying the identical data. In `packages/core/agent-loop/src/loop.ts` the two sat one line apart:
+
+```ts ignore-check
+const chunkEvent = session.append('assistant/chunk', { turn, step, chunk })
+chunkSeqs.push(chunkEvent.seq)
+ctx.emit('agent/stream-chunk', agent, turn, step, chunk)   // ← the mirror
+```
+
+- Durable: `assistant/chunk: { turn, step, chunk }`.
+- Live emit: `agent/stream-chunk(agent, turn, step, chunk)` — same `StreamChunk`, same `turn`/`step`.
+
+The only thing the emit added over the session event was the live `Agent` handle, and the sole consumer discarded it (its handler signature was `(_agent, _turn, _step, chunk)`).
+
+This is the same duplication the [boundary-mirror removal](2026-06-20-remove-agent-boundary-mirror-events.md) eliminated for turn/step boundaries: a consumer had two sources of truth for one durable fact, and every change had to touch both. That RFC deferred the chunk stream ("`assistant/chunk` persistence remains load-bearing, so the chunk stream could later be evaluated as a mirror, but that is a separate decision") rather than bundling it in. This RFC is that separate decision.
+
+The premise the deferral hinged on is settled: chunk persistence is authoritative and staying. The proposal to stop persisting chunks and keep only a transient live stream event was [rejected](../../rejected/simplification/2026-06-20-assembled-assistant-messages-only.md) — high-fidelity replay, partial failed streams, and snapshot replay all depend on the persisted `assistant/chunk` feed. So `assistant/chunk` on `session/event` is the durable, load-bearing token stream, and `agent/stream-chunk` is a pure redundant mirror of it.
+
+## Decision
+
+Remove `agent/stream-chunk` from the agent event taxonomy. The token stream is read off `session/event` as `assistant/chunk`, the same feed persistence and replay already use — `session/event` is the single live transcript stream (assistant chunks, turn/step boundaries, tool activity, todos).
+
+**Consumers.** The only production consumer that mattered — the ACP bridge (`dsh-acp`), the real editor-facing streaming surface — already renders `assistant/chunk` off `session/event`, never `agent/stream-chunk`, so it is unaffected. The stdio UI (`dsh-ui-stdio`, a disposable test REPL) was the sole live consumer; it already had a `session/event` listener (from the boundary migration), so its chunk rendering folded into that listener as an `assistant/chunk` case. Consolidating to one listener also removed a latent hazard: the `inReasoning` dim-SGR flag was previously shared across two separate listeners (`agent/stream-chunk` and `session/event`), so a chunk and a boundary racing on it had no defined order; a single listener over the append order makes the interleaving deterministic.
+
+## Scope
+
+Removed: `agent/stream-chunk`.
+
+Not touched:
+- `assistant/chunk` (the durable session event) — the authoritative token stream, kept exactly as-is. This RFC removes the LIVE MIRROR, not the persistence (the persistence-removal proposal was separately rejected — see above).
+- `agent/steering` — a live control signal with no durable twin, retained (its fate remains a separate future decision, per the boundary RFC).
+- `agent/status`, `agent/error`, `agent/created`/`agent/disposed`, `agent/queued`, `agent/session-start` — lifecycle/control events that are not transcript data and have no durable duplicate.
+
+## What we give up
+
+A plugin can no longer observe token deltas from an `Agent`-first event. It subscribes to `session/event` and filters `assistant/chunk` (the `Agent` handle, if needed, is recovered from a session-id→agent map built from `agent/created`/`agent/disposed`, exactly as boundary consumers already do). No production consumer needed the live `Agent` at chunk time; this is the same acceptable trade the boundary-mirror removal made.

+ 4 - 4
docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md

@@ -7,7 +7,7 @@ Status: proposed
 
 ## Problem
 
-The coding agent is reachable only through the readline `stdio-chat` plugin: it reads lines from stdin, calls `agent.send()`, and prints `agent/stream-chunk` to stdout. There is no structured protocol, so the agent cannot be embedded in an editor — no streaming render, no tool-call display, no permission UI, no resumable sessions.
+The coding agent is reachable only through the readline `stdio-chat` plugin: it reads lines from stdin, calls `agent.send()`, and prints the assistant token stream (`session/event` `assistant/chunk`) to stdout. There is no structured protocol, so the agent cannot be embedded in an editor — no streaming render, no tool-call display, no permission UI, no resumable sessions.
 
 Editors are converging on the Agent Client Protocol (ACP), which Zed and others speak: JSON-RPC 2.0 over newline-delimited stdio, modeled on the Language Server Protocol. An editor boots the agent as a subprocess and exchanges `initialize` / `session/new` / `session/prompt`, rendering streamed `session/update` notifications and `session/request_permission` prompts. The goal is for the agent to be a drop-in ACP server — implement the protocol once and run in any ACP client, with no per-editor glue.
 
@@ -28,8 +28,8 @@ The mapping between ACP and existing harness seams — each row names the seam a
 | `session/load {sessionId, cwd, mcpServers, additionalDirectories}` | the `dsh-agent` resume factory ([session persistence](../../implemented/architecture/2026-06-14-session-persistence.md) + Dependency note) | load `{ meta, events }`, seed the session, re-derive history via `deriveMessages()`, replay prior turns to the client as `session/update` per the ACP load contract; `mcpServers` and `additionalDirectories` rejected as in `session/new` |
 | `session/prompt {prompt}` | `agent.send()` (idle) | text blocks → `TextBlock`; reject image/audio per advertised capabilities; one in-flight prompt per session |
 | resolve `session/prompt` → `{stopReason}` | the `turn/end` `session/event` (its `reason`) | map the harness kebab `TurnEndReason` to the ACP snake_case `StopReason` wire enum: `completed`→`end_turn`, `max-tokens`→`max_tokens`, `aborted`(cancel)→`cancelled`, plus `refusal`/`max_turn_requests` when applicable; honor the batch-into-one-turn and send-not-synchronously-running settle semantics |
-| `session/update: agent_message_chunk` | `agent/stream-chunk` `text-delta` only | do NOT also emit on `block-end(TextBlock)` — it carries the fully-assembled block and would duplicate the streamed text |
-| `session/update: agent_thought_chunk` | `agent/stream-chunk` `reasoning-delta` | |
+| `session/update: agent_message_chunk` | `session/event` `assistant/chunk` `text-delta` only | do NOT also emit on `block-end(TextBlock)` — it carries the fully-assembled block and would duplicate the streamed text |
+| `session/update: agent_thought_chunk` | `session/event` `assistant/chunk` `reasoning-delta` | |
 | `session/update: tool_call` (pending→in_progress) | `session/event` `tool/call` | demux via a Session→sessionId map; `kind` inferred from the tool name |
 | `session/update: tool_call_update` (completed/failed) | `session/event` `tool/result` | a throwing `tools/execute` yields NO `tool/result` → fail the pending tool UI from `agent/error`/turn-end |
 | `session/request_permission {sessionId, toolCall, options}` | prepended `tools/execute` listener | no-op unless `exec.agent` is ACP-owned; await the outcome; `selected/allow_*` → `next()`; `reject_*`/`cancelled` → veto `ToolExecutionResult{isError}` |
@@ -46,7 +46,7 @@ Lifecycle and disposal: the connection, listeners, and in-flight permission prom
 1. Package scaffold `packages/ui/acp/` per [the cookbook](../../../cookbook/adding-a-package.md); add `@agentclientprotocol/sdk` and `zod`. Add the abstract create/resume factory to `dsh-agent` (the interface) so the bridge can `inject: ['agents', 'sessions', 'tools', 'sessionPersistence']` without depending on the concrete loop; `sessionPersistence` is required because `session/load` advertises `loadSession: true`. (Fallback only if the factory is judged not worth it: inject `agentLoop` directly and record the architecture-rule exception in `docs/architecture.md`.)
 2. Connection plus `initialize`/`session/new`: wire `AgentSideConnection` to stdin/stdout; protocolVersion negotiation; the single-session guard; create the live session through the new `{ sessionId, meta }` factory seam (so the ACP `sessionId` and validated `cwd` become the session's id and header); the `sessionId↔agent` and `Session↔sessionId` maps.
 3. Internal edit — turn-end reason fidelity (sanctioned: edit internals to fit ACP). Extend `TurnEndReasonMap` in the proper places: (a) declaration-merge a `max-tokens` variant in the owning package (`packages/core/session/src/types.ts`, alongside `completed|aborted|error|disposed`) — add `max-tokens` because `FinishReasonMap` produces it (DeepSeek maps `length` → `max-tokens`); do not add `refusal`, since no current adapter produces it (unknown DeepSeek finish reasons collapse to `error`), but leave a comment in `TurnEndReasonMap` noting `refusal` should be added when an adapter first emits it (`FinishReasonMap` is merge-extensible); (b) make `agent-loop`'s `loop.ts` populate the reason from the model `finish` chunk — `assembler.finish` lives inside `runStep`, so `runStep` must return it up to `runTurn`, and the rule is "the last step's finish reason wins, but any `max-tokens` in the turn surfaces as `max-tokens`"; (c) no consumer exhaustively switches over `TurnEndReason` today (the invariants plugin switches on `SessionEventType`, and `deriveMessages` ignores `turn/end`), so adding `max-tokens` is a non-breaking extension — but recheck before landing; (d) update [docs/architecture.md](../../../architecture.md) (the CI-verified loop-lifecycle/event-taxonomy doc) and the affected package READMEs/JSDoc (`dsh-session`, `dsh-agent`, `dsh-agent-loop`) per the repo doc-sync policy. This replaces a fragile "observe the finish chunk in the bridge" hack with a real, documented contract.
-4. Prompt-turn streaming plus load: translate `agent/stream-chunk` and `session/event` into `session/update`; resolve `session/prompt` on settle, mapping the harness `TurnEndReason` to the ACP `StopReason` wire enum (`completed`→`end_turn`, `max-tokens`→`max_tokens`, `aborted`→`cancelled`) — a small total function with a test asserting the exact wire strings, since the SDK rejects an unknown `stopReason`. Concrete correlation, since the loop batches queued messages into one turn and `send()` does not synchronously flip to running: install the `session/event` listener before `send()`; capture the prompt's owning turn from its `turn/start` record, then resolve on that turn's `turn/end` (with `agent/status` idle/disposed as a fallback); reject an empty/whitespace prompt up front rather than calling `send()` (no turn would ever start, so the RPC would hang). Implement `session/load` on the session-persistence resume seam.
+4. Prompt-turn streaming plus load: translate `session/event` (the `assistant/chunk` token stream plus boundaries and tool activity) into `session/update`; resolve `session/prompt` on settle, mapping the harness `TurnEndReason` to the ACP `StopReason` wire enum (`completed`→`end_turn`, `max-tokens`→`max_tokens`, `aborted`→`cancelled`) — a small total function with a test asserting the exact wire strings, since the SDK rejects an unknown `stopReason`. Concrete correlation, since the loop batches queued messages into one turn and `send()` does not synchronously flip to running: install the `session/event` listener before `send()`; capture the prompt's owning turn from its `turn/start` record, then resolve on that turn's `turn/end` (with `agent/status` idle/disposed as a fallback); reject an empty/whitespace prompt up front rather than calling `send()` (no turn would ever start, so the RPC would hang). Implement `session/load` on the session-persistence resume seam.
 5. Permission gate: a single `tools/execute` listener registered with `prepend: true`, owning a `WeakMap<Agent, sessionId>` of bridge-created agents; no-op (`next()`) for unowned/no-agent calls; for owned calls → `session/request_permission` → allow (`next()`) / veto; settle the stored resolver exactly once on outcome, cancel, or connection close.
 6. Example wiring (extract a shared base). `@cordisjs/plugin-include` is itself a plugin entry that resets `ctx.baseUrl` and loads a path, so a child `cordis.yml` can nest-include a shared base; the extraction is safe because every dependent plugin declares `inject` (loader groups initialize via `Promise.all`, so YAML order is NOT the dependency mechanism — never rely on it). Extract the provider/tool core (`llm, sessions, system-prompt, tools, agents, invariants, llm-deepseek, bash-local, tool-bash`) into `examples/base.yml`; have both `coding-agent` and a new `examples/acp-agent/` include it and add their own UI plugin plus logger. Keep `agent-loop` per-example (NOT in the base): `AgentLoop` creates its configured agents in its constructor, and the two examples disagree — `coding-agent` needs a pre-created `main` (its `stdio-chat` calls `ctx.agents.get('main')`), while `acp-agent` must pre-create none (ACP `session/new` creates agents). So `coding-agent` declares `agent-loop` with `agents: [{ id: main, … }]` and `acp-agent` with `agents: []`. `acp-agent` loads `dsh-session-persistence-jsonl` (from [session persistence](../../implemented/architecture/2026-06-14-session-persistence.md) — required for `session/load`), omits the stdout logger (see Risks), and adds `pnpm run demo:acp` plus the Zed `agent_servers` snippet.
 7. Tests (the repo cares a lot here): a property-based test for the protocol shape (precedent: [property-based testing](../../implemented/testing/2026-06-11-property-based-testing.md)) — fuzz arbitrary harness event sequences and assert ACP-stream invariants (never a `tool_call_update` before its `tool_call`; exactly one `session/prompt` resolution per prompt; monotonic, well-formed ordering; `stopReason` in the legal set); codec unit tests over an in-memory `Duplex` pair (drive `AgentSideConnection` without a subprocess; assert exact frames for `initialize`, `session/new`, a full prompt turn); the mandatory HMR-safety test (dispose the fiber; assert the connection closed, all `ctx.on` listeners gone, any in-flight `request_permission` settled); failure-path tests (connection closes mid-stream; closes with a permission pending; a notification `send()` rejects but the turn survives; `finish{kind:'error'|'aborted'}`; a `tools/execute` throw with no `tool/result`; a second `session/new` rejected; a `session/prompt` while one is in flight; an empty prompt rejected without hanging; a `session/load` re-derives identical history and replays it); and an e2e (`*.e2e.ts`, self-skips without `DEEPSEEK_API_KEY`) that boots `examples/acp-agent`, connects a `ClientSideConnection`, sends a real prompt, owns and disposes the harness in `afterEach`, and verifies the world (files on disk), not the agent's self-report.

+ 1 - 2
packages/core/agent-loop/src/loop.ts

@@ -158,7 +158,7 @@ export interface LoopHandle {
  *       req = {model, system, tools, messages: session.deriveMessages(), signal}
  *       req = waterfall agent/request                 ⟵ hooks/model-switch
  *       stream ctx.llm.stream(req)                    ⟵ waterfall llm/stream (raw chunks)
- *         session('assistant/chunk'); emit agent/stream-chunk
+ *         session('assistant/chunk')
  *       msg = waterfall agent/step-result             ⟵ BEFORE the log append, so the
  *       session('assistant/message' {content, usage?})   session records what actually ran
  *       each tool-call in msg (sequential, abort-checked):
@@ -681,7 +681,6 @@ async function runStep(
     if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted'))
     const chunkEvent = session.append('assistant/chunk', { turn, step, chunk })
     chunkSeqs.push(chunkEvent.seq)
-    ctx.emit('agent/stream-chunk', agent, turn, step, chunk)
     assembler.push(chunk)
   }
 

+ 4 - 4
packages/core/agent-loop/tests/cancel.spec.ts

@@ -176,7 +176,7 @@ describe('Agent.cancel()', () => {
     // the step (the turn-scoped marker, not the step AbortController, is what
     // catches this) — no model step runs.
     let streamed = false
-    ctx.on('agent/stream-chunk', () => { streamed = true })
+    ctx.on('session/event', (_s, event) => { if (event.type === 'assistant/chunk') streamed = true })
     const dispose = ctx.on('session/event', (session, event) => {
       if (session === agent.session && event.type === 'turn/start') agent.cancel('from turn-start')
     })
@@ -205,7 +205,7 @@ describe('Agent.cancel()', () => {
     // cancel check (the one that must closeStep() to balance the already-open
     // step) — distinct from a turn-start cancel, caught before the step opens.
     let streamed = false
-    ctx.on('agent/stream-chunk', () => { streamed = true })
+    ctx.on('session/event', (_s, event) => { if (event.type === 'assistant/chunk') streamed = true })
     const dispose = ctx.on('session/event', (session, event) => {
       if (session === agent.session && event.type === 'step/start') agent.cancel('from step-start')
     })
@@ -245,7 +245,7 @@ describe('Agent.cancel()', () => {
 
     let disposalDone: Promise<void> | undefined
     let streamed = false
-    ctx.on('agent/stream-chunk', () => { streamed = true })
+    ctx.on('session/event', (_s, event) => { if (event.type === 'assistant/chunk') streamed = true })
     ctx.on('session/event', (session, event) => {
       if (session === agent.session && event.type === 'step/start') disposalDone = handle.dispose()
     })
@@ -308,7 +308,7 @@ describe('Agent.cancel()', () => {
     // runTurn. The second check (after the running flip) must drop the turn —
     // runTurn would otherwise throw on the now-empty queue.
     let streamed = false
-    ctx.on('agent/stream-chunk', () => { streamed = true })
+    ctx.on('session/event', (_s, event) => { if (event.type === 'assistant/chunk') streamed = true })
     const dispose = ctx.on('agent/status', (subject, status) => {
       if (subject === agent && status === 'running') agent.cancel('from running listener')
     })

+ 4 - 8
packages/core/agent-loop/tests/loop.spec.ts

@@ -137,21 +137,17 @@ describe('agent loop', () => {
     expect(request!.tools?.map(t => t.name)).toEqual(['noop'])
   })
 
-  it('records raw chunks for replay and emits agent/stream-chunk', async () => {
+  it('records raw chunks for replay as assistant/chunk session events', async () => {
     const adapter = new MockAdapter([textResponse('abc')])
     const ctx = await harness(adapter)
     const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
 
-    const streamed: StreamChunk[] = []
-    ctx.on('agent/stream-chunk', (_agent, _turn, _step, chunk) => void streamed.push(chunk))
-
     send(agent, 'hi')
     await waitForIdle(ctx, agent)
 
     const chunkEvents = agent.session.events.filter(e => e.type === 'assistant/chunk')
     // textResponse('abc') = block-start + 3 deltas + block-end + usage + finish = 7
     expect(chunkEvents).toHaveLength(7)
-    expect(streamed).toHaveLength(7)
     // replay: chunk events alone re-assemble to the recorded assistant message
     const deltaText = chunkEvents
       .flatMap(e => e.type === 'assistant/chunk' ? [e.data.chunk] : [])
@@ -685,10 +681,10 @@ describe('agent loop', () => {
     ctx.on('session/event', (_s, event) => { if (event.type === 'turn/start') turns.push(event.data.turn) })
 
     // queue two messages while idle — first starts turn 1 immediately;
-    // queue the second during turn 1 via a stream-chunk hook
+    // queue the second during turn 1 when the first assistant chunk streams
     let queued = false
-    ctx.on('agent/stream-chunk', () => {
-      if (!queued) {
+    ctx.on('session/event', (_s, event) => {
+      if (event.type === 'assistant/chunk' && !queued) {
         queued = true
         send(agent, 'second message')
       }

+ 2 - 7
packages/core/agent/src/types.ts

@@ -19,7 +19,7 @@
  *   live `Agent`. Two shapes: INTERCEPTION seams (the `agent/prompt-submit`/
  *   `agent/request`/`agent/step-result`/`agent/turn-continuation` waterfalls and
  *   the serial `agent/pre-step`) that mutate/veto, and TRANSIENT emits
- *   (`agent/status`, `agent/stream-chunk`, `agent/error`, `agent/created`/
+ *   (`agent/status`, `agent/error`, `agent/created`/
  *   `agent/disposed`, `agent/queued`, `agent/steering`, `agent/session-start`)
  *   that notify with the `Agent` in hand. Turn/step boundaries are NOT here —
  *   they are durable `session/event` records. Answers "right now, with the agent
@@ -44,7 +44,7 @@
  */
 
 import type { Branded } from '@deepseek-ai/dsh-brand'
-import type { ContentBlock, GenerateOptions, Message, MessageSource, StreamChunk } from '@deepseek-ai/dsh-llm'
+import type { ContentBlock, GenerateOptions, Message, MessageSource } from '@deepseek-ai/dsh-llm'
 
 /** Identifies one live agent in the registry. */
 export type AgentId = Branded<'AgentId'>
@@ -340,11 +340,6 @@ declare module 'cordis' {
     'agent/turn-continuation'(agent: Agent, turn: number, defaultDecision: ContinuationDecision, next: () => Promise<ContinuationDecision>): Promise<ContinuationDecision>
 
     // ---- streaming + tool notifications (emit) ----
-    /**
-     * A raw {@link StreamChunk} arrived from the model (token-level UI/log feed).
-     * @mode emit
-     */
-    'agent/stream-chunk'(agent: Agent, turn: number, step: number, chunk: StreamChunk): void
     /**
      * Steering content was injected into a running turn.
      * @mode emit

+ 2 - 3
packages/support/ui-stdio/README.md

@@ -1,6 +1,6 @@
 # @deepseek-ai/dsh-ui-stdio
 
-A minimal stdio (readline) UI, as a plugin. It reads lines from stdin and feeds them to an agent (`send` when idle, `steer` while a turn is running), and renders that agent's streamed output and tool activity to stdout. A UI is "just a plugin" here — it consumes the `session/event` transcript feed plus a few `agent/*` control events (`agent/stream-chunk`, `agent/status`, `agent/created`/`agent/disposed`) and the `agents` service (`inject: ['agents']`), so the same plugin drives any example or product surface.
+A minimal stdio (readline) UI, as a plugin. It reads lines from stdin and feeds them to an agent (`send` when idle, `steer` while a turn is running), and renders that agent's streamed output and tool activity to stdout. A UI is "just a plugin" here — it consumes the `session/event` transcript feed plus a few `agent/*` control events (`agent/status`, `agent/created`/`agent/disposed`) and the `agents` service (`inject: ['agents']`), so the same plugin drives any example or product surface.
 
 This is a **convenience REPL for local testing and the demos, not a product surface** — its observable behavior is free to change. It is deliberately NOT treated as a load-bearing consumer when weighing whether a live event/API must exist: the boundary mirror events were removed precisely because "ui-stdio renders from them" is not a product constraint (it was migrated to `session/event`). The real product surfaces are the ACP bridge (`dsh-acp`) and the app packages.
 
@@ -24,8 +24,7 @@ This package consolidates what were two near-identical copies under `examples/ec
 
 Rendering is **global** — every agent's events are written to stdout, not just `config.agent`'s. `config.agent` scopes only *input* (which agent stdin drives) and the EOF-exit gate; the single-agent demos this serves have just one agent, so the distinction is moot for them. (A multi-agent UI that needs per-agent panes would filter these handlers by the agent argument — deliberately out of scope here.)
 
-- `agent/stream-chunk` — `text-delta` is written verbatim; `reasoning-delta` is wrapped in the dim SGR (`\x1B[2m … \x1B[0m`) so the chain-of-thought is visually subordinate to the answer. Reasoning rendering is inert when no `reasoning-delta` chunks arrive (e.g. a mock model), so it is always on.
-- `session/event` — the durable transcript feed drives all boundary and content rendering: `turn/start` prints a `[<agent> turn N]` header (the short agent label comes from a session-id→agent-id map seeded from `ctx.agents.list()` at install and kept live via `agent/created`/`agent/disposed`, since the turn event carries only the turn number), `turn/end` prints the trailing `> ` prompt, `tool/call` renders `[tool call] name(args)`, `tool/result` renders the joined text blocks as `[tool result] …`, and `todo/write` renders a glyphed checklist.
+- `session/event` — the durable transcript feed drives ALL rendering, from a single listener so `inReasoning` transitions stay deterministic in append order: `assistant/chunk` writes the model's `text-delta` verbatim and wraps `reasoning-delta` in the dim SGR (`\x1B[2m … \x1B[0m`) so the chain-of-thought is visually subordinate to the answer (inert when no `reasoning-delta` chunks arrive, e.g. a mock model); `turn/start` prints a `[<agent> turn N]` header (the short agent label comes from a session-id→agent-id map seeded from `ctx.agents.list()` at install and kept live via `agent/created`/`agent/disposed`, since the turn event carries only the turn number); `turn/end` prints the trailing `> ` prompt; `tool/call` renders `[tool call] name(args)`; `tool/result` renders the joined text blocks as `[tool result] …`; and `todo/write` renders a glyphed checklist.
 
 ## The I/O seam
 

+ 23 - 20
packages/support/ui-stdio/src/index.ts

@@ -1,8 +1,10 @@
 /**
  * Minimal stdio UI plugin: reads lines from stdin → `agent.send()`/`steer()`,
- * and renders the agent's stream chunks and tool activity to stdout. A UI is
- * "just a plugin" — it only consumes the `agent/*` event taxonomy and the
- * `agents` service, so the same plugin drives any example or product surface.
+ * and renders the durable transcript to stdout. A UI is "just a plugin" — it
+ * consumes the `session/event` feed (the assistant token stream, turn/step
+ * boundaries, tool activity, todos) plus a few `agent/*` control events
+ * (`agent/status`, `agent/created`/`agent/disposed`) and the `agents` service,
+ * so the same plugin drives any example or product surface.
  *
  * Consolidates what were two near-identical copies under `examples/echo-agent`
  * and `examples/coding-agent` (the latter a superset). This package IS that
@@ -91,25 +93,26 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
   ctx.on('agent/created', (agent) => { labelBySession.set(agent.session.header.id, agent.id) })
   ctx.on('agent/disposed', (agent) => { labelBySession.delete(agent.session.header.id) })
 
+  // Transcript rendering off the durable `session/event` feed — the assistant
+  // token stream, turn/step boundaries, tool activity, and todos all come from
+  // the one canonical stream (no agent/* mirrors). A single listener over the
+  // append order keeps `inReasoning` transitions deterministic across chunk and
+  // boundary events.
   let inReasoning = false
-  ctx.on('agent/stream-chunk', (_agent, _turn, _step, chunk) => {
-    if (chunk.type === 'reasoning-delta') {
-      // Dim the chain-of-thought so the final answer stands out.
-      if (!inReasoning) output.write('\x1B[2m')
-      inReasoning = true
-      output.write(chunk.text)
-    } else if (chunk.type === 'text-delta') {
-      if (inReasoning) output.write('\x1B[0m\n')
-      inReasoning = false
-      output.write(chunk.text)
-    }
-  })
-
-  // Transcript rendering off the durable `session/event` feed — turn/step
-  // boundaries, tool activity, and todos all come from the one canonical stream
-  // (no agent/* boundary mirrors).
   ctx.on('session/event', (session, event) => {
-    if (event.type === 'turn/start') {
+    if (event.type === 'assistant/chunk') {
+      const { chunk } = event.data
+      if (chunk.type === 'reasoning-delta') {
+        // Dim the chain-of-thought so the final answer stands out.
+        if (!inReasoning) output.write('\x1B[2m')
+        inReasoning = true
+        output.write(chunk.text)
+      } else if (chunk.type === 'text-delta') {
+        if (inReasoning) output.write('\x1B[0m\n')
+        inReasoning = false
+        output.write(chunk.text)
+      }
+    } else if (event.type === 'turn/start') {
       const label = labelBySession.get(session.header.id) ?? session.header.id
       output.write(`\n[${label} turn ${event.data.turn}] `)
     } else if (event.type === 'turn/end') {

+ 17 - 16
packages/support/ui-stdio/tests/ui-stdio.spec.ts

@@ -3,7 +3,7 @@ import { describe, expect, it, vi } from 'vitest'
 import { Context } from 'cordis'
 import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
 import AgentRegistry from '@deepseek-ai/dsh-agent'
-import type { ContentBlock } from '@deepseek-ai/dsh-llm'
+import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm'
 import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
 import { createStdioChat, type Config, type StdioRuntime } from '../src/index.ts'
 
@@ -69,6 +69,11 @@ function makeSession(agentId: string): Session {
   return { header: { id: `${agentId}-session` } } as Session
 }
 
+/** An `assistant/chunk` session event carrying one raw stream chunk. */
+function chunkEvent(chunk: StreamChunk): SessionEvent {
+  return { type: 'assistant/chunk', seq: 0, time: 0, data: { turn: 1, step: 0, chunk } }
+}
+
 const CONFIG: Config = { welcome: 'hi there', agent: 'main' }
 
 async function setup(config: Config = CONFIG, runtimeOver: Partial<StdioRuntime> = {}) {
@@ -102,25 +107,23 @@ describe('createStdioChat rendering', () => {
 
   it('renders text-delta chunks verbatim', async () => {
     const { ctx, out } = await setup()
-    const agent = makeAgent('main')
-    ctx.emit('agent/stream-chunk', agent, 1, 0, { type: 'text-delta', index: 0, text: 'hello' })
+    ctx.emit('session/event', makeSession('main'), chunkEvent({ type: 'text-delta', index: 0, text: 'hello' }))
     expect(out.text()).toContain('hello')
   })
 
   it('wraps reasoning-delta in the dim SGR and resets on the following text-delta', async () => {
     const { ctx, out } = await setup()
-    const agent = makeAgent('main')
-    ctx.emit('agent/stream-chunk', agent, 1, 0, { type: 'reasoning-delta', index: 0, text: 'think' })
-    ctx.emit('agent/stream-chunk', agent, 1, 0, { type: 'reasoning-delta', index: 0, text: 'more' })
-    ctx.emit('agent/stream-chunk', agent, 1, 0, { type: 'text-delta', index: 0, text: 'answer' })
+    const session = makeSession('main')
+    ctx.emit('session/event', session, chunkEvent({ type: 'reasoning-delta', index: 0, text: 'think' }))
+    ctx.emit('session/event', session, chunkEvent({ type: 'reasoning-delta', index: 0, text: 'more' }))
+    ctx.emit('session/event', session, chunkEvent({ type: 'text-delta', index: 0, text: 'answer' }))
     expect(out.text()).toContain('\x1B[2mthinkmore\x1B[0m\nanswer')
   })
 
   it('ignores stream-chunk types it does not render', async () => {
     const { ctx, out } = await setup()
     const before = out.text()
-    const agent = makeAgent('main')
-    ctx.emit('agent/stream-chunk', agent, 1, 0, { type: 'block-start', index: 0, blockType: 'text' })
+    ctx.emit('session/event', makeSession('main'), chunkEvent({ type: 'block-start', index: 0, blockType: 'text' }))
     expect(out.text()).toBe(before)
   })
 
@@ -171,9 +174,9 @@ describe('createStdioChat rendering', () => {
 
   it('resets dim styling at turn/end if a turn ends mid-reasoning', async () => {
     const { ctx, out } = await setup()
-    const agent = makeAgent('main')
-    ctx.emit('agent/stream-chunk', agent, 1, 0, { type: 'reasoning-delta', index: 0, text: 'mid' })
-    ctx.emit('session/event', makeSession('main'), {
+    const session = makeSession('main')
+    ctx.emit('session/event', session, chunkEvent({ type: 'reasoning-delta', index: 0, text: 'mid' }))
+    ctx.emit('session/event', session, {
       type: 'turn/end', seq: 1, time: 0, data: { turn: 1, reason: { kind: 'completed' } },
     } as SessionEvent)
     expect(out.text()).toContain('\x1B[2mmid\x1B[0m')
@@ -230,8 +233,7 @@ describe('createStdioChat rendering', () => {
 
   it('resets dim styling when a todo/write interrupts reasoning', async () => {
     const { ctx, out } = await setup()
-    const agent = makeAgent('main')
-    ctx.emit('agent/stream-chunk', agent, 1, 0, { type: 'reasoning-delta', index: 0, text: 'r' })
+    ctx.emit('session/event', {} as Session, chunkEvent({ type: 'reasoning-delta', index: 0, text: 'r' }))
     ctx.emit('session/event', {} as Session, {
       type: 'todo/write', seq: 1, time: 0,
       data: { todos: [{ content: 'a task', status: 'pending' }] },
@@ -241,9 +243,8 @@ describe('createStdioChat rendering', () => {
 
   it('resets dim styling when a tool/call interrupts reasoning', async () => {
     const { ctx, out } = await setup()
-    const agent = makeAgent('main')
-    ctx.emit('agent/stream-chunk', agent, 1, 0, { type: 'reasoning-delta', index: 0, text: 'r' })
     const session = {} as Session
+    ctx.emit('session/event', session, chunkEvent({ type: 'reasoning-delta', index: 0, text: 'r' }))
     ctx.emit('session/event', session, {
       type: 'tool/call', seq: 1, time: 0,
       data: { turn: 1, step: 0, callId: 'c1', name: 'bash', arguments: '{}' },