fix(tui): surface gateway stderr tail in start_timeout activity (#17112)

* fix(tui): append gateway stderr tail to start_timeout activity

`gateway.start_timeout` previously published only `cwd` + `python`,
which made TUI startup failures hard to disambiguate.  The user saw
`gateway startup timed out · /path/to/python /repo · /logs to inspect`
with no signal whether the actual cause was a wrong python interpreter,
a missing dependency, or a config parse failure.

Plumb a 20-line stderr tail through the event so the most useful lines
land directly in the TUI activity feed, capped to the last 8 non-empty
lines for readability:

* `gatewayClient.ts` — collect `getLogTail(20)` when the readyTimer
  fires and attach it as `payload.stderr_tail`.
* `gatewayTypes.ts`  — extend the `gateway.start_timeout` event union
  with the new optional field.
* `createGatewayEventHandler.ts` — emit the trimmed lines after the
  existing `gateway startup timed out` activity entry, classified
  `error`.

Tests: regression test in `createGatewayEventHandler.test.ts` checks
that `ModuleNotFoundError` / `FileNotFoundError` lines from the tail
land in `getTurnState().activity` so they show up in the UI immediately.

Validation: `npm run type-check` clean, `npm test --run` 390/390.

* review(copilot): filter blanks before slice and cap stderr tail at 120 chars
This commit is contained in:
brooklyn!
2026-04-28 13:56:02 -07:00
committed by GitHub
parent 0d957a8d48
commit a830f25f71
4 changed files with 52 additions and 3 deletions

View File

@@ -293,6 +293,27 @@ describe('createGatewayEventHandler', () => {
expect(appended[1]).toMatchObject({ role: 'assistant', text: 'final answer' })
})
it('annotates gateway.start_timeout with stderr tail lines so users can diagnose without /logs', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
onEvent({
payload: {
cwd: '/repo',
python: '/opt/venv/bin/python',
stderr_tail:
'[startup] timed out\nModuleNotFoundError: No module named openai\nFileNotFoundError: ~/.hermes/config.yaml'
},
type: 'gateway.start_timeout'
} as any)
const messages = getTurnState().activity.map(a => a.text)
expect(messages.some(m => m.includes('gateway startup timed out'))).toBe(true)
expect(messages.some(m => m.includes('ModuleNotFoundError'))).toBe(true)
expect(messages.some(m => m.includes('FileNotFoundError'))).toBe(true)
})
it('anchors inline_diff as its own segment where the edit happened', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))

View File

@@ -321,12 +321,30 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
}
case 'gateway.start_timeout': {
const { cwd, python } = ev.payload ?? {}
const { cwd, python, stderr_tail: stderrTail } = ev.payload ?? {}
const trace = python || cwd ? ` · ${String(python || '')} ${String(cwd || '')}`.trim() : ''
setStatus('gateway startup timeout')
turnController.pushActivity(`gateway startup timed out${trace} · /logs to inspect`, 'error')
// Surface the most useful stderr lines inline so users can tell
// "wrong python", "missing dep", and "config parse failure"
// apart without leaving the TUI. Filter blank rows BEFORE
// taking the last N so trailing empty lines in the buffer
// don't crowd out actual content; truncate to match the
// 120-char clip used for `gateway.stderr` activity entries.
const STDERR_LINE_CAP = 120
const STDERR_LINES_MAX = 8
const tailLines = (stderrTail ?? '')
.split('\n')
.map(l => l.trim())
.filter(Boolean)
.slice(-STDERR_LINES_MAX)
for (const line of tailLines) {
turnController.pushActivity(line.slice(0, STDERR_LINE_CAP), 'error')
}
return
}

View File

@@ -117,8 +117,18 @@ export class GatewayClient extends EventEmitter {
return
}
// Append the most recent gateway stderr/log lines to the timeout
// event so users can tell apart "wrong python", "missing dep",
// and "config parse failure" from one glance instead of having
// to dig through `/logs`. Capped to keep the activity feed
// readable on slow boots.
const stderrTail = this.getLogTail(20)
this.pushLog(`[startup] timed out waiting for gateway.ready (python=${python}, cwd=${cwd})`)
this.publish({ type: 'gateway.start_timeout', payload: { cwd, python } })
this.publish({
type: 'gateway.start_timeout',
payload: { cwd, python, stderr_tail: stderrTail }
})
}, STARTUP_TIMEOUT_MS)
this.proc = spawn(python, ['-m', 'tui_gateway.entry'], { cwd, env, stdio: ['pipe', 'pipe', 'pipe'] })

View File

@@ -415,7 +415,7 @@ export type GatewayEvent =
| { payload?: { state?: 'idle' | 'listening' | 'transcribing' }; session_id?: string; type: 'voice.status' }
| { payload?: { no_speech_limit?: boolean; text?: string }; session_id?: string; type: 'voice.transcript' }
| { payload: { line: string }; session_id?: string; type: 'gateway.stderr' }
| { payload?: { cwd?: string; python?: string }; session_id?: string; type: 'gateway.start_timeout' }
| { payload?: { cwd?: string; python?: string; stderr_tail?: string }; session_id?: string; type: 'gateway.start_timeout' }
| { payload?: { preview?: string }; session_id?: string; type: 'gateway.protocol_error' }
| { payload?: { text?: string }; session_id?: string; type: 'reasoning.delta' | 'reasoning.available' }
| { payload: { name?: string; preview?: string }; session_id?: string; type: 'tool.progress' }