fix(ui): surface declarative startup failures

This commit is contained in:
Turtle
2026-07-17 16:44:25 +08:00
parent 1119c537d0
commit 12b7ac67ba
20 changed files with 484 additions and 72 deletions

View File

@@ -47,7 +47,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w
Types: [Agent](../core-data-structures/core.md)
Source: [`packages/core/agent/src/types.ts:283`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:292`](../../packages/core/agent/src/types.ts)
### `agent/pre-step` — serial
@@ -59,7 +59,7 @@ Awaited serial checkpoint for session-surface mutation after prompt assembly and
Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md)
Source: [`packages/core/agent/src/types.ts:202`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:211`](../../packages/core/agent/src/types.ts)
### `agent/prompt-submit` — waterfall
@@ -71,7 +71,7 @@ Allow, rewrite, or block one drained prompt before it becomes a user message. Ca
Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md)
Source: [`packages/core/agent/src/types.ts:212`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:221`](../../packages/core/agent/src/types.ts)
### `agent/queued` — emit
@@ -83,7 +83,7 @@ Detached, frozen content entered the agent's inbox. Source defaults have already
Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md)
Source: [`packages/core/agent/src/types.ts:167`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:176`](../../packages/core/agent/src/types.ts)
### `agent/request` — waterfall
@@ -95,7 +95,7 @@ Replace the frozen call configuration. Model-visible content must use logged cha
Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md)
Source: [`packages/core/agent/src/types.ts:224`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:233`](../../packages/core/agent/src/types.ts)
### `agent/session-prefix` — waterfall
@@ -107,7 +107,7 @@ Compose request-only messages placed before derived history. The frozen result i
Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md)
Source: [`packages/core/agent/src/types.ts:239`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:248`](../../packages/core/agent/src/types.ts)
### `agent/session-start` — emit
@@ -119,7 +119,17 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to
Types: [Agent](../core-data-structures/core.md) · [SessionStartSource](../core-data-structures/core.md)
Source: [`packages/core/agent/src/types.ts:180`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:189`](../../packages/core/agent/src/types.ts)
### `agent/start-failed` — emit
Declarative startup failed before an agent could be published. Programmatic `ctx.agents.create()` / `resume()` calls report failure through rejection; this event covers the fire-and-forget config path.
```ts cordis-catalog
'agent/start-failed'(agentId: AgentId, error: Error): void
```
Source: [`packages/core/agent/src/types.ts:157`](../../packages/core/agent/src/types.ts)
### `agent/status` — emit
@@ -131,7 +141,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does no
Types: [Agent](../core-data-structures/core.md)
Source: [`packages/core/agent/src/types.ts:157`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:166`](../../packages/core/agent/src/types.ts)
### `agent/step-result` — waterfall
@@ -143,7 +153,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va
Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md)
Source: [`packages/core/agent/src/types.ts:250`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:259`](../../packages/core/agent/src/types.ts)
### `agent/turn-continuation` — waterfall
@@ -155,7 +165,7 @@ Override whether the turn continues. The default continues after tool calls or s
Types: [Agent](../core-data-structures/core.md)
Source: [`packages/core/agent/src/types.ts:260`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:269`](../../packages/core/agent/src/types.ts)
### `agent/turn-stop` — serial
@@ -167,7 +177,7 @@ Monotonic terminal-stop checkpoint after continuation and steering are folded; a
Types: [Agent](../core-data-structures/core.md)
Source: [`packages/core/agent/src/types.ts:270`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:279`](../../packages/core/agent/src/types.ts)
## `approval/*`

View File

@@ -32,13 +32,15 @@ async resume(options: ResumeAgentOptions): Promise<AgentHandle>
register(agent: Agent): () => void
enter(agent: Agent): () => void
announce(agent: Agent): void
reportStartFailure(id: AgentId, error: Error): () => void
getStartFailure(id: AgentId): Error | undefined
get(id: AgentId): Agent | undefined
list(): Agent[]
```
Types: [Agent](../core-data-structures/core.md)
Source: [`packages/core/agent/src/index.ts:133`](../../packages/core/agent/src/index.ts)
Source: [`packages/core/agent/src/index.ts:187`](../../packages/core/agent/src/index.ts)
## `ctx.approval` — `ApprovalService`

View File

@@ -7,19 +7,20 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| Event | Mode | Declared in | Dispatchers | Listeners |
| --- | --- | --- | --- | --- |
| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:139`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`jsonrpc`](../packages/ui/jsonrpc), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) |
| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:139`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent`](../packages/core/agent), [`jsonrpc`](../packages/ui/jsonrpc), [`stdio`](../packages/ui/stdio) |
| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:148`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) |
| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:283`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`tui`](../packages/ui/tui) |
| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:202`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`user-approval`](../packages/ui/user-approval) |
| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:212`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) |
| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:167`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:224`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:239`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill) |
| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:180`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:157`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) |
| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:250`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:260`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:270`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:292`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`tui`](../packages/ui/tui) |
| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:211`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`user-approval`](../packages/ui/user-approval) |
| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:221`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) |
| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:176`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:233`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:248`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill) |
| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:189`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `agent/start-failed` | `emit` | [`packages/core/agent/src/types.ts:157`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent`](../packages/core/agent) |
| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:166`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) |
| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:259`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:269`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:279`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
| `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:31`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) |
| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:59`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:68`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) |

View File

@@ -23,7 +23,7 @@ Each run starts a fresh session by default (its event log lands under `./.sessio
RESUME_SESSION_ID=<prior-session-id> pnpm run demo:repl
```
The id is wired through `cordis.yml` (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`); unset, the agent starts a new session. A missing or unreadable id starts no `main` agent, so unset it or choose an existing session id.
The id is wired through `cordis.yml` (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`); unset, the agent starts a new session. A missing or unreadable id starts no `main` agent and emits `agent/start-failed`; both terminal front doors print the failure and exit nonzero, so unset it or choose an existing session id.
## Code Mode

View File

@@ -13,7 +13,7 @@ const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
const PTY_DRIVER = String.raw`
import errno, os, pty, select, signal, sys, time
node, tsx_loader, bin_script, config_path, tsconfig_path, cwd = sys.argv[1:]
node, tsx_loader, bin_script, config_path, tsconfig_path, cwd, resume_session_id = sys.argv[1:]
env = os.environ.copy()
env.update({
"DEEPSEEK_API_KEY": "keyless-tui-no-call",
@@ -21,6 +21,8 @@ env.update({
"DSH_AGENTS_HOME": os.path.join(cwd, ".agents"),
"TSX_TSCONFIG_PATH": tsconfig_path,
})
if resume_session_id:
env["RESUME_SESSION_ID"] = resume_session_id
pid, fd = pty.fork()
if pid == 0:
os.chdir(cwd)
@@ -53,15 +55,23 @@ if status is None:
os.kill(pid, signal.SIGKILL)
_, status = os.waitpid(pid, 0)
sys.stdout.buffer.write(output)
if not sent_exit:
sys.stderr.write("TUI did not render its welcome marker before timeout\n")
sys.exit(124)
if not os.WIFEXITED(status) or os.WEXITSTATUS(status) != 0:
sys.stderr.write("TUI child did not exit cleanly\n")
sys.exit(125)
if resume_session_id:
if b'ui-tui: agent "main" failed to start:' not in output:
sys.stderr.write("TUI did not render the startup failure before timeout\n")
sys.exit(126)
if not os.WIFEXITED(status) or os.WEXITSTATUS(status) != 1:
sys.stderr.write("TUI startup failure did not exit with status 1\n")
sys.exit(127)
else:
if not sent_exit:
sys.stderr.write("TUI did not render its welcome marker before timeout\n")
sys.exit(124)
if not os.WIFEXITED(status) or os.WEXITSTATUS(status) != 0:
sys.stderr.write("TUI child did not exit cleanly\n")
sys.exit(125)
`
async function runTuiLoaderSmoke(): Promise<string> {
async function runTuiLoaderSmoke(resumeSessionId = ''): Promise<string> {
const cwd = await mkdtemp(join(tmpdir(), 'coding-tui-smoke-'))
try {
return await new Promise((resolve, reject) => {
@@ -74,6 +84,7 @@ async function runTuiLoaderSmoke(): Promise<string> {
configPath,
tsconfigPath,
cwd,
resumeSessionId,
], { stdio: ['ignore', 'pipe', 'pipe'] })
let stdout = ''
let stderr = ''
@@ -98,4 +109,10 @@ describe('coding-agent TUI keyless smoke (real Loader tree in a PTY)', () => {
expect(output).toContain('DEEPSEEK')
expect(output).toContain('agent REPL ready.')
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
it('prints a config-resume failure and exits instead of leaving a blank terminal', async () => {
const output = await runTuiLoaderSmoke('missing-session')
expect(output).toContain('ui-tui: agent "main" failed to start:')
expect(output).toContain('missing-session')
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
})

View File

@@ -71,6 +71,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
'register(agent: Agent): () => void',
'enter(agent: Agent): () => void',
'announce(agent: Agent): void',
'reportStartFailure(id: AgentId, error: Error): () => void',
'getStartFailure(id: AgentId): Error | undefined',
'get(id: AgentId): Agent | undefined',
'list(): Agent[]',
],
@@ -317,6 +319,12 @@ export const EVENT_API: readonly EventApiEntry[] = [
signature: '\'agent/session-start\'(this: Scoped<Agent>, agent: Agent, source: SessionStartSource): void',
summary: 'The session lifecycle began, once before the first turn.',
},
{
name: 'agent/start-failed',
mode: 'emit',
signature: '\'agent/start-failed\'(agentId: AgentId, error: Error): void',
summary: 'Declarative startup failed before an agent could be published.',
},
{
name: 'agent/status',
mode: 'emit',

View File

@@ -79,6 +79,6 @@ Everything that goes beyond "call the model, run the tools, repeat" belongs to p
## Known Limitations and Deferred Work
- **Tool calls within a step execute sequentially** — parallel execution waits on concurrency-safety metadata in the tool contract (see `dsh-tools`).
- **No resume-or-create policy on the config path** — config-driven `create()` starts a fresh `${id}-session-<uuid>` every run (`TODO(demo)`), and a config `resumeSessionId` whose resume fails logs a warning and creates no agent.
- **No resume-or-create policy on the config path** — config-driven `create()` starts a fresh `${id}-session-<uuid>` every run (`TODO(demo)`), and a config `resumeSessionId` whose resume fails logs a warning, emits and retains `agent/start-failed` while that declaration remains loaded, and creates no agent.
- **Config agents have no per-agent persona field or setup hook** — they use the deployment persona; scoped persona/tool composition is available only through the programmatic `ctx.agents.create()` / `resume()` factory options.
- **No built-in turn budget** — the default continuation is `continue` whenever a step had tool calls or steering; bounding a runaway turn requires an `agent/turn-continuation` force-stop plugin.

View File

@@ -363,18 +363,29 @@ export class AgentLoop extends Service implements AgentFactory {
this.create(id, options, cwd === undefined ? {} : { cwd })
continue
}
ctx.effect(() => {
ctx.effect(function* (this: AgentLoop) {
let active = true
let releaseFailure = (): void => {}
const fiber = ctx.inject(['sessionPersistence'], (childCtx: Context) => {
void this.resumeWith(ctx, childCtx.sessionPersistence, {
agentId: id,
resumeSessionId,
agentOptions: options,
}).catch((error: unknown) => {
if (!active) return
const failure = new Error(error instanceof Error ? error.message : String(error), { cause: error })
ctx.logger.warn(`agent "${id}": config-driven resume of "${resumeSessionId}" failed: ${String(error)}`)
releaseFailure = ctx.agents.reportStartFailure(id, failure)
})
})
return fiber.dispose
}, `agentLoop.resume(${id})`)
yield fiber.dispose
// Yielded last, disposed first: suppress teardown rejection before the
// deferred persistence child wakes and clear any retained record.
yield () => {
active = false
releaseFailure()
}
}.bind(this), `agentLoop.resume(${id})`)
}
}

View File

@@ -4,10 +4,11 @@ import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SessionStore, { SessionId, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
import SessionPersistence from '@deepseek-ai/dsh-session-persistence'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, textResponse } from './mock-adapter.ts'
@@ -42,6 +43,72 @@ describe('config-driven session id', () => {
await loopFiber.dispose()
})
it('drops an in-flight declarative resume when its owner is disposed', async () => {
const loadStarted = Promise.withResolvers<undefined>()
const pendingLoad = Promise.withResolvers<{ meta: SessionHeader; events: SessionEvent[] }>()
class DeferredSessionPersistence extends SessionPersistence {
create(_meta: SessionHeader): Promise<void> { return Promise.resolve() }
append(_id: SessionId, _events: readonly SessionEvent[]): Promise<void> { return Promise.resolve() }
load(_id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
loadStarted.resolve(undefined)
return pendingLoad.promise
}
list(): Promise<SessionHeader[]> { return Promise.resolve([]) }
}
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
const failures: Error[] = []
ctx.on('agent/start-failed', (_id, error) => { failures.push(error) })
const loopFiber = await ctx.plugin(AgentLoop, {
agents: [{ id: AgentId('main'), model: 'mock', resumeSessionId: SessionId('deferred') }],
})
await ctx.plugin(DeferredSessionPersistence)
await loadStarted.promise
await loopFiber.dispose()
await Promise.resolve()
expect(failures).toEqual([])
expect(ctx.agents.getStartFailure(AgentId('main'))).toBeUndefined()
await ctx.fiber.dispose()
})
it('normalizes a non-Error declarative resume rejection without duplicating an Error prefix', async () => {
class RejectingSessionPersistence extends SessionPersistence {
create(_meta: SessionHeader): Promise<void> { return Promise.resolve() }
append(_id: SessionId, _events: readonly SessionEvent[]): Promise<void> { return Promise.resolve() }
load(_id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
// Third-party backends can reject arbitrary values; this exercises normalization.
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
return Promise.reject('plain failure')
}
list(): Promise<SessionHeader[]> { return Promise.resolve([]) }
}
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
const failures: Error[] = []
ctx.on('agent/start-failed', (_id, error) => { failures.push(error) })
const loopFiber = await ctx.plugin(AgentLoop, {
agents: [{ id: AgentId('main'), model: 'mock', resumeSessionId: SessionId('rejected') }],
})
await ctx.plugin(RejectingSessionPersistence)
await vi.waitFor(() => { expect(failures).toHaveLength(1) })
expect(failures[0]?.message).toBe('plain failure')
expect(failures[0]?.cause).toBe('plain failure')
await loopFiber.dispose()
await ctx.fiber.dispose()
})
it('config-driven create uses a fresh ${id}-session-<uuid> per run (restart-safe)', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-cfg-session-'))
dirs.push(root)
@@ -137,7 +204,9 @@ describe('config-driven session id', () => {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [{ id: AgentId('main'), model: 'mock', resumeSessionId: SessionId('does-not-exist') }] })
const failures: Array<{ id: string; error: Error }> = []
ctx.on('agent/start-failed', (id, error) => { failures.push({ id, error }) })
const loopFiber = await ctx.plugin(AgentLoop, { agents: [{ id: AgentId('main'), model: 'mock', resumeSessionId: SessionId('does-not-exist') }] })
const warn = vi.spyOn((ctx.agentLoop as unknown as { ctx: { logger: { warn: (...a: unknown[]) => void } } }).ctx.logger, 'warn')
.mockImplementation(() => undefined)
await ctx.plugin(SessionPersistenceJsonl, { root })
@@ -148,6 +217,13 @@ describe('config-driven session id', () => {
await new Promise(r => setTimeout(r, 200))
expect(ctx.agents.get(AgentId('main'))).toBeUndefined()
expect(warn).toHaveBeenCalledWith(expect.stringContaining('config-driven resume of "does-not-exist" failed'))
expect(failures).toHaveLength(1)
expect(failures[0]?.id).toBe('main')
expect(failures[0]?.error.message).toBe('session "does-not-exist" not found')
expect(failures[0]?.error.cause).toBeInstanceOf(Error)
expect(ctx.agents.getStartFailure(AgentId('main'))).toBe(failures[0]?.error)
await loopFiber.dispose()
expect(ctx.agents.getStartFailure(AgentId('main'))).toBeUndefined()
warn.mockRestore()
await ctx.fiber.dispose()
})

View File

@@ -14,6 +14,9 @@ Tracks live agents so UI, hook, and orchestrator plugins can find them without i
- Advanced factory lifecycle: `enter(agent)` publishes without announcing and returns an entry-bound detach; `announce(agent)` emits creation once. Detach during creation dispatch is deferred. Ordinary plugins use `register()`.
- `ctx.agents.get(id: AgentId): Agent | undefined`
- `ctx.agents.list(): Agent[]`
- `ctx.agents.reportStartFailure(id, error): () => void` announces and retains a declarative startup failure; the disposer clears that exact record without deleting a newer replacement.
- `ctx.agents.getStartFailure(id: AgentId): Error | undefined` returns a retained declarative startup failure for a configured id that never became live. Successful publication clears it.
- `observeAgentStart(ctx, id, handlers)` observes publication or declarative startup failure, including the retained-failure race for late-mounted front doors; its listeners belong to `ctx` and the returned disposer cancels the observation.
#### Factory seam (creation)
@@ -29,7 +32,7 @@ The loop plugin registers `AgentFactory`, keeping consumers independent of its c
`dsh-agent` declares the live `agent/*` coordination vocabulary so plugins do not depend on the concrete loop. Exact signatures, dispatch modes, scope-filtering rules, and payload contracts live in the generated [Cordis event catalog](../../../docs/cordis-catalog/events.md); the [architecture turn flow](../../../docs/architecture.md#turn-flow) shows their order relative to durable session events.
`agent/created` runs after setup and both registry entries; the following `agent/session-start` is the first supported startup injection point. `agent/disposed` means the exact entry left the registry. The loop quiesces its driver first; directly registered custom agents own any stronger ordering.
`agent/created` runs after setup and both registry entries; the following `agent/session-start` is the first supported startup injection point. `agent/disposed` means the exact entry left the registry. `agent/start-failed` reports a contained declarative startup failure before publication; programmatic factory calls report failures through rejection. The loop quiesces its driver first; directly registered custom agents own any stronger ordering.
Most interception points are cooperative waterfalls returning seam-specific decisions. `agent/pre-step` is a serial surface-mutation checkpoint, while `agent/turn-stop` is the terminal serial fold: it runs after ordinary continuation and steering folding, and a returned stop remains in force through turn close and flush so later steering cannot create an extra step or turn. Ordinary queued prompts remain intact. The full rationale is in the [agent-scope runtime-design RFC](../../../docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way).
@@ -69,7 +72,7 @@ The handle every plugin programs against:
## Known Limitations and Deferred Work
- **Inter-agent channels beyond delegation** — shared state, streaming child output, and background/poll semantics remain outside the current synchronous `ctx.subagents` seam.
- **`agent/session-start` cannot gate startup** — it remains a synchronous, veto-less notification; async composition that must finish before publication belongs in the factory's `setup(agentCtx)` transaction instead.
- **`agent/session-start` cannot gate startup** — it remains a synchronous, veto-less notification; async composition that must finish before publication belongs in the factory's `setup(agentCtx)` transaction instead. Declarative startup failures are reported separately through `agent/start-failed`.
- **No public step-only abort** — `cancel()` clears ALL pending work (queued + steering + in-flight); an abort that preserves queued prompts returns only with a named consumer ([stop-surface RFC](../../../docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.md)).
- **`HookContext` carries exactly one `MessageSource`** — contributions from several plugins merged onto one tool call collapse under one source; mixed provenance is unrepresentable.
- **`SessionStartSource` reserves `'clear'`/`'compact'` with no emitter yet** — only `'startup'`/`'resume'` occur until the driving subsystems land (`TODO(compaction)`).

View File

@@ -80,6 +80,60 @@ export interface AgentHandle {
dispose(): Promise<void>
}
/** Handlers for observing one configured agent's publication or startup failure. */
export interface AgentStartHandlers {
/**
* Handle publication of the requested agent.
* @param agent - the live agent that was published.
*/
onStarted: (agent: Agent) => void
/**
* Handle a retained or live declarative startup failure.
* @param error - the contained startup failure.
*/
onFailed: (error: Error) => void
}
/**
* Observe one configured agent until it is published or its declarative startup fails.
* Listeners are owned by the supplied context fiber and the returned disposer is
* idempotent through Cordis's event-disposer semantics.
* @param ctx - the context whose fiber owns the observation listeners.
* @param id - the configured agent id to observe.
* @param handlers - publication and failure callbacks.
* @returns a disposer for the observation listeners.
*/
export function observeAgentStart(ctx: Context, id: AgentId, handlers: AgentStartHandlers): () => void {
const stop = (): void => {
disposeCreated()
disposeFailure()
}
const handleStarted = (agent: Agent): void => {
if (agent.id !== id) return
stop()
handlers.onStarted(agent)
}
const handleFailed = (failedId: AgentId, error: Error): void => {
if (failedId !== id) return
stop()
handlers.onFailed(error)
}
const disposeCreated = ctx.on('agent/created', handleStarted)
const disposeFailure = ctx.on('agent/start-failed', handleFailed)
const agent = ctx.agents.get(id)
if (agent !== undefined) {
stop()
handlers.onStarted(agent)
} else {
const failure = ctx.agents.getStartFailure(id)
if (failure !== undefined) {
stop()
handlers.onFailed(failure)
}
}
return stop
}
/**
* The agent-creation factory the loop implementation provides to the registry
* via {@link AgentRegistry.setFactory}. Kept on the `dsh-agent` interface so
@@ -136,6 +190,7 @@ export class AgentRegistry extends Service {
// plus entry.agent identity; this WeakMap mirrors the authoritative id map.
private entries = new WeakMap<Agent, AgentEntry>()
private factory: FactorySlot | undefined
private startFailures = new Map<AgentId, { error: Error; token: object }>()
constructor(ctx: Context) {
super(ctx, 'agents')
@@ -224,6 +279,7 @@ export class AgentRegistry extends Service {
const carrier = scopeTarget(agent, agent)
// Prepared transactions arbitrate identity at this publication boundary.
if (this.entries.has(agent) || this.store.has(id)) throw new Error(`agent "${id}" is already registered`)
this.startFailures.delete(id)
const entry: AgentEntry = {
id,
agent,
@@ -316,6 +372,45 @@ export class AgentRegistry extends Service {
}
}
/**
* Report a contained declarative startup failure and retain it for late UI observers.
* @param id - the configured agent id that failed before publication.
* @param error - the normalized startup error.
* @returns a disposer that clears this exact failure record.
*/
reportStartFailure(id: AgentId, error: Error): () => void {
const token = {}
this.startFailures.set(id, { error, token })
this.emitStartFailed(id, error)
return () => {
if (this.startFailures.get(id)?.token === token) this.startFailures.delete(id)
}
}
/**
* Return a retained declarative startup failure for an id that never became live.
* @param id - the configured agent id.
* @returns the startup error, or undefined when the id has no retained failure.
*/
getStartFailure(id: AgentId): Error | undefined {
return this.startFailures.get(id)?.error
}
/** Emit an unscoped startup failure with the same listener containment as agent lifecycle events. */
private emitStartFailed(id: AgentId, error: Error): void {
const args: unknown[] = ['agent/start-failed', id, error]
for (const callback of this.ctx.events.dispatch('emit', args)) {
try {
const returned: unknown = callback(id, error)
void Promise.resolve(returned).catch((listenerError: unknown) => {
this.ctx.logger.warn(`agent "${id}": agent/start-failed listener rejected: ${String(listenerError)}`)
})
} catch (listenerError: unknown) {
this.ctx.logger.warn(`agent "${id}": agent/start-failed listener threw: ${String(listenerError)}`)
}
}
}
/**
* Look up a live agent.
* @param id - the agent id to look up.

View File

@@ -146,6 +146,15 @@ declare module 'cordis' {
* @mode emit
*/
'agent/disposed'(this: Scoped<Agent>, agent: Agent): void
/**
* Declarative startup failed before an agent could be published. Programmatic
* `ctx.agents.create()` / `resume()` calls report failure through rejection;
* this event covers the fire-and-forget config path.
* @param agentId - the configured id that could not be started.
* @param error - the contained startup failure.
* @mode emit
*/
'agent/start-failed'(agentId: AgentId, error: Error): void
/**
* Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does
* not enter `running` synchronously; drive lifecycle from this event.

View File

@@ -2,7 +2,7 @@ import { describe, expect, expectTypeOf, it } from 'vitest'
import { Context, Service, symbols } from 'cordis'
import type { Events } from 'cordis'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import AgentRegistry, { AgentId, agentEvents } from '@deepseek-ai/dsh-agent'
import AgentRegistry, { AgentId, agentEvents, observeAgentStart } from '@deepseek-ai/dsh-agent'
import type { Agent, AgentFactory, ContinuationStop, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent'
function stubAgent(rawId: string): Agent {
@@ -85,6 +85,110 @@ describe('AgentRegistry', () => {
])
})
it('retains declarative startup failures, contains listeners, and clears stale records', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
const warnings: string[] = []
const heard: string[] = []
ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
ctx.on('agent/start-failed', () => { throw new Error('start sync') })
ctx.on('agent/start-failed', () => Promise.reject(new Error('start async')) as never)
ctx.on('agent/start-failed', id => void heard.push(id))
const firstError = new Error('first failure')
const disposeFirst = ctx.agents.reportStartFailure(AgentId('main'), firstError)
expect(ctx.agents.getStartFailure(AgentId('main'))).toBe(firstError)
await Promise.resolve()
expect(heard).toEqual(['main'])
expect(warnings).toEqual([
'agent "main": agent/start-failed listener threw: Error: start sync',
'agent "main": agent/start-failed listener rejected: Error: start async',
])
disposeFirst()
expect(ctx.agents.getStartFailure(AgentId('main'))).toBeUndefined()
const disposeSecond = ctx.agents.reportStartFailure(AgentId('main'), new Error('second failure'))
const thirdError = new Error('third failure')
const disposeThird = ctx.agents.reportStartFailure(AgentId('main'), thirdError)
expect(ctx.agents.getStartFailure(AgentId('main'))).toBe(thirdError)
disposeSecond()
expect(ctx.agents.getStartFailure(AgentId('main'))).toBe(thirdError)
disposeThird()
const disposeOccupiedAgent = ctx.agents.register(stubAgent('occupied'))
const occupiedError = new Error('occupied failure')
const disposeOccupiedFailure = ctx.agents.reportStartFailure(AgentId('occupied'), occupiedError)
expect(() => { ctx.agents.enter(stubAgent('occupied')) }).toThrow(/already registered/)
expect(ctx.agents.getStartFailure(AgentId('occupied'))).toBe(occupiedError)
disposeOccupiedFailure()
disposeOccupiedAgent()
const disposeCleared = ctx.agents.reportStartFailure(AgentId('main'), new Error('cleared failure'))
ctx.agents.register(stubAgent('main'))()
expect(ctx.agents.getStartFailure(AgentId('main'))).toBeUndefined()
disposeCleared()
await ctx.fiber.dispose()
})
it('observes immediate, retained, live, unrelated, and cancelled startup outcomes', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
const outcomes: string[] = []
const existing = stubAgent('existing')
const disposeExisting = ctx.agents.register(existing)
observeAgentStart(ctx, existing.id, {
onStarted: agent => void outcomes.push(`started:${agent.id}`),
onFailed: error => void outcomes.push(`failed:${error.message}`),
})()
const retainedError = new Error('retained')
const disposeRetained = ctx.agents.reportStartFailure(AgentId('retained'), retainedError)
observeAgentStart(ctx, AgentId('retained'), {
onStarted: agent => void outcomes.push(`started:${agent.id}`),
onFailed: error => void outcomes.push(`failed:${error.message}`),
})()
const stopLiveStart = observeAgentStart(ctx, AgentId('live-start'), {
onStarted: agent => void outcomes.push(`started:${agent.id}`),
onFailed: error => void outcomes.push(`failed:${error.message}`),
})
const disposeUnrelatedAgent = ctx.agents.register(stubAgent('unrelated'))
const disposeUnrelatedFailure = ctx.agents.reportStartFailure(AgentId('unrelated'), new Error('unrelated'))
const disposeLiveAgent = ctx.agents.register(stubAgent('live-start'))
stopLiveStart()
const stopLiveFailure = observeAgentStart(ctx, AgentId('live-failure'), {
onStarted: agent => void outcomes.push(`started:${agent.id}`),
onFailed: error => void outcomes.push(`failed:${error.message}`),
})
const disposeLiveFailure = ctx.agents.reportStartFailure(AgentId('live-failure'), new Error('live'))
stopLiveFailure()
const stopCancelled = observeAgentStart(ctx, AgentId('cancelled'), {
onStarted: agent => void outcomes.push(`started:${agent.id}`),
onFailed: error => void outcomes.push(`failed:${error.message}`),
})
stopCancelled()
const disposeCancelledFailure = ctx.agents.reportStartFailure(AgentId('cancelled'), new Error('cancelled'))
expect(outcomes).toEqual([
'started:existing',
'failed:retained',
'started:live-start',
'failed:live',
])
disposeCancelledFailure()
disposeLiveFailure()
disposeLiveAgent()
disposeUnrelatedFailure()
disposeUnrelatedAgent()
disposeRetained()
disposeExisting()
await ctx.fiber.dispose()
})
it('separates entry from announcement and stale/idempotent detach cannot remove a replacement', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)

View File

@@ -15,8 +15,8 @@ A terminal chat always wants the same cluster, so the package owns it rather tha
| `@deepseek-ai/dsh-user-interaction` | the human question/answer seam used by confirmation tools |
| `@deepseek-ai/dsh-tool-ask-user` | the model-facing `ask_user_question` tool |
| `@cordisjs/plugin-logger-console` | readline diagnostics for non-TTY operation; omitted from the fullscreen TUI path |
| `@deepseek-ai/dsh-stdio` | the line-oriented terminal channel, bound to `main` for pipes and automation |
| `@deepseek-ai/dsh-tui` | the interactive pi-tui channel, bound to `main` for TTY pairs |
| `@deepseek-ai/dsh-stdio` | the line-oriented terminal channel, bound to `main` for pipes and automation; matching `agent/start-failed` errors print and exit nonzero |
| `@deepseek-ai/dsh-tui` | the interactive pi-tui channel, bound to `main` for TTY pairs; matching `agent/start-failed` startup errors are printed before fullscreen mode and exit nonzero |
`@cordisjs/plugin-hmr` (the dev/demo edit-reload loop) is deliberately a **leaf** entry, not baked in here: it is a Loader-only, subprocess-only dev plugin whose constructor needs `node --expose-internals` plus a live `loader`. The `demo:echo` / `demo:repl` leaves load it and pass `--expose-internals`.

View File

@@ -11,7 +11,7 @@ This package owns the terminal channel only. It injects `agents` and `userIntera
| `welcome` | `ready.` | Banner printed before the first prompt |
| `agent` | `main` | Agent id 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 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.
```yaml
- id: stdio

View File

@@ -13,7 +13,7 @@ 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 { AgentId, observeAgentStart } from '@deepseek-ai/dsh-agent'
import {
UserInteractionError,
type AskUserQuestionAnswer,
@@ -363,14 +363,12 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
*/
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)
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)
},
})
}

