Merge master into tool-result pruning

Retarget the pruning feature onto the merged compaction foundation.

Adapt content-only tool-result rewrite validation to the session-owned surface manager, migrate the demo wiring to repl-agent, and refresh generated type, catalog, and website contracts while preserving pressure and overflow pruning behavior.
This commit is contained in:
Tianyi Cui
2026-07-19 17:54:55 +08:00
972 changed files with 59334 additions and 11465 deletions

View File

@@ -9,16 +9,16 @@ This package owns the terminal channel only. It injects `agents` and `userIntera
| Key | Default | Meaning |
|---|---|---|
| `welcome` | `ready.` | Banner printed before the first prompt |
| `agent` | `main` | Agent id driven by stdin and observed for EOF shutdown |
| `sessionId` | `main` | Exact agent/session identity driven by stdin and observed for EOF shutdown |
The plugin seeds display labels from the live agent registry, then tracks `agent/created` and `agent/disposed` so HMR and externally managed agents render consistently. Disposal closes readline and unregisters every listener/provider through Cordis effects.
The plugin seeds display labels from the live agent registry, then tracks `agent/created` and `agent/disposed` so HMR and externally managed agents render consistently. While an initial exact identity is pending, it buffers nonblank input until `agent/session-start` and observes live `agent-loop/config-start-failed`; a matching failure drops queued lines, reports the loss, and lets piped EOF finish instead of hanging. The composing app must mount this front door before its config-created agent. Disposal closes readline and unregisters every listener/provider through Cordis effects.
```yaml
- id: stdio
name: '@deepseek-ai/dsh-stdio'
config:
welcome: 'agent REPL ready. Give it a coding task.'
agent: main
sessionId: main
```
## Model Experience
@@ -37,6 +37,6 @@ The plugin seeds display labels from the live agent registry, then tracks `agent
## Known Limitations and Deferred Work
- **One configured agent receives stdin** — the session/event renderer can print output from any session, but input lines always drive the configured `agent` id rather than routing by the visible label.
- **One configured session receives stdin** — the session/event renderer can print output from any session, but input lines always drive the configured `sessionId` rather than routing by the visible label.
- **Terminal questions are text-only and sequential** — the provider queues asks, supports option labels plus custom text, and has no richer UI shapes such as file pickers or diff previews.
- **Closed stdin ends the terminal channel** — EOF rejects active or queued questions and exits after submitted work reaches idle; there is no reconnect path for a long-lived process.

View File

