From a830f25f716190168dd7db6819c0b48848049002 Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Tue, 28 Apr 2026 13:56:02 -0700 Subject: [PATCH] fix(tui): surface gateway stderr tail in start_timeout activity (#17112) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .../createGatewayEventHandler.test.ts | 21 +++++++++++++++++++ ui-tui/src/app/createGatewayEventHandler.ts | 20 +++++++++++++++++- ui-tui/src/gatewayClient.ts | 12 ++++++++++- ui-tui/src/gatewayTypes.ts | 2 +- 4 files changed, 52 insertions(+), 3 deletions(-) diff --git a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts index c09bd4ee9..fbcc069dd 100644 --- a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts +++ b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts @@ -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)) diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index 267bf8c16..d36faa336 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -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 } diff --git a/ui-tui/src/gatewayClient.ts b/ui-tui/src/gatewayClient.ts index 9bf681f8b..838bf31fb 100644 --- a/ui-tui/src/gatewayClient.ts +++ b/ui-tui/src/gatewayClient.ts @@ -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'] }) diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 605d51213..5a7f8d8ad 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -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' }