Merge worktree-hooks-b-bash-seam into worktree-hooks-c-interception

Bring the interception-seams branch onto current master (via A→B). The
substantive reconciliation is master's compaction `agent/pre-step` serial seam
meeting C's interception seams:

- types.ts: keep BOTH master's `agent/pre-step` AND C's new interception events
  (`agent/prompt-submit`, `agent/session-start`, `agent/turn-continuation`→
  `ContinuationDecision`); drop the turn-mirror declarations (removed on A).
- loop.ts: the merged per-turn order is `turn/start` → per queued msg
  `agent/prompt-submit` (rewrite/inject/block) → (fully-blocked ⇒ zero-step
  `rejected`) → per step: drain steering → assemble system prompt →
  `agent/pre-step` (compaction, OUTSIDE the step) → `step/start` → single
  `deriveMessages()` → model → tools/pre-execute·dispatch·post-execute. No
  turn-mirror emits; `closeTurn()` is the A-simplified single-call form.
- Docs (architecture, core.md, agent/agent-loop READMEs, catalog) reconciled to
  show C's interception seams alongside `agent/pre-step`, no turn/step mirrors.
- rfc/README: dropped the stale `proposed/` compaction row (master moved that RFC
  to implemented/); kept C's new `pre-tool-input-rewrite` proposed row.
- interception.spec.ts: migrated its two `agent/turn-end` reason collectors to
  the `turn/end` session event, and ADDED a cross-test proving a
  `prompt-submit` rewrite + additionalContext is VISIBLE to an `agent/pre-step`
  listener on the same turn — pinning the merged seam ordering (compaction sees
  the post-prompt-submit surface, not stale history).
This commit is contained in:
Tianyi Cui
2026-07-02 04:50:14 +08:00
75 changed files with 4794 additions and 687 deletions

View File

