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.
This commit is contained in:
Tianyi Cui
2026-07-02 23:42:16 +08:00
parent 2a66b7c4e0
commit b84d4828a8
15 changed files with 116 additions and 89 deletions

View File

@@ -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)
}

View File

@@ -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')
})

View File

@@ -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')
}

View File

@@ -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

View File

@@ -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

View File

@@ -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') {

View File

@@ -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: '{}' },