Merge origin/master into feat/tui-package
Master unifies every live agent with its exact SessionId and moves declarative startup failures to agent-loop/config-start-failed. Keeping the branch’s AgentId label binding would let the TUI target the wrong lifecycle after reload and would miss asynchronous resume failures. Resolve that contract migration by giving the selected terminal front door the same generated or resumed SessionId as agent-core, mounting the front door first, and entering fullscreen only after the matching root appears. Refresh the source-derived catalogs and keyless terminal goldens so Code Mode, workflow, Cordis-tool, and transient UI scenarios all exercise the merged identity model.
This commit is contained in:
@@ -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. While waiting for its configured agent, it also observes retained and live `agent/start-failed` notifications; a matching failure is printed and exits with status 1 instead of waiting forever. 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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+125
-48
@@ -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, observeAgentStart } 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')
|
||||
@@ -138,10 +159,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
|
||||
@@ -149,6 +176,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
|
||||
@@ -156,19 +215,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()
|
||||
})
|
||||
@@ -321,17 +394,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);
|
||||
@@ -347,29 +428,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')
|
||||
observeAgentStart(ctx, agentId, {
|
||||
onStarted: () => { createStdioChat(ctx, config, runtime) },
|
||||
onFailed: (error) => {
|
||||
runtime.output.write(`ui-stdio: agent "${agentId}" failed to start: ${error.message}\n`)
|
||||
runtime.exit(1)
|
||||
},
|
||||
})
|
||||
createStdioChat(ctx, config, runtime)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ import { Readable, Writable } from 'node:stream'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
|
||||
import AgentRegistry, { AgentId } 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,50 +113,14 @@ 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()
|
||||
})
|
||||
|
||||
it('prints a matching live startup failure and exits instead of waiting forever', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
const { runtime, out, exit } = makeRuntime()
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
mountStdio(inner, CONFIG, runtime)
|
||||
}, { inject: ['agents', 'userInteraction'] }))
|
||||
|
||||
ctx.agents.reportStartFailure(AgentId('other'), new Error('other failed'))
|
||||
expect(out.text()).toBe('')
|
||||
expect(exit).not.toHaveBeenCalled()
|
||||
ctx.agents.reportStartFailure(AgentId('main'), new Error('resume failed'))
|
||||
expect(out.text()).toBe('ui-stdio: agent "main" failed to start: resume failed\n')
|
||||
expect(exit).toHaveBeenCalledWith(1)
|
||||
|
||||
ctx.agents.register(makeAgent('main'))
|
||||
expect(out.text()).toBe('ui-stdio: agent "main" failed to start: resume failed\n')
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
it('prints a retained startup failure for a late-mounted stdio channel', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
ctx.agents.reportStartFailure(AgentId('main'), new Error('already failed'))
|
||||
const { runtime, out, exit } = makeRuntime()
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
mountStdio(inner, CONFIG, runtime)
|
||||
}, { inject: ['agents', 'userInteraction'] }))
|
||||
|
||||
expect(out.text()).toBe('ui-stdio: agent "main" failed to start: already failed\n')
|
||||
expect(exit).toHaveBeenCalledWith(1)
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
it('opens immediately when the configured agent already exists', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
@@ -161,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)
|
||||
@@ -170,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()
|
||||
@@ -184,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 () => {
|
||||
@@ -241,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)
|
||||
@@ -254,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')
|
||||
@@ -293,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 () => {
|
||||
@@ -719,7 +762,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' }]])
|
||||
@@ -729,7 +772,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' }]])
|
||||
@@ -745,22 +788,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', () => {
|
||||
@@ -774,7 +852,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()
|
||||
@@ -789,10 +867,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
|
||||
@@ -810,7 +928,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()
|
||||
@@ -824,7 +942,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')
|
||||
@@ -873,7 +991,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()
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent-loop"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user