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:
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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'] })
|
||||
|
||||
@@ -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' }
|
||||
|
||||
Reference in New Issue
Block a user