View File

@@ -2,7 +2,7 @@ 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 from '@deepseek-ai/dsh-agent'
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
@@ -111,6 +111,42 @@ describe('mountStdio readiness', () => {
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)

View File

@@ -33,7 +33,7 @@ While the agent is running, editor submissions call `agent.steer()`; otherwise t
maxToolOutputLines: 12
```
Startup fails before mounting when either process stream is not a TTY. Disposal stops loaders, rejects pending questions, drains terminal input, restores terminal state, unregisters event listeners and the user-interaction provider, and never exits a replacement process during HMR.
Startup fails before mounting when either process stream is not a TTY. While waiting for its configured agent, the front door also observes retained and live `agent/start-failed` notifications; a matching failure is written before fullscreen mode starts and exits with status 1 instead of leaving a blank terminal. Disposal stops loaders, rejects pending questions, drains terminal input, restores terminal state, unregisters event listeners and the user-interaction provider, and never exits a replacement process during HMR.
## Model Experience

View File

@@ -34,7 +34,7 @@ import {
} from '@earendil-works/pi-tui'
import type { Context } from 'cordis'
import z from 'schemastery'
import { AgentId, type Agent, type AgentStatus } from '@deepseek-ai/dsh-agent'
import { AgentId, observeAgentStart, type Agent, type AgentStatus } from '@deepseek-ai/dsh-agent'
import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm'
import type { Session, SessionEvent, TodoItem } from '@deepseek-ai/dsh-session'
import type {
@@ -133,7 +133,7 @@ export interface ResolvedTuiConfig {
export interface TuiRuntime {
/** Terminal implementation; production uses pi-tui's `ProcessTerminal`. */
terminal: Terminal
/** Exit hook used by `/exit`, Ctrl+D, or Ctrl+C while idle. */
/** Exit hook used by terminal shutdown or a target-agent startup failure. */
exit(code: number): void
}
@@ -1252,20 +1252,17 @@ export function createTuiChat(
*/
export function mountTui(ctx: Context, config: Config, runtime: TuiRuntime): void {
const agentId = AgentId(config.agent ?? 'main')
const start = (): void => {
ctx.effect(() => {
const controller = createTuiChat(ctx, config, runtime)
return () => controller.dispose()
}, 'ui-tui')
}
if (ctx.agents.get(agentId) !== undefined) {
start()
return
}
const dispose = ctx.on('agent/created', (agent) => {
if (agent.id !== agentId) return
dispose()
start()
observeAgentStart(ctx, agentId, {
onStarted: () => {
ctx.effect(() => {
const controller = createTuiChat(ctx, config, runtime)
return () => controller.dispose()
}, 'ui-tui')
},
onFailed: (error) => {
runtime.terminal.write(`ui-tui: agent "${agentId}" failed to start: ${error.message}\n`)
runtime.exit(1)
},
})
}

View File

@@ -910,6 +910,51 @@ describe('terminal mounting', () => {
await ctx.fiber.dispose()
})
it('prints a matching live startup failure and exits instead of waiting forever', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
ctx.provide('tools', { get: () => undefined } as never)
const terminal = new FakeTerminal()
const exit = vi.fn()
mountTui(ctx, { agent: 'main', color: false }, { terminal, exit })
ctx.agents.reportStartFailure(AgentId('other'), new Error('other failed'))
expect(terminal.output).toBe('')
expect(exit).not.toHaveBeenCalled()
ctx.agents.reportStartFailure(AgentId('main'), new Error('resume failed'))
expect(terminal.output).toBe('ui-tui: agent "main" failed to start: resume failed\n')
expect(exit).toHaveBeenCalledWith(1)
const session = ctx.sessions.create(SessionId('must-not-start'))
ctx.agents.register({
id: AgentId('main'), options: {}, session, status: 'idle', ctx,
send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
})
await tick()
expect(terminal.started).toBe(0)
await ctx.fiber.dispose()
})
it('prints a retained startup failure for a late-mounted TUI', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
ctx.provide('tools', { get: () => undefined } as never)
ctx.agents.reportStartFailure(AgentId('main'), new Error('already failed'))
const terminal = new FakeTerminal()
const exit = vi.fn()
mountTui(ctx, { agent: 'main', color: false }, { terminal, exit })
expect(terminal.started).toBe(0)
expect(terminal.output).toBe('ui-tui: agent "main" failed to start: already failed\n')
expect(exit).toHaveBeenCalledWith(1)
await ctx.fiber.dispose()
})
it('rolls back providers, listeners, and terminal state when startup fails', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)