@@ -23,10 +23,16 @@
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-agent-loop": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-user-interaction": "^0.0.1",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
},
"peerDependenciesMeta": {
"@deepseek-ai/dsh-agent-loop": {
"optional": true
}
},
"dependencies": {
"schemastery": "^3.18.0"
@@ -34,9 +40,10 @@
"devDependencies": {
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-user-interaction": "workspace:^",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -1,7 +1,8 @@
/**
* The stdio app's readline UI: reads lines from stdin into `agent.send()` or
* `steer()`, renders the durable event stream to stdout, and exits piped input
* only after submitted work reaches idle.
* `steer()`, renders the durable event stream to stdout, buffers startup input
* for one exact agent/session identity, and exits piped input only after
* submitted work reaches idle.
*
* This package is the independently composable stdio front door. It establishes
* the terminal channel and drives an agent created or resumed by app or
@@ -13,7 +14,9 @@ import { createInterface } from 'node:readline'
import type { Readable, Writable } from 'node:stream'
import type { Context } from 'cordis'
import z from 'schemastery'
import { AgentId } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-agent-loop'
import { SessionId } from '@deepseek-ai/dsh-session'
import {
UserInteractionError,
type AskUserQuestionAnswer,
@@ -30,13 +33,13 @@ export const inject = ['agents', 'userInteraction']
export interface Config {
/** Banner printed once on start, before the first `> ` prompt. */
welcome?: string
/** Id of the agent stdin drives (`send`/`steer`) and whose status gates the EOF exit; rendering is global. Defaults to `'main'`. */
agent?: string
/** Exact shared agent/session identity stdin drives. Defaults to `'main'`. */
sessionId?: string
}
export const Config: z<Config> = z.object({
welcome: z.string().default('ready.'),
agent: z.string().default('main'),
sessionId: z.string().default('main'),
})
/**
@@ -59,6 +62,15 @@ function isTTYPair(input: Readable, output: Writable): boolean {
return Boolean((input as { isTTY?: boolean }).isTTY && (output as { isTTY?: boolean }).isTTY)
}
/** Render an arbitrary failure without allowing hostile coercion to escape the UI boundary. */
function renderThrown(value: unknown): string {
try {
return String(value)
} catch {
return '<unrenderable thrown value>'
}
}
interface PendingQuestion {
request: AskUserQuestionRequest
questionIndex: number
@@ -74,10 +86,15 @@ type OptionSelection =
| { kind: 'invalid' }
/**
* Register stdio chat against an injectable I/O runtime.
* @param ctx - agent and event context.
* @param config - plugin config, defaulted for direct callers.
* @param runtime - line source, render sink, and exit hook.
* The plugin body, parameterized over its I/O runtime. `apply` is the thin
* production wrapper that binds the real `process` streams; tests call this
* directly with fakes. Returns nothing — all registration is via `ctx.on`/
* `ctx.effect`, so fiber disposal tears every listener and the readline
* interface down.
* @param ctx - the context supplying the `agents` service and the event feeds.
* @param config - the plugin config; defaults are re-applied here for direct
* callers that bypass Loader validation.
* @param runtime - the process-I/O seam (line source, render sink, exit hook).
*/
export function createStdioChat(ctx: Context, config: Config, runtime: StdioRuntime): void {
// Default here too (not just via schemastery's `.default()`): this helper is
@@ -85,18 +102,22 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
// Loader validation, so it must be self-contained rather than trusting the
// cast — `config.welcome as string` would otherwise be `undefined` on `{}`.
const welcome = config.welcome ?? 'ready.'
const agentId = AgentId(config.agent ?? 'main')
const sessionId = SessionId(config.sessionId ?? 'main')
const { input, output, exit } = runtime
// Session ids need not equal agent ids. Seed existing agents before listening
// so a pre-created or HMR-surviving agent still gets its short render label.
const labelBySession = new Map<string, string>()
for (const agent of ctx.agents.list()) labelBySession.set(agent.session.header.id, agent.id)
ctx.on('agent/created', (agent) => { labelBySession.set(agent.session.header.id, agent.id) })
ctx.on('agent/disposed', (agent) => { labelBySession.delete(agent.session.header.id) })
// Bind only to the exact identity this app passed to its config-created
// agent. Session ids are opaque: neither a prefix nor registry order can
// identify ownership. The root check rejects a child that somehow preempts
// the configured id; later recreation under the same id supports loop HMR.
const matchesConfiguredIdentity = (agent: Agent): boolean =>
agent.id === sessionId && ctx.agents.roots().includes(agent)
let target: Agent | undefined = ctx.agents.roots().find(agent => agent.id === sessionId)
// Render the canonical append order from session/event so reasoning state is
// deterministic across chunks and boundaries; there are no agent/* mirrors.
// 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('session/event', (session, event) => {
if (event.type === 'assistant/chunk') {
@@ -112,7 +133,7 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
output.write(chunk.text)
}
} else if (event.type === 'turn/start') {
const label = labelBySession.get(session.header.id) ?? session.header.id
const label = target?.session === session ? 'main' : session.id
output.write(`\n[${label} turn ${event.data.turn}] `)
} else if (event.type === 'turn/end') {
if (inReasoning) output.write('\x1B[0m')
@@ -142,10 +163,16 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
})
ctx.effect(() => {
const reader = createInterface({ input, output, terminal: isTTYPair(input, output) })
// On piped EOF, exit immediately if no work was submitted. Otherwise wait
// for a real running state followed by idle: sends do not synchronously mark
// running, and several queued lines may share one turn.
// Piped-input exit, once stdin reaches EOF:
// - If no line ever submitted work (empty stdin, blank-only lines), exit
// immediately — no turn will ever start, so there is nothing to wait
// for. (Gating on an observed 'running' here would hang forever.)
// - If work WAS submitted, exit the next time the agent settles to idle
// AFTER having run. Two subtleties this handles: the loop batches
// several queued messages into ONE turn (one idle), so we don't count
// sends; and agent.send() does NOT synchronously flip status to
// 'running', so requiring an observed 'running' first (`sawRunning`)
// avoids exiting in the gap before the turn starts and dropping work.
let stdinClosed = false
let disposed = false
let submittedWork = false
@@ -153,6 +180,38 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
let exitTimer: ReturnType<typeof setTimeout> | undefined
let activeQuestion: PendingQuestion | undefined
const questionQueue: PendingQuestion[] = []
const queuedInput: string[] = []
let targetReady = target !== undefined
let hadReadyTarget = targetReady
let failedStartup: { error: unknown } | undefined
const submit = (agent: Agent, text: string): void => {
submittedWork = true
if (agent.status === 'running') {
agent.steer([{ type: 'text', text }])
} else {
agent.send([{ type: 'text', text }])
}
}
const disposeCreatedListener = ctx.on('agent/created', (agent) => {
if (!matchesConfiguredIdentity(agent)) return
target = agent
targetReady = false
failedStartup = undefined
})
const disposeSessionStartListener = ctx.on('agent/session-start', (agent) => {
if (agent !== target) return
targetReady = true
hadReadyTarget = true
for (const text of queuedInput.splice(0)) submit(agent, text)
})
const disposeDisposedListener = ctx.on('agent/disposed', (agent) => {
if (target !== agent) return
target = undefined
targetReady = false
})
const reader = createInterface({ input, output, terminal: isTTYPair(input, output) })
const maybeExit = (): void => {
if (disposed || !stdinClosed) return
@@ -160,19 +219,33 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
// Work submitted: wait until a turn has run and the agent is idle.
if (submittedWork) {
if (!sawRunning) return
const agent = ctx.agents.get(agentId)
const agent = target
if (agent && agent.status !== 'idle') return // a turn is still running
}
// Let final output flush; track the timer so re-entry coalesces and HMR
// disposal can cancel it before it exits the replacement process.
// Let any final output flush, then exit. The handle is tracked so the
// disposer can cancel it — a dispose within the flush window must not let
// the process exit out from under HMR. Re-entrant `maybeExit` calls (e.g.
// repeated idle signals) coalesce onto the one pending timer.
if (exitTimer !== undefined) {
return // exit already scheduled — coalesce re-entrant calls
}
exitTimer = setTimeout(() => { exit(0) }, 200)
}
const disposeStartupFailedListener = ctx.on('agent-loop/config-start-failed', (failedSessionId, error) => {
if (failedSessionId !== sessionId || targetReady) return
failedStartup = { error }
const dropped = queuedInput.length
queuedInput.length = 0
submittedWork = sawRunning
if (dropped > 0) {
ctx.logger.error(`ui-stdio: main agent failed to start; dropped queued stdin (${dropped} line(s)): ${renderThrown(error)}`)
}
maybeExit()
})
const disposeStatusListener = ctx.on('agent/status', (subject, status) => {
if (subject.id !== agentId) return
if (subject !== target) return
if (status === 'running') sawRunning = true
if (status === 'idle') maybeExit()
})
@@ -325,17 +398,25 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
}
const text = line.trim()
if (!text) return
const agent = ctx.agents.get(agentId)
if (!agent) {
ctx.logger.error('ui-stdio: agent "%s" is not running', agentId)
if (failedStartup !== undefined) {
ctx.logger.error(`ui-stdio: main agent failed to start; dropped queued stdin (1 line(s)): ${renderThrown(failedStartup.error)}`)
return
}
submittedWork = true
if (agent.status === 'running') {
agent.steer([{ type: 'text', text }])
} else {
agent.send([{ type: 'text', text }])
const agent = target
if (agent === undefined || !targetReady) {
// Initial exact-id restoration is asynchronous. Preserve input until
// session-start, the first supported point for queueing agent work.
// After a previously ready target disappears, a line in the HMR gap
// still fails loud unless its exact replacement is already publishing.
if (!hadReadyTarget || agent !== undefined) {
submittedWork = true
queuedInput.push(text)
return
}
ctx.logger.error('ui-stdio: main agent is not running')
return
}
submit(agent, text)
})
reader.on('close', () => {
// Fires for BOTH stdin EOF and plugin disposal (reader.close() below);
@@ -351,31 +432,25 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
disposePendingQuestions()
disposeUserInteractionProvider()
disposeStatusListener()
disposeCreatedListener()
disposeSessionStartListener()
disposeDisposedListener()
disposeStartupFailedListener()
reader.close()
}
}, 'ui-stdio')
}
/**
* Open the terminal channel once its configured agent exists. Generated stdio
* projects boot the Cordis tree first and create or resume the agent from
* developer code immediately afterward, so stdin must remain untouched until
* the matching `agent/created` notification arrives.
* Open the terminal channel for one exact identity. The chat registers before
* that agent necessarily exists so it can buffer startup input and observe a
* config-start failure instead of leaving piped stdin hanging.
* @param ctx - the context supplying the agent registry and event stream.
* @param config - presentation and target-agent configuration.
* @param runtime - process-I/O seam.
*/
export function mountStdio(ctx: Context, config: Config, runtime: StdioRuntime): void {
const agentId = AgentId(config.agent ?? 'main')
if (ctx.agents.get(agentId) !== undefined) {
createStdioChat(ctx, config, runtime)
return
}
const dispose = ctx.on('agent/created', (agent) => {
if (agent.id !== agentId) return
dispose()
createStdioChat(ctx, config, runtime)
})
createStdioChat(ctx, config, runtime)
}
/**

View File

@@ -16,9 +16,9 @@ function fakeContext(): Context {
return {
on: vi.fn(() => vi.fn()),
effect: vi.fn((callback: () => () => void) => callback()),
// The UI seeds its label map from the registry at install; this suite only
// The UI seeds its root target from the registry at install; this suite only
// exercises readline terminal-mode selection, so an empty roster suffices.
agents: { list: vi.fn(() => []) },
agents: { roots: vi.fn(() => []) },
userInteraction: { registerProvider: vi.fn(() => vi.fn()) },
} as unknown as Context
}

View File

@@ -4,7 +4,7 @@ import { Context } from 'cordis'
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import { SessionId, type Session, type SessionEvent } from '@deepseek-ai/dsh-session'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import { createStdioChat, mountStdio, type Config, type StdioRuntime } from '../src/index.ts'
@@ -57,17 +57,23 @@ 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` } },
// A minimal session stub with the agent's shared durable identity.
session: { id, header: { id } },
send: (content: ContentBlock[]) => void sent.push(content),
steer: (content: ContentBlock[]) => void steered.push(content),
} as never
}
/** Register a fake configured agent and cross the supported startup-work boundary. */
function registerReady(ctx: Context, agent: Agent, source: 'startup' | 'resume' = 'startup'): () => void {
const dispose = ctx.agents.register(agent)
ctx.emit('agent/session-start', agent, source)
return dispose
}
/** 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
function makeSession(id: string): Session {
return { id, header: { id } } as Session
}
/** An `assistant/chunk` session event carrying one raw stream chunk. */
@@ -75,7 +81,11 @@ 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' }
const CONFIG: Config = { welcome: 'hi there', sessionId: 'main' }
function unrenderableFailure(): unknown {
return { [Symbol.toPrimitive](): never { throw new Error('coercion escaped') } }
}
async function setup(config: Config = CONFIG, runtimeOver: Partial<StdioRuntime> = {}) {
const ctx = new Context()
@@ -94,7 +104,7 @@ function flushExit(): Promise<void> {
}
describe('mountStdio readiness', () => {
it('leaves stdin untouched until the configured agent is created', async () => {
it('opens before the configured agent is created so startup input can queue', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
@@ -103,9 +113,9 @@ describe('mountStdio readiness', () => {
mountStdio(inner, CONFIG, runtime)
}, { inject: ['agents', 'userInteraction'] }))
expect(out.text()).toBe('')
expect(out.text()).toBe('hi there\n> ')
ctx.agents.register(makeAgent('other'))
expect(out.text()).toBe('')
expect(out.text()).toBe('hi there\n> ')
ctx.agents.register(makeAgent('main'))
expect(out.text()).toBe('hi there\n> ')
await fiber.dispose()
@@ -125,7 +135,7 @@ describe('mountStdio readiness', () => {
await fiber.dispose()
})
it('waits for main when no target agent is configured', async () => {
it('opens for the default main identity when no target is configured', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
@@ -134,8 +144,9 @@ describe('mountStdio readiness', () => {
mountStdio(inner, { welcome: 'ready' }, runtime)
}, { inject: ['agents', 'userInteraction'] }))
expect(out.text()).toBe('ready\n> ')
ctx.agents.register(makeAgent('other'))
expect(out.text()).toBe('')
expect(out.text()).toBe('ready\n> ')
ctx.agents.register(makeAgent('main'))
expect(out.text()).toBe('ready\n> ')
await fiber.dispose()
@@ -148,12 +159,11 @@ describe('createStdioChat rendering', () => {
expect(out.text()).toBe('hi there\n> ')
})
it('falls back to default welcome/agent when called with empty config', async () => {
it('falls back to the default welcome when called with empty config', async () => {
// createStdioChat is exported and may be driven directly (bypassing the
// Loader's schemastery validation), so it must default welcome/agent itself.
// Loader's schemastery validation), so it must default the welcome itself.
const { out } = await setup({})
expect(out.text()).toBe('ready.\n> ')
// And it drives the default agent id 'main'.
})
it('detects readline terminal mode from both stream TTY flags', async () => {
@@ -205,9 +215,8 @@ describe('createStdioChat rendering', () => {
it('renders turn/start and turn/end markers from the session feed', async () => {
const { ctx, out } = await setup()
const agent = makeAgent('main')
// agent/created populates the session-id → agent-id label map.
ctx.emit('agent/created', agent)
const session = makeSession('main')
ctx.agents.register(agent)
const session = agent.session
ctx.emit('session/event', session, {
type: 'turn/start', seq: 1, time: 0, data: { turn: 3, trigger: { kind: 'message' } },
} as SessionEvent)
@@ -218,35 +227,59 @@ describe('createStdioChat rendering', () => {
expect(out.text()).toContain('\n> ')
})
it('falls back to the session id as the label when no agent is mapped', async () => {
it('uses the session id as the label for a non-target session', async () => {
const { ctx, out } = await setup()
// No agent/created emitted, so the label map is empty — the header id shows.
// No target exists, so the event's durable identity is the label.
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] ')
expect(out.text()).toContain('[orphan turn 1] ')
})
it('seeds labels for agents already registered before the UI installs', async () => {
// The pre-created `main` agent (and any agent surviving an HMR reload of just this fiber)
// fired its `agent/created` before the UI's listener existed, so the live listener alone
// would miss it. Seeding from `ctx.agents.list()` preserves the `[main turn N]` label instead
// of falling back to the raw session id.
it('uses an agent already registered before the UI installs as its target', async () => {
// The pre-created `main` agent (and any agent surviving an HMR reload of just
// this fiber) fired its `agent/created` before the UI's listener existed, so
// the live listener alone would miss it. Seeding from `ctx.agents.list()` at
// install time preserves the terminal's fixed `[main turn N]` label.
const ctx = new Context()
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
const agent = makeAgent('main')
// Durable lineage does not imply runtime child ownership: the stdio app
// may explicitly resume a persisted fork as its one configured agent.
;(agent.session.header as { parentSession?: string }).parentSession = 'persisted-parent'
ctx.agents.register(agent) // registered BEFORE the UI plugin below
const { runtime, out } = makeRuntime()
await ctx.plugin(Object.assign((inner: Context) => {
createStdioChat(inner, CONFIG, runtime)
}, { inject: ['agents', 'userInteraction'] }))
ctx.emit('session/event', makeSession('main'), {
ctx.emit('session/event', agent.session, {
type: 'turn/start', seq: 1, time: 0, data: { turn: 5, trigger: { kind: 'message' } },
} as SessionEvent)
expect(out.text()).toContain('[main turn 5] ')
})
it('buffers input for a lineage-bearing configured agent until its session starts', async () => {
const { ctx, input } = await setup({ welcome: 'hi there', sessionId: 'resumed' })
input.feed('continue')
await new Promise(resolve => setImmediate(resolve))
const unrelated = makeAgent('unrelated')
ctx.agents.register(unrelated)
ctx.emit('agent/session-start', unrelated, 'startup')
const resumed = makeAgent('resumed')
;(resumed.session.header as { parentSession?: string }).parentSession = 'persisted-parent'
ctx.agents.register(resumed)
await new Promise(resolve => setImmediate(resolve))
expect(resumed.sent).toEqual([])
ctx.emit('agent/session-start', resumed, 'resume')
await new Promise(resolve => setImmediate(resolve))
expect(unrelated.sent).toEqual([])
expect(resumed.sent).toEqual([[{ type: 'text', text: 'continue' }]])
})
it('resets dim styling at turn/end if a turn ends mid-reasoning', async () => {
const { ctx, out } = await setup()
const session = makeSession('main')
@@ -257,17 +290,63 @@ describe('createStdioChat rendering', () => {
expect(out.text()).toContain('\x1B[2mmid\x1B[0m')
})
it('drops the label mapping on agent/disposed', async () => {
it('drops the target object 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'), {
const dispose = ctx.agents.register(agent)
dispose()
// After disposal the event belongs to a non-target session, so its durable
// identity is rendered directly.
ctx.emit('session/event', agent.session, {
type: 'turn/start', seq: 1, time: 0, data: { turn: 1, trigger: { kind: 'message' } },
} as SessionEvent)
expect(out.text()).toContain('[main-session turn 1] ')
expect(out.text()).toContain('[main turn 1] ')
})
it('keeps the target when a different agent is disposed', async () => {
const { ctx, out } = await setup()
const target = makeAgent('main')
ctx.agents.register(target)
ctx.emit('agent/disposed', makeAgent('other'))
ctx.emit('session/event', target.session, {
type: 'turn/start', seq: 1, time: 0, data: { turn: 1, trigger: { kind: 'message' } },
} as SessionEvent)
expect(out.text()).toContain('[main turn 1] ')
})
it('retargets only the exact identity after loop HMR recreation', async () => {
const { ctx, input } = await setup({ welcome: 'hi there', sessionId: 'main-session-fixed' })
const oldRoot = makeAgent('main-session-fixed')
const prefixCollision = makeAgent('main-session-unrelated')
const disposeOld = ctx.agents.register(oldRoot)
ctx.agents.register(prefixCollision)
disposeOld()
const replacement = makeAgent('main-session-fixed')
ctx.agents.register(replacement)
input.feed('after hmr')
await new Promise(resolve => setImmediate(resolve))
expect(replacement.sent).toEqual([])
ctx.emit('agent/session-start', replacement, 'resume')
await new Promise(resolve => setImmediate(resolve))
expect(prefixCollision.sent).toEqual([])
expect(replacement.sent).toEqual([[{ type: 'text', text: 'after hmr' }]])
})
it('does not retarget stdin to an unrelated root after the configured agent is disposed', async () => {
const { ctx, input } = await setup()
const unrelated = makeAgent('unrelated')
ctx.agents.register(unrelated)
const configured = makeAgent('main')
const disposeConfigured = registerReady(ctx, configured)
const error = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {})
disposeConfigured()
input.feed('must not leak')
await new Promise(resolve => setImmediate(resolve))
expect(unrelated.sent).toEqual([])
expect(error).toHaveBeenCalledWith('ui-stdio: main agent is not running')
})
it('renders tool/call and tool/result session events', async () => {
@@ -720,7 +799,7 @@ describe('createStdioChat input', () => {
it('sends a typed line to an idle agent', async () => {
const { ctx, input } = await setup()
const agent = makeAgent('main', 'idle')
ctx.agents.register(agent)
registerReady(ctx, agent)
input.feed('do a thing')
await new Promise(r => setImmediate(r))
expect(agent.sent).toEqual([[{ type: 'text', text: 'do a thing' }]])
@@ -730,7 +809,7 @@ describe('createStdioChat input', () => {
it('steers a typed line into a running agent', async () => {
const { ctx, input } = await setup()
const agent = makeAgent('main', 'running')
ctx.agents.register(agent)
registerReady(ctx, agent)
input.feed('steer me')
await new Promise(r => setImmediate(r))
expect(agent.steered).toEqual([[{ type: 'text', text: 'steer me' }]])
@@ -746,22 +825,57 @@ describe('createStdioChat input', () => {
expect(agent.sent).toEqual([])
})
it('logs and drops a line when the target agent is not running', async () => {
it('buffers a line until the initial target session starts', async () => {
const { ctx, input } = await setup()
const spy = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {})
input.feed('nobody home')
await new Promise(r => setImmediate(r))
expect(spy).toHaveBeenCalledWith('ui-stdio: agent "%s" is not running', 'main')
expect(spy).not.toHaveBeenCalled()
const agent = makeAgent('main')
ctx.agents.register(agent)
await new Promise(r => setImmediate(r))
expect(agent.sent).toEqual([])
ctx.emit('agent/session-start', agent, 'startup')
await new Promise(r => setImmediate(r))
expect(agent.sent).toEqual([[{ type: 'text', text: 'nobody home' }]])
})
it('drives the agent named in config, not a hardcoded id', async () => {
const { ctx, input } = await setup({ welcome: 'w', agent: 'worker' })
it('drops later input after the configured startup fails', async () => {
const { ctx, input } = await setup()
const error = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {})
const failure = unrenderableFailure()
ctx.emit('agent-loop/config-start-failed', SessionId('main'), failure)
input.feed('cannot run')
await new Promise(r => setImmediate(r))
expect(error).toHaveBeenCalledWith(
'ui-stdio: main agent failed to start; dropped queued stdin (1 line(s)): <unrenderable thrown value>',
)
})
it('ignores a stale config-start failure after the exact target is ready', async () => {
const { ctx, input } = await setup()
const agent = makeAgent('main')
registerReady(ctx, agent)
ctx.emit('agent-loop/config-start-failed', SessionId('main'), new Error('stale'))
input.feed('still live')
await new Promise(r => setImmediate(r))
expect(agent.sent).toEqual([[{ type: 'text', text: 'still live' }]])
})
it('drives the exact app-configured resumed session', async () => {
const { ctx, input } = await setup({ welcome: 'w', sessionId: 'worker' })
const agent = makeAgent('worker')
ctx.agents.register(agent)
registerReady(ctx, agent, 'resume')
input.feed('hi')
await new Promise(r => setImmediate(r))
expect(agent.sent).toHaveLength(1)
})
})
describe('createStdioChat EOF exit', () => {
@@ -775,7 +889,7 @@ describe('createStdioChat EOF exit', () => {
it('waits for the agent to settle idle after running before exiting', async () => {
const { ctx, input, exit } = await setup()
const agent = makeAgent('main', 'idle')
ctx.agents.register(agent)
registerReady(ctx, agent)
input.feed('work')
await new Promise(r => setImmediate(r))
input.finish()
@@ -790,10 +904,50 @@ describe('createStdioChat EOF exit', () => {
expect(exit).toHaveBeenCalledWith(0)
})
it('keeps piped EOF pending until buffered startup input runs', async () => {
const { ctx, input, exit } = await setup()
input.feed('work')
input.finish()
await flushExit()
expect(exit).not.toHaveBeenCalled()
const agent = makeAgent('main', 'idle')
ctx.agents.register(agent)
await new Promise(r => setImmediate(r))
expect(agent.sent).toEqual([])
ctx.emit('agent/session-start', agent, 'startup')
await new Promise(r => setImmediate(r))
expect(agent.sent).toEqual([[{ type: 'text', text: 'work' }]])
ctx.emit('agent/status', agent, 'running')
;(agent as { status: AgentStatus }).status = 'idle'
ctx.emit('agent/status', agent, 'idle')
await flushExit()
expect(exit).toHaveBeenCalledWith(0)
})
it('drains buffered piped input and exits when configured startup fails', async () => {
const { ctx, input, exit } = await setup()
const error = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {})
input.feed('work')
input.finish()
await new Promise(r => setImmediate(r))
ctx.emit('agent-loop/config-start-failed', SessionId('other'), new Error('unrelated'))
await flushExit()
expect(exit).not.toHaveBeenCalled()
ctx.emit('agent-loop/config-start-failed', SessionId('main'), unrenderableFailure())
await flushExit()
expect(error).toHaveBeenCalledWith(
'ui-stdio: main agent failed to start; dropped queued stdin (1 line(s)): <unrenderable thrown value>',
)
expect(exit).toHaveBeenCalledWith(0)
})
it('schedules the exit only once when idle fires repeatedly', async () => {
const { ctx, input, exit } = await setup()
const agent = makeAgent('main', 'running')
ctx.agents.register(agent)
registerReady(ctx, agent)
input.feed('work')
await new Promise(r => setImmediate(r))
ctx.emit('agent/status', agent, 'running') // sawRunning = true
@@ -811,7 +965,7 @@ describe('createStdioChat EOF exit', () => {
it('does not exit on an idle transition for a different agent', async () => {
const { ctx, input, exit } = await setup()
const agent = makeAgent('main', 'idle')
ctx.agents.register(agent)
registerReady(ctx, agent)
input.feed('work')
await new Promise(r => setImmediate(r))
input.finish()
@@ -825,7 +979,7 @@ describe('createStdioChat EOF exit', () => {
it('does not exit while a turn is still running at EOF', async () => {
const { ctx, input, exit } = await setup()
const agent = makeAgent('main', 'idle')
ctx.agents.register(agent)
registerReady(ctx, agent)
input.feed('work')
await new Promise(r => setImmediate(r))
ctx.emit('agent/status', agent, 'running')
@@ -874,7 +1028,7 @@ describe('createStdioChat disposal (HMR safety)', () => {
it('removes the agent/status listener on dispose', async () => {
const { ctx, fiber, input, exit } = await setup()
const agent = makeAgent('main', 'idle')
ctx.agents.register(agent)
registerReady(ctx, agent)
input.feed('work')
await new Promise(r => setImmediate(r))
await fiber.dispose()

View File

@@ -17,6 +17,9 @@
{
"path": "../../core/agent"
},
{
"path": "../../core/agent-loop"
},
{
"path": "../../core/session"
},