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

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