Files
hermes/ui-tui/src/__tests__/createGatewayEventHandler.test.ts
brooklyn! 87d3fa6f1c feat(tui): opt-in auto-resume of the most recent session (#17130)
* feat(tui): opt-in auto-resume of the most recent session

`hermes --tui` always forges a fresh session at startup unless the user
sets `HERMES_TUI_RESUME=<id>`.  Disconnects, terminal-window crashes,
and accidental Ctrl+D therefore lose every piece of in-flight context
even though `state.db` still has the full history a `/resume` away.

Add an opt-in path that mirrors classic CLI's `hermes -c` muscle
memory: when `display.tui_auto_resume_recent: true` is set in
`~/.hermes/config.yaml`, the TUI looks up the most recent human-facing
session and resumes it instead of starting fresh.  Default off so
existing users aren't surprised; explicit `HERMES_TUI_RESUME` always
wins.

Wires:

* New `session.most_recent` JSON-RPC in `tui_gateway/server.py` that
  returns the first non-`tool` row from `list_sessions_rich`, or
  `{"session_id": null}` when none.  Uses the same deny-list as
  `session.list` so sub-agent rows can't sneak in.
* `createGatewayEventHandler.handleReady` re-ordered: explicit
  `STARTUP_RESUME_ID` first (unchanged), then conditional auto-resume
  via `config.get full → display.tui_auto_resume_recent`, then the
  legacy `newSession()` fallback.  Failures of either RPC fall back
  to `newSession()` so the path is always finite.
* Default `display.tui_auto_resume_recent: False` added to
  `DEFAULT_CONFIG` in `hermes_cli/config.py` (no `_config_version`
  bump per AGENTS.md — deep-merge handles the additive key).

Tests:

* 4 new vitest cases in `createGatewayEventHandler.test.ts` cover
  every gate-and-fallback combination (env wins, config off, config
  on with hit, config on with miss).
* 3 new pytest cases for `session.most_recent` (denied row skip,
  tool-only → null, db-unavailable → null).

Validation:
  scripts/run_tests.sh tests/test_tui_gateway_server.py — 93/93.
  cd ui-tui && npm run type-check — clean; npm test --run — 393/393.

* review(copilot): fold session.most_recent errors into null + extend ConfigDisplayConfig

* review(copilot): cover RPC-rejection fallbacks in auto-resume tests
2026-04-28 16:53:38 -05:00

728 lines
28 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createGatewayEventHandler } from '../app/createGatewayEventHandler.js'
import { getOverlayState, resetOverlayState } from '../app/overlayStore.js'
import { turnController } from '../app/turnController.js'
import { getTurnState, resetTurnState } from '../app/turnStore.js'
import { patchUiState, resetUiState } from '../app/uiStore.js'
import { estimateTokensRough } from '../lib/text.js'
import type { Msg } from '../types.js'
const ref = <T>(current: T) => ({ current })
const buildCtx = (appended: Msg[]) =>
({
composer: {
dequeue: () => undefined,
queueEditRef: ref<null | number>(null),
sendQueued: vi.fn(),
setInput: vi.fn()
},
gateway: {
gw: { request: vi.fn() },
rpc: vi.fn(async () => null)
},
session: {
STARTUP_RESUME_ID: '',
colsRef: ref(80),
newSession: vi.fn(),
resetSession: vi.fn(),
resumeById: vi.fn(),
setCatalog: vi.fn()
},
submission: {
submitRef: { current: vi.fn() }
},
system: {
bellOnComplete: false,
sys: vi.fn()
},
transcript: {
appendMessage: (msg: Msg) => appended.push(msg),
panel: (title: string, sections: any[]) =>
appended.push({ kind: 'panel', panelData: { sections, title }, role: 'system', text: '' }),
setHistoryItems: vi.fn()
},
voice: {
setProcessing: vi.fn(),
setRecording: vi.fn(),
setVoiceEnabled: vi.fn()
}
}) as any
describe('createGatewayEventHandler', () => {
beforeEach(() => {
resetOverlayState()
resetUiState()
resetTurnState()
turnController.fullReset()
patchUiState({ showReasoning: true })
})
it('archives incomplete todos into transcript flow at end of turn so they scroll up', () => {
const appended: Msg[] = []
const todos = [
{ content: 'Gather ingredients', id: 'prep', status: 'completed' },
{ content: 'Boil water', id: 'boil', status: 'in_progress' },
{ content: 'Make sauce', id: 'sauce', status: 'pending' }
]
const onEvent = createGatewayEventHandler(buildCtx(appended))
onEvent({ payload: {}, type: 'message.start' } as any)
onEvent({ payload: { name: 'todo', todos, tool_id: 'todo-1' }, type: 'tool.start' } as any)
expect(getTurnState().todos).toEqual(todos)
onEvent({ payload: { text: 'Started a todo list.' }, type: 'message.complete' } as any)
const trail = appended.find(msg => msg.kind === 'trail' && msg.todos?.length)
const finalText = appended.find(msg => msg.role === 'assistant' && msg.text === 'Started a todo list.')
expect(finalText).toBeDefined()
expect(trail).toMatchObject({ kind: 'trail', role: 'system', todos, todoIncomplete: true })
// Todo archive must sit ABOVE the final assistant text so the panel
// doesn't visibly jump across the final answer at end-of-turn.
expect(appended.indexOf(trail!)).toBeLessThan(appended.indexOf(finalText!))
expect(getTurnState().todos).toEqual([])
})
it('archives completed todos into transcript flow at end of turn', () => {
const appended: Msg[] = []
const todos = [{ content: 'Serve tiny latte', id: 'serve', status: 'completed' }]
const onEvent = createGatewayEventHandler(buildCtx(appended))
onEvent({ payload: { name: 'todo', todos, tool_id: 'todo-1' }, type: 'tool.start' } as any)
onEvent({ payload: { text: 'done' }, type: 'message.complete' } as any)
expect(getTurnState().todos).toEqual([])
expect(appended).toContainEqual({
kind: 'trail',
role: 'system',
text: '',
todoCollapsedByDefault: true,
todos
})
})
it('keeps the current todo list visible when the next message starts', () => {
const appended: Msg[] = []
const todos = [{ content: 'Boil water', id: 'boil', status: 'in_progress' }]
const onEvent = createGatewayEventHandler(buildCtx(appended))
onEvent({ payload: { name: 'todo', todos, tool_id: 'todo-1' }, type: 'tool.start' } as any)
expect(getTurnState().todos).toEqual(todos)
onEvent({ payload: {}, type: 'message.start' } as any)
expect(getTurnState().todos).toEqual(todos)
})
it('clears the visible todo list when the todo tool returns an empty list', () => {
const appended: Msg[] = []
const todos = [{ content: 'Boil water', id: 'boil', status: 'in_progress' }]
const onEvent = createGatewayEventHandler(buildCtx(appended))
onEvent({ payload: { name: 'todo', todos, tool_id: 'todo-1' }, type: 'tool.start' } as any)
expect(getTurnState().todos).toEqual(todos)
onEvent({ payload: { name: 'todo', todos: [], tool_id: 'todo-1' }, type: 'tool.complete' } as any)
expect(getTurnState().todos).toEqual([])
})
it('persists completed tool rows when message.complete lands immediately after tool.complete', () => {
const appended: Msg[] = []
turnController.reasoningText = 'mapped the page'
const onEvent = createGatewayEventHandler(buildCtx(appended))
onEvent({
payload: { context: 'home page', name: 'search', tool_id: 'tool-1' },
type: 'tool.start'
} as any)
onEvent({
payload: { name: 'search', preview: 'hero cards' },
type: 'tool.progress'
} as any)
onEvent({
payload: { summary: 'done', tool_id: 'tool-1' },
type: 'tool.complete'
} as any)
onEvent({
payload: { text: 'final answer' },
type: 'message.complete'
} as any)
expect(appended).toHaveLength(2)
expect(appended[0]).toMatchObject({ kind: 'trail', role: 'system', text: '', thinking: 'mapped the page' })
expect(appended[0]?.tools).toHaveLength(1)
expect(appended[0]?.tools?.[0]).toContain('hero cards')
expect(appended[0]?.toolTokens).toBeGreaterThan(0)
expect(appended[1]).toMatchObject({ role: 'assistant', text: 'final answer' })
})
it('groups sequential completed tools into one trail when the turn completes', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
onEvent({ payload: { context: 'alpha', name: 'search_files', tool_id: 'tool-1' }, type: 'tool.start' } as any)
onEvent({
payload: { name: 'search_files', summary: 'first done', tool_id: 'tool-1' },
type: 'tool.complete'
} as any)
onEvent({ payload: { context: 'beta', name: 'read_file', tool_id: 'tool-2' }, type: 'tool.start' } as any)
onEvent({ payload: { name: 'read_file', summary: 'second done', tool_id: 'tool-2' }, type: 'tool.complete' } as any)
expect(getTurnState().streamSegments.filter(msg => msg.kind === 'trail' && msg.tools?.length)).toHaveLength(1)
expect(getTurnState().streamSegments[0]?.tools).toHaveLength(2)
expect(getTurnState().streamPendingTools).toEqual([])
onEvent({ payload: { text: '' }, type: 'message.complete' } as any)
const toolTrails = appended.filter(msg => msg.kind === 'trail' && msg.tools?.length)
expect(toolTrails).toHaveLength(1)
expect(toolTrails[0]?.tools).toHaveLength(2)
expect(toolTrails[0]?.tools?.[0]).toContain('Search Files')
expect(toolTrails[0]?.tools?.[1]).toContain('Read File')
})
it('keeps tool tokens across handler recreation mid-turn', () => {
const appended: Msg[] = []
turnController.reasoningText = 'mapped the page'
createGatewayEventHandler(buildCtx(appended))({
payload: { context: 'home page', name: 'search', tool_id: 'tool-1' },
type: 'tool.start'
} as any)
const onEvent = createGatewayEventHandler(buildCtx(appended))
onEvent({
payload: { name: 'search', preview: 'hero cards' },
type: 'tool.progress'
} as any)
onEvent({
payload: { summary: 'done', tool_id: 'tool-1' },
type: 'tool.complete'
} as any)
onEvent({
payload: { text: 'final answer' },
type: 'message.complete'
} as any)
expect(appended).toHaveLength(2)
expect(appended[0]?.tools).toHaveLength(1)
expect(appended[0]?.toolTokens).toBeGreaterThan(0)
expect(appended[1]).toMatchObject({ role: 'assistant', text: 'final answer' })
})
it('streams legacy thinking.delta into visible reasoning state', () => {
vi.useFakeTimers()
const appended: Msg[] = []
const streamed = 'short streamed reasoning'
createGatewayEventHandler(buildCtx(appended))({ payload: { text: streamed }, type: 'thinking.delta' } as any)
vi.runOnlyPendingTimers()
expect(getTurnState().reasoning).toBe(streamed)
expect(getTurnState().reasoningActive).toBe(true)
expect(getTurnState().reasoningTokens).toBe(estimateTokensRough(streamed))
vi.useRealTimers()
})
it('preserves streamed reasoning as one completed thinking panel after segment flushes', () => {
const appended: Msg[] = []
const streamed = 'first reasoning chunk\nsecond reasoning chunk'
const onEvent = createGatewayEventHandler(buildCtx(appended))
onEvent({ payload: { text: streamed }, type: 'reasoning.delta' } as any)
onEvent({ payload: { text: 'Before edit.' }, type: 'message.delta' } as any)
turnController.flushStreamingSegment()
onEvent({ payload: { text: 'final answer' }, type: 'message.complete' } as any)
expect(appended.map(msg => msg.thinking).filter(Boolean)).toEqual([streamed])
expect(appended[appended.length - 1]).toMatchObject({ role: 'assistant', text: 'final answer' })
})
it('filters spinner/status-only reasoning noise from completed thinking', () => {
const appended: Msg[] = []
const streamed = '(¬_¬) synthesizing...\nactual plan\n( ͡° ͜ʖ ͡°) pondering...\nnext step'
const onEvent = createGatewayEventHandler(buildCtx(appended))
onEvent({ payload: { text: streamed }, type: 'reasoning.delta' } as any)
onEvent({ payload: { text: 'final answer' }, type: 'message.complete' } as any)
expect(appended[0]?.thinking).toBe(streamed)
expect(appended[0]?.text).toBe('')
expect(appended[appended.length - 1]).toMatchObject({ role: 'assistant', text: 'final answer' })
})
it('ignores fallback reasoning.available when streamed reasoning already exists', () => {
const appended: Msg[] = []
const streamed = 'short streamed reasoning'
const fallback = 'x'.repeat(400)
const onEvent = createGatewayEventHandler(buildCtx(appended))
onEvent({ payload: { text: streamed }, type: 'reasoning.delta' } as any)
onEvent({ payload: { text: fallback }, type: 'reasoning.available' } as any)
onEvent({ payload: { text: 'final answer' }, type: 'message.complete' } as any)
expect(appended).toHaveLength(2)
expect(appended[0]?.thinking).toBe(streamed)
expect(appended[0]?.thinkingTokens).toBe(estimateTokensRough(streamed))
expect(appended[1]).toMatchObject({ role: 'assistant', text: 'final answer' })
})
it('uses message.complete reasoning when no streamed reasoning ref', () => {
const appended: Msg[] = []
const fromServer = 'recovered from last_reasoning'
const onEvent = createGatewayEventHandler(buildCtx(appended))
onEvent({ payload: { reasoning: fromServer, text: 'final answer' }, type: 'message.complete' } as any)
expect(appended).toHaveLength(2)
expect(appended[0]?.thinking).toBe(fromServer)
expect(appended[0]?.thinkingTokens).toBe(estimateTokensRough(fromServer))
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))
const diff = '\u001b[31m--- a/foo.ts\u001b[0m\n\u001b[32m+++ b/foo.ts\u001b[0m\n@@\n-old\n+new'
const cleaned = '--- a/foo.ts\n+++ b/foo.ts\n@@\n-old\n+new'
const block = `\`\`\`diff\n${cleaned}\n\`\`\``
// Narration → tool → tool-complete → more narration → message-complete.
// The diff MUST land between the two narration segments, not tacked
// onto the final one.
onEvent({ payload: { text: 'Editing the file' }, type: 'message.delta' } as any)
onEvent({ payload: { context: 'foo.ts', name: 'patch', tool_id: 'tool-1' }, type: 'tool.start' } as any)
onEvent({ payload: { inline_diff: diff, summary: 'patched', tool_id: 'tool-1' }, type: 'tool.complete' } as any)
// Diff is already committed to segmentMessages as its own segment.
expect(appended).toHaveLength(0)
expect(turnController.segmentMessages).toEqual([
{ role: 'assistant', text: 'Editing the file' },
{
kind: 'diff',
role: 'assistant',
text: block,
tools: [expect.stringMatching(/^Patch\("foo\.ts"\)(?: \([^)]+\))? ✓$/)]
}
])
onEvent({ payload: { text: 'patch applied' }, type: 'message.complete' } as any)
expect(appended).toHaveLength(4)
expect(appended[0]?.text).toBe('Editing the file')
expect(appended[1]).toMatchObject({ kind: 'diff', text: block })
expect(appended[1]?.tools?.[0]).toContain('Patch')
expect(appended[3]?.text).toBe('patch applied')
expect(appended[3]?.text).not.toContain('```diff')
})
it('keeps full final responses from duplicating flushed pre-diff narration', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
const diff = '--- a/foo.ts\n+++ b/foo.ts\n@@\n-old\n+new'
const block = `\`\`\`diff\n${diff}\n\`\`\``
onEvent({ payload: { text: 'Before edit. ' }, type: 'message.delta' } as any)
onEvent({ payload: { context: 'foo.ts', name: 'patch', tool_id: 'tool-1' }, type: 'tool.start' } as any)
onEvent({ payload: { inline_diff: diff, summary: 'patched', tool_id: 'tool-1' }, type: 'tool.complete' } as any)
onEvent({ payload: { text: 'After edit.' }, type: 'message.delta' } as any)
onEvent({ payload: { text: 'Before edit. After edit.' }, type: 'message.complete' } as any)
expect(appended.map(msg => msg.text.trim()).filter(Boolean)).toEqual(['Before edit.', block, 'After edit.'])
expect(appended[1]?.tools?.[0]).toContain('Patch')
})
it('drops the diff segment when the final assistant text narrates the same diff', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
const cleaned = '--- a/foo.ts\n+++ b/foo.ts\n@@\n-old\n+new'
const assistantText = `Done. Here's the inline diff:\n\n\`\`\`diff\n${cleaned}\n\`\`\``
onEvent({ payload: { inline_diff: cleaned, summary: 'patched', tool_id: 'tool-1' }, type: 'tool.complete' } as any)
onEvent({ payload: { text: assistantText }, type: 'message.complete' } as any)
// Only the final message — diff-only segment dropped so we don't
// render two stacked copies of the same patch.
expect(appended).toHaveLength(1)
expect(appended[0]?.text).toBe(assistantText)
expect((appended[0]?.text.match(/```diff/g) ?? []).length).toBe(1)
})
it('strips the CLI "┊ review diff" header from inline diff segments', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
const raw = ' \u001b[33m┊ review diff\u001b[0m\n--- a/foo.ts\n+++ b/foo.ts\n@@\n-old\n+new'
onEvent({ payload: { inline_diff: raw, summary: 'patched', tool_id: 'tool-1' }, type: 'tool.complete' } as any)
onEvent({ payload: { text: 'done' }, type: 'message.complete' } as any)
// Tool trail first, then diff segment (kind='diff'), then final narration.
expect(appended).toHaveLength(2)
expect(appended[0]?.kind).toBe('diff')
expect(appended[0]?.text).not.toContain('┊ review diff')
expect(appended[0]?.text).toContain('--- a/foo.ts')
expect(appended[0]?.tools?.[0]).toContain('Tool')
expect(appended[1]?.text).toBe('done')
})
it('drops the diff segment when assistant writes its own ```diff fence', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
const inlineDiff = '--- a/foo.ts\n+++ b/foo.ts\n@@\n-old\n+new'
const assistantText = 'Done. Clean swap:\n\n```diff\n-old\n+new\n```'
onEvent({
payload: { inline_diff: inlineDiff, summary: 'patched', tool_id: 'tool-1' },
type: 'tool.complete'
} as any)
onEvent({ payload: { text: assistantText }, type: 'message.complete' } as any)
expect(appended).toHaveLength(1)
expect(appended[0]?.text).toBe(assistantText)
expect((appended[0]?.text.match(/```diff/g) ?? []).length).toBe(1)
})
it('keeps tool trail terse when inline_diff is present', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
const diff = '--- a/foo.ts\n+++ b/foo.ts\n@@\n-old\n+new'
onEvent({
payload: { inline_diff: diff, name: 'review_diff', summary: diff, tool_id: 'tool-1' },
type: 'tool.complete'
} as any)
onEvent({ payload: { text: 'done' }, type: 'message.complete' } as any)
// Tool row is now placed before the diff, so telemetry does not render
// below the patch that came from that tool.
expect(appended).toHaveLength(2)
expect(appended[0]?.kind).toBe('diff')
expect(appended[0]?.text).toContain('```diff')
expect(appended[0]?.tools?.[0]).toContain('Review Diff')
expect(appended[0]?.tools?.[0]).not.toContain('--- a/foo.ts')
expect(appended[1]?.text).toBe('done')
expect(appended[1]?.tools ?? []).toEqual([])
})
it('shows setup panel for missing provider startup error', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
onEvent({
payload: {
message:
'agent init failed: No LLM provider configured. Run `hermes model` to select a provider, or run `hermes setup` for first-time configuration.'
},
type: 'error'
} as any)
expect(appended).toHaveLength(1)
expect(appended[0]).toMatchObject({
kind: 'panel',
panelData: { title: 'Setup Required' },
role: 'system'
})
})
it('on gateway.ready with no STARTUP_RESUME_ID and auto_resume off, forges a new session', async () => {
const appended: Msg[] = []
const newSession = vi.fn()
const resumeById = vi.fn()
const ctx = buildCtx(appended)
ctx.session.newSession = newSession
ctx.session.resumeById = resumeById
ctx.session.STARTUP_RESUME_ID = ''
ctx.gateway.rpc = vi.fn(async (method: string) => {
if (method === 'config.get') {
return { config: { display: { tui_auto_resume_recent: false } } }
}
return null
})
createGatewayEventHandler(ctx)({ payload: {}, type: 'gateway.ready' } as any)
await vi.waitFor(() => expect(newSession).toHaveBeenCalled())
expect(resumeById).not.toHaveBeenCalled()
})
it('on gateway.ready with auto_resume on and a recent session, resumes it', async () => {
const appended: Msg[] = []
const newSession = vi.fn()
const resumeById = vi.fn()
const ctx = buildCtx(appended)
ctx.session.newSession = newSession
ctx.session.resumeById = resumeById
ctx.session.STARTUP_RESUME_ID = ''
ctx.gateway.rpc = vi.fn(async (method: string) => {
if (method === 'config.get') {
return { config: { display: { tui_auto_resume_recent: true } } }
}
if (method === 'session.most_recent') {
return { session_id: 'sess-most-recent' }
}
return null
})
createGatewayEventHandler(ctx)({ payload: {}, type: 'gateway.ready' } as any)
await vi.waitFor(() => expect(resumeById).toHaveBeenCalledWith('sess-most-recent'))
expect(newSession).not.toHaveBeenCalled()
})
it('on gateway.ready with auto_resume on but no eligible session, falls back to new', async () => {
const appended: Msg[] = []
const newSession = vi.fn()
const resumeById = vi.fn()
const ctx = buildCtx(appended)
ctx.session.newSession = newSession
ctx.session.resumeById = resumeById
ctx.session.STARTUP_RESUME_ID = ''
ctx.gateway.rpc = vi.fn(async (method: string) => {
if (method === 'config.get') {
return { config: { display: { tui_auto_resume_recent: true } } }
}
if (method === 'session.most_recent') {
return { session_id: null }
}
return null
})
createGatewayEventHandler(ctx)({ payload: {}, type: 'gateway.ready' } as any)
await vi.waitFor(() => expect(newSession).toHaveBeenCalled())
expect(resumeById).not.toHaveBeenCalled()
})
it('on gateway.ready when config.get rejects, falls back to new session', async () => {
const appended: Msg[] = []
const newSession = vi.fn()
const resumeById = vi.fn()
const ctx = buildCtx(appended)
ctx.session.newSession = newSession
ctx.session.resumeById = resumeById
ctx.session.STARTUP_RESUME_ID = ''
ctx.gateway.rpc = vi.fn(async (method: string) => {
if (method === 'config.get') {
throw new Error('gateway timeout')
}
return null
})
createGatewayEventHandler(ctx)({ payload: {}, type: 'gateway.ready' } as any)
await vi.waitFor(() => expect(newSession).toHaveBeenCalled())
expect(resumeById).not.toHaveBeenCalled()
})
it('on gateway.ready when session.most_recent rejects, falls back to new session', async () => {
const appended: Msg[] = []
const newSession = vi.fn()
const resumeById = vi.fn()
const ctx = buildCtx(appended)
ctx.session.newSession = newSession
ctx.session.resumeById = resumeById
ctx.session.STARTUP_RESUME_ID = ''
ctx.gateway.rpc = vi.fn(async (method: string) => {
if (method === 'config.get') {
return { config: { display: { tui_auto_resume_recent: true } } }
}
if (method === 'session.most_recent') {
throw new Error('db locked')
}
return null
})
createGatewayEventHandler(ctx)({ payload: {}, type: 'gateway.ready' } as any)
await vi.waitFor(() => expect(newSession).toHaveBeenCalled())
expect(resumeById).not.toHaveBeenCalled()
})
it('on gateway.ready with STARTUP_RESUME_ID set, the env wins over config auto_resume', async () => {
const appended: Msg[] = []
const newSession = vi.fn()
const resumeById = vi.fn()
const ctx = buildCtx(appended)
ctx.session.newSession = newSession
ctx.session.resumeById = resumeById
ctx.session.STARTUP_RESUME_ID = 'env-explicit'
ctx.gateway.rpc = vi.fn(async () => ({
config: { display: { tui_auto_resume_recent: true } }
}))
createGatewayEventHandler(ctx)({ payload: {}, type: 'gateway.ready' } as any)
await vi.waitFor(() => expect(resumeById).toHaveBeenCalledWith('env-explicit'))
expect(newSession).not.toHaveBeenCalled()
})
it('keeps gateway noise informational and approval out of Activity', async () => {
const appended: Msg[] = []
const ctx = buildCtx(appended)
ctx.gateway.rpc = vi.fn(async () => {
throw new Error('cold start')
})
const onEvent = createGatewayEventHandler(ctx)
onEvent({ payload: { line: 'Traceback: noisy but non-fatal' }, type: 'gateway.stderr' } as any)
onEvent({ payload: { preview: 'bad framing' }, type: 'gateway.protocol_error' } as any)
onEvent({
payload: { command: 'rm -rf /tmp/nope', description: 'dangerous command' },
type: 'approval.request'
} as any)
onEvent({ payload: {}, type: 'gateway.ready' } as any)
await Promise.resolve()
await Promise.resolve()
expect(getOverlayState().approval).toMatchObject({ description: 'dangerous command' })
expect(getTurnState().activity).toMatchObject([
{ text: 'Traceback: noisy but non-fatal', tone: 'info' },
{ text: 'protocol noise detected · /logs to inspect', tone: 'info' },
{ text: 'protocol noise: bad framing', tone: 'info' },
{ text: 'command catalog unavailable: cold start', tone: 'info' }
])
})
it('still surfaces terminal turn failures as errors', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
onEvent({ payload: { message: 'boom' }, type: 'error' } as any)
expect(getTurnState().activity).toMatchObject([{ text: 'boom', tone: 'error' }])
})
it('drops stale reasoning/tool/todos events after ctrl-c until the next message starts', () => {
// Repro for the discord report: ctrl-c interrupts, but late reasoning/tool
// events from the still-winding-down agent loop kept populating the UI for
// ~1s, making it look like the interrupt had been ignored.
//
// Fake timers because `interruptTurn` schedules a real setTimeout for
// its cooldown — without flushing it inside this test, the timeout
// can fire later and mutate uiStore/turnState during unrelated tests
// (cross-file flake).
vi.useFakeTimers()
try {
const appended: Msg[] = []
const ctx = buildCtx(appended)
ctx.gateway.gw.request = vi.fn(async () => ({ status: 'interrupted' }))
const onEvent = createGatewayEventHandler(ctx)
patchUiState({ sid: 'sess-1' })
onEvent({ payload: {}, type: 'message.start' } as any)
onEvent({
payload: {
context: 'pre',
name: 'search',
todos: [{ content: 'pre-interrupt', id: 'todo-1', status: 'pending' }],
tool_id: 't-1'
},
type: 'tool.start'
} as any)
// Pre-interrupt todos should land in turn state.
expect(getTurnState().todos).toEqual([
{ content: 'pre-interrupt', id: 'todo-1', status: 'pending' }
])
turnController.interruptTurn({
appendMessage: (msg: Msg) => appended.push(msg),
gw: ctx.gateway.gw,
sid: 'sess-1',
sys: ctx.system.sys
})
onEvent({ payload: { text: 'still thinking…' }, type: 'reasoning.delta' } as any)
// Post-interrupt tool.start with a todos payload — must NOT mutate todos.
onEvent({
payload: {
context: 'post',
name: 'browser',
todos: [{ content: 'late ghost', id: 'todo-ghost', status: 'pending' }],
tool_id: 't-2'
},
type: 'tool.start'
} as any)
// Late tool.generating must NOT push a 'drafting …' line into the trail.
const trailBefore = getTurnState().turnTrail.length
onEvent({ payload: { name: 'browser' }, type: 'tool.generating' } as any)
expect(getTurnState().turnTrail.length).toBe(trailBefore)
onEvent({ payload: { name: 'browser', preview: 'loading' }, type: 'tool.progress' } as any)
onEvent({ payload: { summary: 'done', tool_id: 't-2' }, type: 'tool.complete' } as any)
onEvent({ payload: { text: 'late chunk' }, type: 'message.delta' } as any)
expect(getTurnState().tools).toEqual([])
expect(turnController.reasoningText).toBe('')
expect(turnController.bufRef).toBe('')
expect(getTurnState().streamPendingTools).toEqual([])
expect(getTurnState().streamSegments).toEqual([])
// Stale post-interrupt todos must not have leaked through.
// (This test does not assert that pre-interrupt todos are cleared —
// current interrupt path leaves them visible until the next message.)
expect(getTurnState().todos.find(t => t.content === 'late ghost')).toBeUndefined()
onEvent({ payload: {}, type: 'message.start' } as any)
onEvent({ payload: { text: 'fresh' }, type: 'reasoning.delta' } as any)
expect(turnController.reasoningText).toBe('fresh')
} finally {
// Drain pending fake timers BEFORE restoring real timers so a mid-
// test assertion failure can't leak the interrupt-cooldown setTimeout
// across test files (the original Copilot concern).
vi.runAllTimers()
vi.useRealTimers()
}
})
})