@@ -162,9 +162,6 @@ function checkEvent(trace: SessionTrace, event: SessionEvent): void {
trace.surface.push(event.seq)
} else {
const { start, end } = se.surfaceOp
if (start > end) {
throw new InvariantError(`surface replace: start ${start} must be <= end ${end}`)
}
const startIdx = trace.surface.indexOf(start)
if (startIdx === -1) {
throw new InvariantError(`surface replace: start seq ${start} is not on the surface`)

View File

@@ -530,16 +530,17 @@ describe('surface invariants', () => {
}).toThrow(/unknown seq 2/)
})
it('rejects replace op with start > end', async () => {
it('rejects a replace whose start is positioned after its end on the surface', async () => {
const { ctx } = await setup()
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('step/start', { turn: 1, step: 1 })
session.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2
// start > end is invalid (reversed order).
session.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 3
// Reversed range: start seq 3 is at a later surface position than end seq 2.
expect(() => {
session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 2, end: 1 }, sourceEventSeqs: [2] })
}).toThrow(/must be <= end/)
session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 3, end: 2 }, sourceEventSeqs: [2, 3] })
}).toThrow(/is after end seq 2 .* on the surface/)
})
it('rejects a replace whose sourceEventSeqs omits a shadowed surface node', async () => {
@@ -608,6 +609,23 @@ describe('surface invariants', () => {
}).toThrow(/is after end seq 4 .* on the surface/)
})
it('accepts a replace whose start seq exceeds its end seq when the surface position order is valid', async () => {
const { ctx } = await setup()
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('step/start', { turn: 1, step: 1 })
session.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2
session.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 3
// Replace node 2 (position 0) with seq 4 — surface becomes [4, 3], so the
// head seq (4) is numerically GREATER than the tail seq (3): the surface is
// not seq-ordered. A replace spanning start=4 (pos 0) … end=3 (pos 1) is
// valid positionally and must be accepted even though start seq > end seq.
session.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 's' }] }, { surfaceOp: { op: 'replace', start: 2, end: 2 }, sourceEventSeqs: [2] }) // seq 4
expect(() => {
session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 4, end: 3 }, sourceEventSeqs: [4, 3] }) // seq 5
}).not.toThrow()
})
it('rejects a replace that omits sourceEventSeqs entirely', async () => {
const { ctx } = await setup()
const session = ctx.sessions.create()

View File

@@ -1,6 +1,8 @@
# @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 only consumes the `agent/*` event taxonomy plus 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/stream-chunk`, `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.
This package consolidates what were two near-identical copies under `examples/echo-agent` and `examples/coding-agent`. The coding copy was a superset; this package IS that superset — dimmed chain-of-thought rendering plus robust piped-stdin EOF handling — with the per-consumer differences moved into `Config`.
@@ -23,8 +25,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.
- `agent/turn-start` / `agent/turn-end` — a `[<agent> turn N]` header and a trailing `> ` prompt.
- `session/event``tool/call` renders `[tool call] name(args)`; `tool/result` renders the joined text blocks as `[tool result] …`.
- `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 an `agent/created`→id map, 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

@@ -76,6 +76,15 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
const agentId = AgentId(config.agent ?? 'main')
const { input, output, exit } = runtime
// Render label lookup: the `turn/start` session event carries only the turn
// number, so to print the short agent id (`[main turn 1]`) we map the
// session's id to its agent's id. The session id is not reliably the agent id
// (a session can be created with an explicit/client-supplied id), so build the
// map from `agent/created` rather than parsing the id string.
const labelBySession = new Map<string, string>()
ctx.on('agent/created', (agent) => { labelBySession.set(agent.session.header.id, agent.id) })
ctx.on('agent/disposed', (agent) => { labelBySession.delete(agent.session.header.id) })
let inReasoning = false
ctx.on('agent/stream-chunk', (_agent, _turn, _step, chunk) => {
if (chunk.type === 'reasoning-delta') {
@@ -90,18 +99,18 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
}
})
ctx.on('agent/turn-start', (agent, turn) => {
output.write(`\n[${agent.id} turn ${turn}] `)
})
ctx.on('agent/turn-end', () => {
if (inReasoning) output.write('\x1B[0m')
inReasoning = false
output.write('\n> ')
})
ctx.on('session/event', (_session, event) => {
if (event.type === 'tool/call') {
// 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') {
const label = labelBySession.get(session.header.id) ?? session.header.id
output.write(`\n[${label} turn ${event.data.turn}] `)
} else if (event.type === 'turn/end') {
if (inReasoning) output.write('\x1B[0m')
inReasoning = false
output.write('\n> ')
} else if (event.type === 'tool/call') {
const { name: toolName, arguments: args } = event.data
if (inReasoning) output.write('\x1B[0m')
inReasoning = false

View File

@@ -56,11 +56,19 @@ function makeAgent(id: string, status: AgentStatus = 'idle'): Agent & {
status,
sent,
steered,
// A minimal session stub: the UI reads only `session.header.id` (to map the
// session back to its agent id for the turn-boundary label).
session: { header: { id: `${id}-session` } },
send: (content: ContentBlock[]) => void sent.push(content),
steer: (content: ContentBlock[]) => void steered.push(content),
} as never
}
/** A session stub whose `header.id` matches an agent's, for `session/event` emits. */
function makeSession(agentId: string): Session {
return { header: { id: `${agentId}-session` } } as Session
}
const CONFIG: Config = { welcome: 'hi there', agent: 'main' }
async function setup(config: Config = CONFIG, runtimeOver: Partial<StdioRuntime> = {}) {
@@ -116,23 +124,54 @@ describe('createStdioChat rendering', () => {
expect(out.text()).toBe(before)
})
it('renders turn-start and turn-end markers', async () => {
it('renders turn/start and turn/end markers from the session feed', async () => {
const { ctx, out } = await setup()
const agent = makeAgent('main')
ctx.emit('agent/turn-start', agent, 3)
// agent/created populates the session-id → agent-id label map.
ctx.emit('agent/created', agent)
const session = makeSession('main')
ctx.emit('session/event', session, {
type: 'turn/start', seq: 1, time: 0, data: { turn: 3, trigger: { kind: 'message' } },
} as SessionEvent)
expect(out.text()).toContain('[main turn 3] ')
ctx.emit('agent/turn-end', agent, 3, { kind: 'completed' })
ctx.emit('session/event', session, {
type: 'turn/end', seq: 2, time: 0, data: { turn: 3, reason: { kind: 'completed' } },
} as SessionEvent)
expect(out.text()).toContain('\n> ')
})
it('resets dim styling at turn-end if a turn ends mid-reasoning', async () => {
it('falls back to the session id as the label when no agent is mapped', async () => {
const { ctx, out } = await setup()
// No agent/created emitted, so the label map is empty — the header id shows.
ctx.emit('session/event', makeSession('orphan'), {
type: 'turn/start', seq: 1, time: 0, data: { turn: 1, trigger: { kind: 'message' } },
} as SessionEvent)
expect(out.text()).toContain('[orphan-session turn 1] ')
})
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('agent/turn-end', agent, 1, { kind: 'completed' })
ctx.emit('session/event', makeSession('main'), {
type: 'turn/end', seq: 1, time: 0, data: { turn: 1, reason: { kind: 'completed' } },
} as SessionEvent)
expect(out.text()).toContain('\x1B[2mmid\x1B[0m')
})
it('drops the label mapping on agent/disposed', async () => {
const { ctx, out } = await setup()
const agent = makeAgent('main')
ctx.emit('agent/created', agent)
ctx.emit('agent/disposed', agent)
// After disposal the map no longer resolves the agent id — fall back to the
// session header id.
ctx.emit('session/event', makeSession('main'), {
type: 'turn/start', seq: 1, time: 0, data: { turn: 1, trigger: { kind: 'message' } },
} as SessionEvent)
expect(out.text()).toContain('[main-session turn 1] ')
})
it('renders tool/call and tool/result session events', async () => {
const { ctx, out } = await setup()
const session = {} as Session
@@ -196,7 +235,8 @@ describe('createStdioChat rendering', () => {
const { ctx, out } = await setup()
const before = out.text()
ctx.emit('session/event', {} as Session, {
type: 'turn/start', seq: 1, time: 0, data: { turn: 1, trigger: { kind: 'continuation' } },
type: 'user/message', seq: 1, time: 0,
data: { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } },
} as SessionEvent)
expect(out.text()).toBe(before)
})