test: remove low-value chat session and picker tests (#38417)

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
OrcaWin
2026-11-04 23:02:33 -08:00
committed by GitHub
co-authored by OrcaWin
parent f0e7eaa070
commit fd04f15026
11 changed files with 10 additions and 3205 deletions
-1
View File
@@ -292,7 +292,6 @@
1 test-no-real-time-wait:src/renderer/src/components/editor/DiagramViewerModal.test.tsx
1 test-no-real-time-wait:src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-stale-analysis-work-budget.test.tsx
1 test-no-real-time-wait:src/renderer/src/components/native-chat/NativeChatModelCombobox.test.tsx
1 test-no-real-time-wait:src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.focus-return.test.tsx
1 test-no-real-time-wait:src/renderer/src/components/native-chat/native-chat-composer-draft-lifecycle.test.tsx
1 test-no-real-time-wait:src/renderer/src/components/native-chat/structured-chat-menu-lifetime.test.tsx
1 test-no-real-time-wait:src/renderer/src/components/native-chat/use-structured-agent-session-options.learned-default.test.tsx
@@ -7343,14 +7343,11 @@
"src/renderer/src/components/native-chat/NativeChatRewindAction.target.test.tsx": 3816,
"src/renderer/src/components/native-chat/NativeChatRewindAction.test.tsx": 2795,
"src/renderer/src/components/native-chat/NativeChatSelectionQuote.test.tsx": 2370,
"src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.focus-return.test.tsx": 2767,
"src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx": 1384,
"src/renderer/src/components/native-chat/NativeChatStructuredSession.approval-and-start-failure.test.tsx": 2922,
"src/renderer/src/components/native-chat/NativeChatStructuredSession.availability-notice.test.tsx": 2764,
"src/renderer/src/components/native-chat/NativeChatStructuredSession.goal-dock.test.tsx": 3180,
"src/renderer/src/components/native-chat/NativeChatStructuredSession.history-read-state.test.tsx": 4919,
"src/renderer/src/components/native-chat/NativeChatStructuredSession.host-outage.test.tsx": 3552,
"src/renderer/src/components/native-chat/NativeChatStructuredSession.launch-lifecycle.test.tsx": 5298,
"src/renderer/src/components/native-chat/NativeChatStructuredSession.notice-card.test.tsx": 2911,
"src/renderer/src/components/native-chat/NativeChatStructuredSession.refusal-notices.test.tsx": 3014,
"src/renderer/src/components/native-chat/NativeChatStructuredSession.retained-draft.test.tsx": 3736,
@@ -1,452 +0,0 @@
// @vitest-environment happy-dom
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { useState } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string, values?: Record<string, string | number>) =>
values
? Object.entries(values).reduce(
(text, [name, value]) => text.replaceAll(`{{${name}}}`, String(value)),
fallback
)
: fallback
}))
import { TooltipProvider } from '@/components/ui/tooltip'
import type {
SessionOptionDescriptor,
SessionOptionSetResult,
SessionOptionValue
} from '../../../../shared/native-chat/input/native-chat-session-options'
import type { NativeChatOptionPickerRequest } from './native-chat-composer-types'
import { NativeChatSessionOptionPickers } from './NativeChatSessionOptionPickers'
import { CODEX_SESSION_OPTION_CATALOG } from '../../../../shared/agent-session/options/agent-session-option-catalog-claude-codex'
import {
applyStructuredAgentSessionOptions,
commitStructuredAgentSessionOption,
createStructuredAgentSessionOptionState
} from '../../../../shared/agent-session/options/structured-agent-session-options'
import {
structuredAgentSessionMessageSelection,
structuredAgentSessionShownSnapshot
} from '../../../../shared/agent-session/options/structured-agent-session-option-view'
const settle = (): Promise<void> =>
act(async () => new Promise((resolve) => setTimeout(resolve, 30)))
it('keeps pointer, ArrowDown and requested-open on the same disabled-placeholder boundary', async () => {
const placeholder: SessionOptionDescriptor = {
id: 'model',
label: 'Model',
category: 'model',
kind: { type: 'select', choices: [] },
valueSource: 'unknown',
transport: 'agent-session',
settable: false
}
const consume = vi.fn()
const view = render(
<TooltipProvider>
<NativeChatSessionOptionPickers
surface={surface}
snapshot={[placeholder]}
isWorking={false}
/>
</TooltipProvider>
)
const trigger = screen.getByRole('button', { name: 'Model' })
expect(trigger).toHaveProperty('disabled', true)
fireEvent.click(trigger)
fireEvent.keyDown(trigger, { key: 'ArrowDown' })
view.rerender(
<TooltipProvider>
<NativeChatSessionOptionPickers
surface={surface}
snapshot={[placeholder]}
isWorking={false}
pickerRequest={{ id: 'model', consume }}
/>
</TooltipProvider>
)
await settle()
expect(consume).toHaveBeenCalledOnce()
expect(screen.queryByRole('dialog')).toBeNull()
view.rerender(
<TooltipProvider>
<NativeChatSessionOptionPickers
surface={surface}
snapshot={[placeholder, effort]}
isWorking={false}
/>
</TooltipProvider>
)
expect(trigger).toHaveProperty('disabled', false)
fireEvent.keyDown(trigger, { key: 'ArrowDown' })
await settle()
expect(screen.getByRole('dialog')).not.toBeNull()
})
const surface = {
getSnapshot: vi.fn(() => []),
setOption: vi.fn((_id: string, _value: SessionOptionValue): Promise<SessionOptionSetResult> =>
Promise.resolve({ snapshot: [] })
),
invokeAction: vi.fn(() => Promise.resolve({ snapshot: [] })),
subscribe: vi.fn(() => vi.fn())
}
const model: SessionOptionDescriptor = {
id: 'model',
label: 'Model',
category: 'model',
kind: {
type: 'select',
currentValue: 'opus',
choices: [
{ value: 'opus', label: 'Opus 4.8' },
{ value: 'sonnet', label: 'Sonnet 5' }
]
},
valueSource: 'applied',
transport: 'catalog',
settable: true
}
const effort: SessionOptionDescriptor = {
id: 'effort',
label: 'Effort',
category: 'thought_level',
kind: {
type: 'select',
currentValue: 'low',
choices: [
{ value: 'low', label: 'Low' },
{ value: 'high', label: 'High' }
]
},
valueSource: 'applied',
transport: 'catalog',
settable: true
}
const speed: SessionOptionDescriptor = {
id: 'serviceTier',
label: 'Speed',
category: 'mode',
kind: {
type: 'select',
currentValue: 'default',
choices: [
{ value: 'default', label: 'Standard' },
{ value: 'fast', label: 'Fast' }
]
},
valueSource: 'applied',
transport: 'catalog',
settable: true
}
const fast: SessionOptionDescriptor = {
id: 'fastMode',
label: 'Fast mode',
category: 'mode',
kind: { type: 'boolean', currentValue: false },
valueSource: 'applied',
transport: 'catalog',
settable: true
}
async function renderComposer(
snapshot: SessionOptionDescriptor[],
pickerRequest: NativeChatOptionPickerRequest | null = null
): Promise<HTMLElement> {
const messageBox = document.createElement('textarea')
render(
<TooltipProvider>
<NativeChatSessionOptionPickers
surface={surface}
snapshot={snapshot}
isWorking={false}
pickerRequest={pickerRequest}
focusComposer={() => messageBox.focus()}
/>
</TooltipProvider>
)
document.body.append(messageBox)
await settle()
return messageBox
}
/** A slash command's request held by the session, which drops it when the picker consumes it. */
function Session(props: {
requestId: string
composerShown?: boolean
surfaceShown?: boolean
}): React.JSX.Element {
const [request, setRequest] = useState<NativeChatOptionPickerRequest | null>({
id: props.requestId,
consume: () => setRequest(null)
})
return (
<TooltipProvider>
{props.composerShown === false ? null : (
<NativeChatSessionOptionPickers
surface={props.surfaceShown === false ? null : surface}
snapshot={[model, effort]}
isWorking={false}
pickerRequest={request}
/>
)}
</TooltipProvider>
)
}
const pill = (): HTMLElement => screen.getByRole('button', { name: /^Model / })
const openPicker = async (): Promise<HTMLElement> => {
const trigger = pill()
fireEvent.click(trigger)
await settle()
return trigger
}
const pressEscape = async (): Promise<void> => {
fireEvent.keyDown(document.activeElement ?? document.body, { key: 'Escape' })
await settle()
}
afterEach(() => {
cleanup()
document.body.replaceChildren()
vi.clearAllMocks()
})
describe('session option picker focus', () => {
it.each(['click', 'ArrowDown'] as const)('opens on %s with the search focused', async (how) => {
await renderComposer([model, effort])
const trigger = pill()
if (how === 'click') {
fireEvent.click(trigger)
} else {
trigger.focus()
fireEvent.keyDown(trigger, { key: 'ArrowDown' })
}
await settle()
expect(document.activeElement).toBe(screen.getByRole('combobox'))
})
it('opens on the option a slash command asked for, once', async () => {
await renderComposer([model, effort], { id: 'effort', consume: () => {} })
expect(document.activeElement).toBe(screen.getByRole('radio', { name: 'Low' }))
// The request is spent: opening by hand afterwards focuses the search as usual.
await pressEscape()
await openPicker()
expect(document.activeElement).toBe(screen.getByRole('combobox'))
})
it('stays open across a model, a speed and a switch pick, and closes into the message box on an effort pick', async () => {
const messageBox = await renderComposer([model, effort, speed, fast])
await openPicker()
fireEvent.click(screen.getByRole('option', { name: /Sonnet 5/ }))
await settle()
fireEvent.click(screen.getByRole('radio', { name: 'Fast' }))
await settle()
fireEvent.click(screen.getByRole('switch', { name: 'Fast mode' }))
await settle()
expect(screen.getByRole('dialog').contains(document.activeElement)).toBe(true)
fireEvent.click(screen.getByRole('radio', { name: 'High' }))
await settle()
expect(surface.setOption.mock.calls).toEqual([
['model', 'sonnet'],
['serviceTier', 'fast'],
['fastMode', true],
['effort', 'high']
])
expect(screen.queryByRole('dialog')).toBeNull()
expect(document.activeElement).toBe(messageBox)
})
it('captures an explicit click on the displayed Codex default while passive display stays sparse', async () => {
let state = applyStructuredAgentSessionOptions(
createStructuredAgentSessionOptionState('codex', CODEX_SESSION_OPTION_CATALOG),
CODEX_SESSION_OPTION_CATALOG,
{
models: [
{
id: 'gpt-host',
label: 'GPT Host',
isDefault: true,
defaultEffort: 'low',
efforts: [
{ value: 'low', label: 'Low' },
{ value: 'high', label: 'High' }
]
}
],
listedModelIds: ['gpt-host'],
current: { model: 'gpt-host', acceptedInputSources: { model: 'saved' }, defaulted: [] }
}
)
surface.setOption.mockImplementationOnce(async (id: string, value: SessionOptionValue) => {
state = commitStructuredAgentSessionOption(state, id, String(value))
return { snapshot: structuredAgentSessionShownSnapshot(state) }
})
await renderComposer(structuredAgentSessionShownSnapshot(state))
expect(structuredAgentSessionMessageSelection(state)).toEqual({ model: 'gpt-host' })
await openPicker()
fireEvent.click(screen.getByRole('radio', { name: 'Low' }))
await settle()
expect(surface.setOption).toHaveBeenCalledExactlyOnceWith('effort', 'low')
expect(structuredAgentSessionMessageSelection(state)).toEqual({
model: 'gpt-host',
effort: 'low'
})
})
it('closes on a press of the effort level already in use, without a write', async () => {
const messageBox = await renderComposer([model, effort])
await openPicker()
fireEvent.click(screen.getByRole('radio', { name: 'Low' }))
await settle()
expect(surface.setOption).not.toHaveBeenCalled()
expect(screen.queryByRole('dialog')).toBeNull()
expect(document.activeElement).toBe(messageBox)
})
// A level the picked model lacks is removed, and a removed element takes focus with it.
it('moves focus from an option to the search when a model row is pressed', async () => {
await renderComposer([model, effort])
await openPicker()
screen.getByRole('radio', { name: 'High' }).focus()
fireEvent.mouseDown(screen.getByRole('option', { name: /Sonnet 5/ }))
expect(document.activeElement).toBe(screen.getByRole('combobox'))
})
// The tooltip is the top layer, so Radix gives it the first Escape; one press still closes both.
it('closes on one Escape from a level whose tooltip is showing', async () => {
const levels: SessionOptionDescriptor = {
...effort,
kind: {
type: 'select',
currentValue: 'xhigh',
choices: [
{ value: 'medium', label: 'Medium' },
{ value: 'xhigh', label: 'Extra high' }
]
}
}
await renderComposer([model, levels])
const trigger = await openPicker()
const level = screen.getByRole('radio', { name: 'XHigh' })
// The tooltip's own open/closed state must not replace the one the level is styled by.
expect(level.getAttribute('data-state')).toBe('on')
level.focus()
await settle()
expect(screen.getByRole('tooltip')).not.toBeNull()
await pressEscape()
expect(screen.queryByRole('dialog')).toBeNull()
expect(document.activeElement).toBe(trigger)
})
it('leaves focus on the pill when the picker is dismissed instead of used', async () => {
await renderComposer([model, effort])
const trigger = await openPicker()
await pressEscape()
expect(surface.setOption).not.toHaveBeenCalled()
expect(document.activeElement).toBe(trigger)
})
it('lands focus in the message box when the pill closes the picker after a pick', async () => {
const messageBox = await renderComposer([model, effort])
const trigger = await openPicker()
fireEvent.click(screen.getByRole('option', { name: /Sonnet 5/ }))
await settle()
// What a press is to the browser: pointerdown, focus on the button, then the click.
fireEvent.pointerDown(trigger)
trigger.focus()
fireEvent.click(trigger)
await settle()
expect(screen.queryByRole('dialog')).toBeNull()
expect(document.activeElement).toBe(messageBox)
})
it('leaves focus where a click outside put it, even after a pick', async () => {
await renderComposer([model, effort])
const elsewhere = document.createElement('input')
document.body.append(elsewhere)
await openPicker()
fireEvent.click(screen.getByRole('option', { name: /Sonnet 5/ }))
await settle()
// Radix waits for the click that follows an outside pointerdown before it dismisses.
fireEvent.pointerDown(elsewhere)
elsewhere.focus()
fireEvent.click(elsewhere)
await settle()
expect(screen.queryByRole('dialog')).toBeNull()
expect(document.activeElement).toBe(elsewhere)
})
it('opens once for a request, and comes back closed after the pill went away', async () => {
const { rerender } = render(<Session requestId="model" />)
await settle()
expect(screen.queryByRole('dialog')).not.toBeNull()
rerender(<Session requestId="model" surfaceShown={false} />)
await settle()
expect(screen.queryByRole('button', { name: /^Model / })).toBeNull()
rerender(<Session requestId="model" />)
await settle()
expect(screen.queryByRole('dialog')).toBeNull()
})
it('focuses the effort for /effort once, not again after a prompt card', async () => {
const { rerender } = render(<Session requestId="effort" />)
await settle()
expect(document.activeElement).toBe(screen.getByRole('radio', { name: 'Low' }))
rerender(<Session requestId="effort" composerShown={false} />)
await settle()
rerender(<Session requestId="effort" />)
await settle()
expect(screen.queryByRole('dialog')).toBeNull()
await openPicker()
expect(document.activeElement).toBe(screen.getByRole('combobox'))
})
it('shows a tooltip on the pill only for what the pill cannot show', async () => {
await renderComposer([model, effort])
fireEvent.pointerMove(pill(), { pointerType: 'mouse' })
pill().focus()
await settle()
expect(screen.queryByRole('tooltip')).toBeNull()
cleanup()
await renderComposer([
{ ...model, settable: false, disabledReason: 'set-when-session-starts' },
effort
])
pill().focus()
await settle()
expect(screen.getByRole('tooltip').textContent).toBe('Set when the session starts.')
})
})
@@ -1,31 +1,10 @@
// @vitest-environment happy-dom
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { useState } from 'react'
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { OMP_SESSION_OPTION_CATALOG } from '../../../../shared/agent-session/options/agent-session-option-catalog-omp'
import type { AcpThinkingOptionsFixtureModule } from '../../../../shared/acp-thinking-options.test-fixture-contract'
import { structuredAgentSessionOptionView } from '../../../../shared/agent-session/options/structured-agent-session-option-view'
import {
applyStructuredAgentSessionModelCatalog,
applyStructuredAgentSessionOptions,
createStructuredAgentSessionOptionState,
structuredAgentSessionOptionPicks
} from '../../../../shared/agent-session/options/structured-agent-session-options'
import {
canSetStructuredAgentSessionOption,
structuredAgentSessionOptionSnapshot
} from '../../../../shared/agent-session/options/structured-agent-session-option-snapshot'
import type { SessionOptionDescriptor } from '../../../../shared/native-chat/input/native-chat-session-options'
import { RuntimeRpcCallError } from '@/runtime/runtime-rpc-result'
import { act, cleanup, render, screen, waitFor } from '@testing-library/react'
let recordedOmpOptions: AcpThinkingOptionsFixtureModule['recordedOmpOptions']
beforeAll(async () => {
const fixtures = await vi.importActual<AcpThinkingOptionsFixtureModule>(
'../../../../main/acp/acp-thinking-options.test-fixture'
)
recordedOmpOptions = fixtures.recordedOmpOptions
})
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SessionOptionDescriptor } from '../../../../shared/native-chat/input/native-chat-session-options'
const toastError = vi.hoisted(() => vi.fn())
vi.mock('sonner', () => ({ toast: { error: toastError } }))
@@ -54,7 +33,7 @@ vi.mock('@/components/ui/tooltip', () => ({
TooltipContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>
}))
// Keep mocked rows mounted for assertions; focus-return tests exercise real dismissal and focus.
// Keep mocked rows mounted for assertions.
vi.mock('@/components/ui/popover', () => ({
Popover: ({ children, open }: { children: React.ReactNode; open?: boolean }) => (
<div data-testid="picker-root" data-open={open ? 'true' : 'false'}>
@@ -87,7 +66,6 @@ vi.mock('@/components/ui/popover', () => ({
}))
import { NativeChatSessionOptionPickers } from './NativeChatSessionOptionPickers'
import type { NativeChatOptionPickerRequest } from './native-chat-composer-types'
const surface = {
getSnapshot: vi.fn(() => []),
@@ -132,568 +110,12 @@ const effort: SessionOptionDescriptor = {
}
/** Only selects can truthfully have no current value; booleans require a baseline. */
const unknownEffort: SessionOptionDescriptor = {
...effort,
kind: { type: 'select', choices: EFFORT_CHOICES },
valueSource: 'unknown'
}
const fast: SessionOptionDescriptor = {
id: 'fastMode',
label: 'Fast mode',
category: 'mode',
kind: { type: 'boolean', currentValue: true },
valueSource: 'applied',
transport: 'catalog',
settable: true
}
const settle = (): Promise<void> => act(async () => {})
const pill = (name: string): HTMLElement => screen.getByRole('button', { name })
const isOpen = (trigger: HTMLElement): boolean =>
trigger.closest('[data-testid="picker-root"]')?.getAttribute('data-open') === 'true'
const effortLevels = (): HTMLElement[] =>
within(screen.getByRole('radiogroup', { name: 'Effort' })).getAllByRole('radio')
afterEach(() => cleanup())
describe('NativeChatSessionOptionPickers', () => {
it.each(['model', 'effort'])(
'opens the one picker when a slash command asks for the %s',
async (id) => {
const demandModelCatalog = vi.fn()
const demanding = { ...surface, demandModelCatalog }
const { rerender } = render(
<NativeChatSessionOptionPickers
surface={demanding}
snapshot={[model(), effort]}
isWorking={false}
pickerRequest={null}
/>
)
expect(isOpen(pill('Model Opus 4.8, Effort High'))).toBe(false)
expect(demandModelCatalog).not.toHaveBeenCalled()
rerender(
<NativeChatSessionOptionPickers
surface={demanding}
snapshot={[model(), effort]}
isWorking={false}
pickerRequest={{ id, consume: () => {} }}
/>
)
await waitFor(() => expect(isOpen(pill('Model Opus 4.8, Effort High'))).toBe(true))
expect(demandModelCatalog).toHaveBeenCalledTimes(1)
}
)
it('keeps the picker a slash command opened when the next model lacks that option', () => {
const request = { id: 'effort', consume: () => {} }
const { rerender } = render(
<NativeChatSessionOptionPickers
surface={surface}
snapshot={[model(), effort]}
isWorking={false}
pickerRequest={request}
/>
)
expect(isOpen(pill('Model Opus 4.8, Effort High'))).toBe(true)
rerender(
<NativeChatSessionOptionPickers
surface={surface}
snapshot={[model()]}
isWorking={false}
pickerRequest={request}
/>
)
expect(isOpen(pill('Model Opus 4.8'))).toBe(true)
expect(effortLevels().every((level) => level.hasAttribute('disabled'))).toBe(true)
})
// Consume session-held requests even across composer remounts so prompts cannot reopen the picker.
it.each<[string, { composerShown?: boolean; snapshot?: SessionOptionDescriptor[] }]>([
['the composer comes back after a prompt card', { composerShown: false }],
['the session reports its model again', { snapshot: [] }]
])('opens once for a request, not again when %s', async (_case, gone) => {
function Session(props: {
composerShown?: boolean
snapshot?: SessionOptionDescriptor[]
}): React.JSX.Element | null {
const [request, setRequest] = useState<NativeChatOptionPickerRequest | null>({
id: 'model',
consume: () => setRequest(null)
})
return props.composerShown === false ? null : (
<NativeChatSessionOptionPickers
surface={surface}
snapshot={props.snapshot ?? [model(), effort]}
isWorking={false}
pickerRequest={request}
/>
)
}
const { rerender } = render(<Session />)
await waitFor(() => expect(isOpen(pill('Model Opus 4.8, Effort High'))).toBe(true))
rerender(<Session {...gone} />)
expect(screen.queryByRole('button', { name: /^Model/ })).toBeNull()
rerender(<Session />)
expect(isOpen(pill('Model Opus 4.8, Effort High'))).toBe(false)
})
it('sends a picked model to the session surface', async () => {
const setOption = vi.fn().mockResolvedValue({ snapshot: [] })
render(
<NativeChatSessionOptionPickers
surface={{ ...surface, setOption }}
snapshot={[model()]}
isWorking={false}
/>
)
screen.getByRole('option', { name: 'Sonnet 5' }).click()
await waitFor(() => expect(setOption).toHaveBeenCalledExactlyOnceWith('model', 'sonnet'))
})
it('opens one picker upwards from the pill, anchored at its right edge', () => {
render(
<NativeChatSessionOptionPickers
surface={surface}
snapshot={[model(), effort]}
isWorking={false}
/>
)
const content = screen.getByTestId('picker-content')
expect(content.getAttribute('data-side')).toBe('top')
expect(content.getAttribute('data-align')).toBe('end')
expect(content.getAttribute('data-collision-padding')).toBe('8')
})
it('shows the model and its option values in one pill', () => {
const { rerender } = render(
<NativeChatSessionOptionPickers
surface={surface}
snapshot={[model(), effort, fast]}
isWorking={false}
/>
)
expect(pill('Model Opus 4.8, Effort High · Fast').textContent).toBe('Opus 4.8High · Fast')
rerender(
<NativeChatSessionOptionPickers
surface={surface}
snapshot={[model(), unknownEffort]}
isWorking={false}
/>
)
expect(pill('Model Opus 4.8').textContent).toBe('Opus 4.8')
rerender(
<NativeChatSessionOptionPickers
surface={surface}
snapshot={[model(), fast]}
isWorking={false}
/>
)
expect(pill('Model Opus 4.8, Session options Fast').textContent).toBe('Opus 4.8Fast')
})
it('keeps terminal command pickers disabled while the agent is working', () => {
render(
<NativeChatSessionOptionPickers surface={surface} snapshot={[model(), effort]} isWorking />
)
expect(pill('Model Opus 4.8, Effort High').parentElement?.getAttribute('data-disabled')).toBe(
'true'
)
})
it('renders a session with no reported model without an empty model control', () => {
const state = applyStructuredAgentSessionOptions(
createStructuredAgentSessionOptionState('omp'),
OMP_SESSION_OPTION_CATALOG,
{ models: [], current: { effort: 'off', confirmed: ['effort'] } }
)
render(
<NativeChatSessionOptionPickers
surface={surface}
snapshot={structuredAgentSessionOptionSnapshot(state)}
isWorking={false}
/>
)
expect(screen.queryByRole('button', { name: /^Model/ })).toBeNull()
expect(screen.queryAllByRole('button', { name: '' })).toEqual([])
})
it('names the control once when the model is unknown', () => {
render(
<NativeChatSessionOptionPickers
surface={surface}
snapshot={[model({ kind: { type: 'select', choices: [] }, valueSource: 'unknown' })]}
isWorking={false}
/>
)
expect(pill('Model').textContent).toBe('Model')
})
// Terminal dispatch lacks confirmation; structured per-turn reporting makes the same hedge unnecessary.
it('hedges a dispatched value the terminal transport produced', () => {
render(
<NativeChatSessionOptionPickers
surface={surface}
snapshot={[model({ valueSource: 'dispatched', transport: 'catalog' })]}
isWorking={false}
/>
)
expect(screen.getAllByText('Sent to the agent — not confirmed').length).toBeGreaterThan(0)
})
it('does not hedge a dispatched value the structured transport produced', () => {
render(
<NativeChatSessionOptionPickers
surface={surface}
snapshot={[model({ valueSource: 'dispatched', transport: 'agent-session' })]}
isWorking={false}
/>
)
expect(screen.queryByText(/not confirmed/)).toBeNull()
})
it.each(['catalog'] as const)(
'does not hedge a reported value on the %s transport',
(transport) => {
render(
<NativeChatSessionOptionPickers
surface={surface}
snapshot={[model({ valueSource: 'reported', transport })]}
isWorking={false}
/>
)
expect(screen.queryByText(/not confirmed/)).toBeNull()
}
)
it('hedges the pill for a dispatched option as it does for the model', () => {
render(
<NativeChatSessionOptionPickers
surface={surface}
snapshot={[
model(),
{
id: 'thinking',
label: 'Thinking',
category: 'mode',
kind: { type: 'boolean', currentValue: true },
valueSource: 'dispatched',
transport: 'catalog',
settable: true
}
]}
isWorking={false}
/>
)
expect(screen.getAllByText('Sent to the agent — not confirmed').length).toBeGreaterThan(0)
})
it('renders agent-picker routes as one action instead of model rows', async () => {
const invokeAction = vi.fn().mockResolvedValue({ snapshot: [] })
const liveSurface = { ...surface, invokeAction }
render(
<NativeChatSessionOptionPickers
surface={liveSurface}
snapshot={[
model({
kind: {
type: 'select',
choices: [
{ value: 'gpt-5.5', label: 'GPT-5.5' },
{ value: 'gpt-5.2-codex', label: 'GPT-5.2 Codex' }
]
},
valueSource: 'unknown',
action: { type: 'agent-picker' }
})
]}
isWorking={false}
/>
)
expect(screen.getByRole('button', { name: 'Choose in agent picker…' })).not.toBeNull()
expect(screen.queryByText('GPT-5.5')).toBeNull()
expect(screen.queryByText('GPT-5.2 Codex')).toBeNull()
screen.getByRole('button', { name: 'Choose in agent picker…' }).click()
await waitFor(() => expect(invokeAction).toHaveBeenCalledWith('model'))
})
it.each([
[
"the table's words for a host's refusal, not its message",
new RuntimeRpcCallError({
id: 'request-1',
ok: false,
error: {
code: 'runtime_error',
message: 'agent_session_journal_unreadable',
data: {
refusal: {
code: 'agent_session_journal_unreadable',
details: { reason: 'journalCorrupt' }
}
}
},
_meta: { runtimeId: 'runtime-1' }
}),
"Unable to load this chat. The setting wasn't changed."
],
[
"a local surface's own sentence",
new Error('The terminal did not accept the command.'),
'The terminal did not accept the command.'
]
])('describes a failed option change with %s', async (_label, error, description) => {
toastError.mockClear()
const invokeAction = vi.fn().mockRejectedValue(error)
render(
<NativeChatSessionOptionPickers
surface={{ ...surface, invokeAction }}
snapshot={[
model({
kind: { type: 'select', choices: [{ value: 'gpt-5.5', label: 'GPT-5.5' }] },
valueSource: 'unknown',
action: { type: 'agent-picker' }
})
]}
isWorking={false}
/>
)
screen.getByRole('button', { name: 'Choose in agent picker…' }).click()
await waitFor(() =>
expect(toastError).toHaveBeenCalledExactlyOnceWith('Could not update option', {
description
})
)
})
it('uses a Toggle action for unknown flip-only options via invokeAction', async () => {
const invokeAction = vi.fn().mockResolvedValue({ snapshot: [] })
const setOption = vi.fn().mockResolvedValue({ snapshot: [] })
const liveSurface = { ...surface, setOption, invokeAction }
render(
<NativeChatSessionOptionPickers
surface={liveSurface}
snapshot={[
model(),
{
...fast,
kind: { type: 'boolean', currentValue: false },
valueSource: 'unknown',
action: { type: 'toggle-command' }
}
]}
isWorking={false}
/>
)
// Flip-only without a baseline: an action, never a switch claiming On or Off.
expect(screen.queryByRole('switch')).toBeNull()
screen.getByRole('button', { name: 'Toggle fast mode' }).click()
await waitFor(() => expect(invokeAction).toHaveBeenCalledWith('fastMode'))
expect(setOption).not.toHaveBeenCalled()
})
it('uses one switch row for a boolean option without inventing a selection', async () => {
const setOption = vi.fn().mockResolvedValue({ snapshot: [] })
const liveSurface = { ...surface, setOption }
const { rerender } = render(
<NativeChatSessionOptionPickers
surface={liveSurface}
snapshot={[
model(),
{
...fast,
kind: { type: 'boolean', currentValue: true },
valueSource: 'applied',
action: undefined
}
]}
isWorking={false}
/>
)
expect(screen.queryByRole('radio')).toBeNull()
const fastSwitch = screen.getByRole('switch', { name: 'Fast mode' })
expect(fastSwitch.getAttribute('aria-checked')).toBe('true')
expect(screen.getAllByText('Fast mode')).toHaveLength(1)
fastSwitch.click()
await waitFor(() => expect(setOption).toHaveBeenCalledWith('fastMode', false))
await settle()
setOption.mockClear()
rerender(
<NativeChatSessionOptionPickers
surface={liveSurface}
snapshot={[
model(),
{
id: 'thinking',
label: 'Thinking',
category: 'mode',
// Model the producer’s catalog fallback with unconfirmed provenance.
kind: { type: 'boolean', currentValue: true },
valueSource: 'unknown',
transport: 'catalog',
settable: true
}
]}
isWorking={false}
/>
)
expect(screen.queryByText('Current value unknown')).toBeNull()
const thinkingSwitch = screen.getByRole('switch', { name: 'Thinking' })
expect(thinkingSwitch.getAttribute('aria-checked')).toBe('true')
thinkingSwitch.click()
await waitFor(() => expect(setOption).toHaveBeenCalledWith('thinking', false))
})
it.each([
{ valueSource: 'unknown', transport: 'agent-session' },
{ valueSource: 'default', transport: 'catalog' },
{ valueSource: 'reported', transport: 'agent-session' }
] as const)('shows a $valueSource boolean as its switch alone', ({ valueSource, transport }) => {
render(
<NativeChatSessionOptionPickers
surface={surface}
snapshot={[
model(),
{ ...fast, kind: { type: 'boolean', currentValue: false }, valueSource, transport }
]}
isWorking={false}
/>
)
const control = screen.getByRole('switch', { name: 'Fast mode' })
expect(control.hasAttribute('aria-describedby')).toBe(false)
expect(screen.queryByText('Not reported')).toBeNull()
expect(screen.queryByText('Default')).toBeNull()
})
it.each(['new', 'loaded'] as const)(
'offers recorded OMP thinking choices for a non-running model in a %s chat without picking a default',
async (origin) => {
const projected = recordedOmpOptions(origin).read()
expect(projected.models[1]).toMatchObject({ effortsKnown: false })
expect(projected.models[1]).not.toHaveProperty('defaultEffort')
const state = applyStructuredAgentSessionModelCatalog(
createStructuredAgentSessionOptionState('omp'),
OMP_SESSION_OPTION_CATALOG,
{ origin: 'probe', fetchedAt: 1, models: projected.models },
{ newLaunch: origin === 'new' }
)
const view = structuredAgentSessionOptionView(state, undefined, { model: 'target' })
const snapshot = structuredAgentSessionOptionSnapshot(view)
expect(snapshot.find((entry) => entry.id === 'effort')).toMatchObject({
valueSource: 'unknown',
settable: true,
kind: {
type: 'select',
choices: [
{ value: 'off', label: 'Off' },
expect.objectContaining({ value: 'auto', label: 'Auto' })
]
}
})
expect(snapshot.find((entry) => entry.id === 'effort')?.kind).not.toHaveProperty(
'currentValue'
)
expect(view.record.values.effort).toBeUndefined()
expect(structuredAgentSessionOptionPicks(view, { model: 'target' })).toEqual([
{ modelId: 'target', optionId: 'model', value: 'target' }
])
const remembered = structuredAgentSessionOptionView(state, undefined, {
model: 'target',
effort: 'high'
})
expect(canSetStructuredAgentSessionOption(remembered, 'effort', 'high')).toBe(true)
const setOption = vi.fn().mockResolvedValue({ snapshot: [] })
render(
<NativeChatSessionOptionPickers
surface={{ ...surface, setOption }}
snapshot={snapshot}
isWorking={false}
/>
)
fireEvent.click(pill('Model Target'))
expect(setOption).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('radio', { name: 'Auto' }))
await waitFor(() => expect(setOption).toHaveBeenCalledExactlyOnceWith('effort', 'auto'))
}
)
it('makes the effort levels one tab stop, moves between them with the arrow keys, and sends the one picked', async () => {
const setOption = vi.fn().mockResolvedValue({ snapshot: [] })
render(
<NativeChatSessionOptionPickers
surface={{ ...surface, setOption }}
snapshot={[model(), effort]}
isWorking={false}
/>
)
const [low, high] = effortLevels()
expect([low?.textContent, high?.textContent]).toEqual(['Low', 'High'])
expect([low?.getAttribute('aria-checked'), high?.getAttribute('aria-checked')]).toEqual([
'false',
'true'
])
// Tab stops at the group, not at each level, and lands on the level in use.
expect(effortLevels().map((level) => level.tabIndex)).toEqual([-1, -1])
screen.getByRole('radiogroup', { name: 'Effort' }).focus()
await waitFor(() => expect(document.activeElement).toBe(high))
// An arrow key moves focus without writing: each write is a round trip to the agent.
fireEvent.keyDown(document.activeElement ?? document.body, { key: 'ArrowLeft' })
await waitFor(() => expect(document.activeElement).toBe(low))
high?.click()
expect(setOption).not.toHaveBeenCalled()
low?.click()
await waitFor(() => expect(setOption).toHaveBeenCalledExactlyOnceWith('effort', 'low'))
})
// Voice control targets what is on screen (WCAG 2.5.3), so the name is the visible text.
it('names a level by what it shows and describes it with the rest', () => {
render(
<NativeChatSessionOptionPickers
surface={surface}
snapshot={[
model(),
{
...effort,
kind: {
type: 'select',
currentValue: 'medium',
choices: [
{ value: 'low', label: 'Low', description: 'Quick answers' },
{ value: 'medium', label: 'Medium' },
{ value: 'xhigh', label: 'Extra high', description: 'Deepest reasoning' },
{ value: 'max', label: 'Max' }
]
}
}
]}
isWorking={false}
/>
)
const described = (name: string): string | null | undefined => {
const id = screen.getByRole('radio', { name }).getAttribute('aria-describedby')
return id ? document.getElementById(id)?.textContent : null
}
expect(described('Low')).toBe('Quick answers')
expect(described('Med')).toBe('Medium')
expect(described('XHigh')).toBe('Extra high Deepest reasoning')
expect(described('Max')).toBeNull()
expect(pill('Model Opus 4.8, Effort Med')?.textContent).toBe('Opus 4.8Med')
})
it('shows concurrent model and effort picks and releases only the settled descriptor', async () => {
let settleWrite: () => void = () => {}
let settleModel: () => void = () => {}
@@ -745,80 +167,4 @@ describe('NativeChatSessionOptionPickers', () => {
await act(async () => settleModel())
await waitFor(() => expect(held()).toEqual(['false', 'false']))
})
it('keeps the model action usable while another descriptor saves', async () => {
let settleWrite: () => void = () => {}
const setOption = vi.fn(
() =>
new Promise<{ snapshot: SessionOptionDescriptor[] }>((resolve) => {
settleWrite = () => resolve({ snapshot: [] })
})
)
const invokeAction = vi.fn().mockResolvedValue({ snapshot: [] })
render(
<NativeChatSessionOptionPickers
surface={{ ...surface, setOption, invokeAction }}
snapshot={[
model({ valueSource: 'unknown', action: { type: 'agent-picker' } }),
{ ...fast, kind: { type: 'boolean', currentValue: false } }
]}
isWorking={false}
pickerRequest={{ id: 'model', consume: () => {} }}
/>
)
const trigger = pill('Model')
const action = screen.getByRole('button', { name: 'Choose in agent picker…' })
const unavailable = (): string | null | undefined =>
action.closest('[data-busy]')?.getAttribute('aria-disabled')
expect(unavailable()).toBe('false')
screen.getByRole('switch', { name: 'Fast mode' }).click()
await waitFor(() =>
expect(screen.getByRole('switch', { name: 'Fast mode' }).getAttribute('aria-checked')).toBe(
'true'
)
)
expect(unavailable()).toBe('false')
fireEvent.click(action)
await act(async () => settleWrite())
await waitFor(() => expect(invokeAction).toHaveBeenCalledExactlyOnceWith('model'))
expect(isOpen(trigger)).toBe(false)
})
// The popover hangs from its bottom edge: a row that vanished would slide the list.
it('keeps a row the next model lacks, switched off, while the picker is mounted', () => {
const { rerender } = render(
<NativeChatSessionOptionPickers
surface={surface}
snapshot={[model(), effort, fast]}
isWorking={false}
/>
)
rerender(
<NativeChatSessionOptionPickers
surface={surface}
snapshot={[
model({
kind: {
type: 'select',
currentValue: 'sonnet',
choices: [
{ value: 'opus', label: 'Opus 4.8' },
{ value: 'sonnet', label: 'Sonnet 5' }
]
}
})
]}
isWorking={false}
/>
)
const fastSwitch = screen.getByRole('switch', { name: 'Fast mode' })
expect(fastSwitch).toHaveProperty('disabled', true)
expect(fastSwitch.getAttribute('aria-checked')).toBe('false')
expect(effortLevels().map((level) => level.getAttribute('aria-checked'))).toEqual([
'false',
'false'
])
expect(effortLevels().every((level) => level.hasAttribute('disabled'))).toBe(true)
})
})
@@ -1,12 +1,6 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { getStructuredAgentSessionReadOwner } from './structured-agent-session-read-owner'
import {
getStructuredAgentSessionPendingSends,
structuredAgentSessionSendOut
} from './structured-agent-session-pending-sends'
import { runtimes } from './structured-agent-session-send-runtime'
import { useStructuredAgentSessionSends } from './use-structured-agent-session-sends'
import { resetStructuredAgentSessionSendsForTests } from './structured-agent-session-message-sender'
const rpc = vi.hoisted(() => ({ call: vi.fn() }))
vi.mock('@/runtime/structured-agent-session-client', () => ({
@@ -18,12 +12,7 @@ import type { NativeChatStructuredComposerTransport } from './native-chat-compos
import type { NativeChatMessage } from '../../../../shared/native-chat/native-chat-types'
import { NativeChatStructuredAsyncQuestion } from './NativeChatStructuredAsyncQuestion'
import { nativeChatPromptDismissals } from './native-chat-prompt-dismissals'
import type {
AgentJournalRenderItem,
AgentJournalSubmission
} from '../../../../shared/agent-session/journal/agent-session-journal-types'
import { agentJournalSubmissionKey } from '../../../../shared/agent-session/journal/agent-session-journal-item-key'
import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection'
import type { AgentJournalSubmission } from '../../../../shared/agent-session/journal/agent-session-journal-types'
let container: HTMLDivElement
let root: ReturnType<typeof createRoot>
@@ -57,23 +46,6 @@ const asked: NativeChatMessage = {
source: 'transcript'
}
const askedItem: AgentJournalRenderItem = {
itemId: asked.id,
sequence: 1,
revision: 0,
observedAt: 1,
body: { kind: 'message', role: 'assistant', blocks: asked.blocks }
}
const answerItem = {
...askedItem,
itemId: agentJournalSubmissionKey('answer'),
sequence: 2,
body: {
kind: 'message',
role: 'user',
blocks: [{ type: 'text', text: '> Which color?\n\nBlue' }]
}
} satisfies AgentJournalRenderItem
const pendingAnswer: AgentJournalSubmission = {
clientMessageId: 'answer',
fence: 1,
@@ -86,295 +58,7 @@ const pendingAnswer: AgentJournalSubmission = {
handoverRecorded: true
}
const NO_SUBMISSIONS: AgentJournalSubmission[] = []
const INITIAL_ITEMS = [askedItem]
describe('async question send ownership', () => {
it.each(['publication', 'read', 'cursorless publication'] as const)(
'bridges its actual sender through RPC-first echo until %s coverage',
async (coverage) => {
let reply = (_value: unknown) => {}
rpc.call.mockImplementation(
() =>
new Promise((resolve) => {
reply = resolve
})
)
let pending: readonly { clientMessageId: string }[] = []
const target = { kind: 'local' } as const
const queue = {
capability: 'unsupported',
followUpsQueue: false,
isWorking: false,
stopping: false,
session: { queuedMessages: [] },
prompts: []
} as const
function Chat({
submissions = NO_SUBMISSIONS,
cursor,
items = INITIAL_ITEMS
}: {
submissions?: AgentJournalSubmission[]
cursor?: { epoch: string; sequence: number }
items?: AgentJournalRenderItem[]
}) {
const sends = useStructuredAgentSessionSends({
sessionId: 'answer-session',
target,
fence: 1,
submissions,
queuedMessageIds: [],
queue,
historyLoaded: false,
isWorking: false,
cursor
})
pending = sends.pending
return (
<NativeChatStructuredAsyncQuestion
paneKey="answer-session"
sourceKey="host/epoch"
sourceScope="host/epoch"
session={{
messages: projectStructuredAgentSessionMessages(items, sends.pending, submissions),
readPhase: 'ready'
}}
submissions={submissions}
send={(text, _attachments, request) => sends.send(text, [], request)}
/>
)
}
act(() => root.render(<Chat />))
act(() =>
[...container.querySelectorAll('button[aria-pressed]')]
.find((node) => node.textContent?.includes('Blue'))!
.dispatchEvent(new MouseEvent('click', { bubbles: true }))
)
await act(async () => {
await vi.advanceTimersByTimeAsync(200)
})
const selected = container.querySelector<HTMLButtonElement>('button[aria-pressed="true"]')!
expect(selected.disabled).toBe(true)
const id = pending[0]!.clientMessageId
const submission = { ...pendingAnswer, clientMessageId: id }
await act(async () =>
reply({
ok: true,
replayed: false,
fence: 1,
...(coverage === 'cursorless publication'
? {}
: { cursor: { epoch: 'epoch', sequence: 2 } }),
value: { clientMessageId: id, submission }
})
)
expect(pending).toHaveLength(1)
expect(container.querySelector('button[aria-pressed="true"]')).toBe(selected)
expect(selected.disabled).toBe(true)
if (coverage !== 'read') {
act(() =>
root.render(
<Chat
submissions={[submission]}
items={[askedItem, { ...answerItem, itemId: agentJournalSubmissionKey(id) }]}
/>
)
)
expect(selected.disabled).toBe(true)
act(() =>
root.render(
<Chat
submissions={[{ ...submission, dispatchState: 'unknown' }]}
items={[askedItem, { ...answerItem, itemId: agentJournalSubmissionKey(id) }]}
/>
)
)
} else {
act(() => root.render(<Chat cursor={{ epoch: 'epoch', sequence: 2 }} />))
}
expect(pending).toHaveLength(0)
expect(container.querySelector('button[aria-pressed="true"]')).toBe(selected)
expect(selected.disabled).toBe(false)
}
)
it.each([true, false])(
'restores explicit answer controls after actual no-row unknown completion (cursor: %s)',
async (positioned) => {
const sessionId = `unknown-answer-${positioned}`
const target = { kind: 'local' } as const
const cursor = { epoch: 'epoch', sequence: 2 }
const owner = getStructuredAgentSessionReadOwner(sessionId, target)
if (positioned) {
owner.publishCreated({
sessionId,
epoch: 'epoch',
fence: 1,
direction: 'tail',
items: [askedItem],
removedItemIds: [],
submissions: [],
window: { oldest: { epoch: 'epoch', sequence: 1 }, newest: cursor, nextCursor: cursor },
liveCursor: cursor,
hasOlder: false,
hasNewer: false
})
expect(owner.getSnapshot().state.cursor).toEqual(cursor)
}
const replies: ((value: unknown) => void)[] = []
rpc.call.mockImplementation(
() =>
new Promise((resolve) => {
replies.push(resolve)
})
)
const completed = vi.fn()
let pending = getStructuredAgentSessionPendingSends(sessionId)
let messages: NativeChatMessage[] = []
const queue = {
capability: 'unsupported',
followUpsQueue: false,
isWorking: false,
stopping: false,
session: { queuedMessages: [] },
prompts: []
} as const
function Chat({ submissions = NO_SUBMISSIONS }: { submissions?: AgentJournalSubmission[] }) {
const sends = useStructuredAgentSessionSends({
sessionId,
target,
fence: 1,
submissions,
queuedMessageIds: [],
queue,
historyLoaded: false,
isWorking: false,
cursor
})
pending = sends.pending
messages = projectStructuredAgentSessionMessages(INITIAL_ITEMS, pending, submissions)
return (
<NativeChatStructuredAsyncQuestion
paneKey={sessionId}
sourceKey="host/epoch"
sourceScope="host/epoch"
session={{ messages, readPhase: 'ready' }}
submissions={submissions}
send={(text, _attachments, request) =>
sends.send(text, [], {
...request,
onSettled: (outcome) => {
completed(outcome)
request?.onSettled?.(outcome)
}
})
}
/>
)
}
act(() => root.render(<Chat />))
act(() =>
[...container.querySelectorAll('button[aria-pressed]')]
.find((node) => node.textContent?.includes('Blue'))!
.dispatchEvent(new MouseEvent('click', { bubbles: true }))
)
await act(async () => {
await vi.advanceTimersByTimeAsync(200)
})
expect(rpc.call).toHaveBeenCalledTimes(1)
expect(rpc.call.mock.calls[0]![1]).toBe('agentSession.send')
expect(pending).toHaveLength(1)
const original = pending[0]!
expect(original).toMatchObject({ phase: 'sending', issued: true, callerKeepsText: true })
expect(original.afterSequence).toBe(positioned ? 2 : undefined)
expect(messages.find((row) => row.role === 'user')?.deliveryState).toBe('unknown')
expect(original.body.blocks).toEqual([{ type: 'text', text: '> Which color?\n\nBlue' }])
const selected = container.querySelector<HTMLButtonElement>('button[aria-pressed="true"]')!
expect(selected.textContent).toContain('Blue')
expect(selected.disabled).toBe(true)
expect(completed).not.toHaveBeenCalled()
await act(async () => {
replies[0]!({
ok: false,
refusal: {
code: 'agent_session_operation_unknown',
message: 'unknown',
details: { reason: 'outcomeUnknown' }
}
})
})
await act(async () => {
await vi.advanceTimersByTimeAsync(31_000)
})
expect(completed).toHaveBeenCalledExactlyOnceWith('unconfirmed')
expect(runtimes.has(original.clientMessageId)).toBe(false)
expect(structuredAgentSessionSendOut(sessionId)).toBe(false)
expect(pending).toHaveLength(1)
expect(pending[0]).toMatchObject({
clientMessageId: original.clientMessageId,
phase: 'unconfirmed'
})
expect(pending[0]!.body).toBe(original.body)
expect(owner.getSnapshot().state.submissions).toHaveLength(0)
expect(messages.filter((message) => message.role === 'user')).toHaveLength(1)
expect(messages.find((message) => message.role === 'user')?.blocks).toEqual(
original.body.blocks
)
expect(container.querySelector('button[aria-pressed="true"]')).toBe(selected)
expect(selected.textContent).toContain('Blue')
expect(rpc.call).toHaveBeenCalledTimes(1)
const submit = [...container.querySelectorAll<HTMLButtonElement>('button')].find(
(node) => node.textContent === 'Submit' || node.textContent === 'Sending…'
)!
expect(submit).toBeDefined()
expect({ answerDisabled: selected.disabled, submitDisabled: submit.disabled }).toEqual({
answerDisabled: false,
submitDisabled: false
})
expect(messages.find((row) => row.role === 'user')?.journalPosition?.sequence).toBe(
positioned ? 2.5 : undefined
)
act(() => submit.dispatchEvent(new MouseEvent('click', { bubbles: true })))
await act(async () => {
await vi.advanceTimersByTimeAsync(200)
})
expect(rpc.call).toHaveBeenCalledTimes(2)
const retry = pending.find((send) => send.clientMessageId !== original.clientMessageId)!
expect(retry.body.blocks).toEqual(original.body.blocks)
expect(selected.disabled).toBe(true)
// Exact old evidence retires only its echo, leaving the newer operation active.
act(() =>
root.render(
<Chat
submissions={[
{
...pendingAnswer,
clientMessageId: original.clientMessageId,
dispatchState: 'unknown'
}
]}
/>
)
)
expect(pending.map((send) => send.clientMessageId)).toEqual([retry.clientMessageId])
expect(selected.disabled).toBe(true)
expect(messages.filter((row) => row.role === 'user')).toHaveLength(1)
expect(rpc.call).toHaveBeenCalledTimes(2)
await act(async () =>
replies[1]!({
ok: false,
refusal: {
code: 'agent_session_operation_unknown',
message: 'unknown',
details: { reason: 'outcomeUnknown' }
}
})
)
expect(selected.disabled).toBe(false)
}
)
it('releases journal-first uncertainty before a late callback, fences retries and resets ownership on revised forms and sources', () => {
const completions: (() => void)[] = []
const send = vi.fn<NativeChatStructuredComposerTransport['send']>((_text, _images, request) => {
@@ -1,6 +1,6 @@
// @vitest-environment happy-dom
import { cleanup, render, screen, waitFor, within } from '@testing-library/react'
import { cleanup, render, waitFor, within } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import type { AgentSessionFailureFact } from '../../../../shared/agent-session/failures/agent-session-failure'
import { agentSessionFailureWords } from '../../../../shared/agent-session/failures/agent-session-failure-words'
@@ -35,118 +35,6 @@ afterEach(() => {
resetStructuredSessionMocks()
})
{
// An approval whose subject a newer Orca wrote, with no detail to show.
const NEWER_APPROVAL: AgentJournalRenderItem = JSON.parse(
JSON.stringify({
itemId: 'approval-item',
revision: 3,
sequence: 1,
observedAt: 1,
body: {
kind: 'approval',
title: 'Review proposed change',
detail: null,
subject: { kind: 'diff', path: 'a.ts' },
options: [{ id: 'allow', label: 'Approve' }],
resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null }
}
})
)
it("hands the card a newer Orca's subject in a writable chat, and its cancel goes to the host with the card", () => {
mocks.turnId = 'turn-1'
mocks.promptItems = [NEWER_APPROVAL]
render(
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="newer-approval-tab"
sessionId="newer-approval-session"
target={{ kind: 'local' }}
agent="claude"
/>
)
expect(mocks.approvalCardProps).toMatchObject({
approval: { subject: { kind: 'diff' } }
})
expect(mocks.approvalCardProps?.approval).not.toHaveProperty('detail')
mocks.approvalCardProps?.onCancel?.()
expect(mocks.cancel).toHaveBeenCalledWith('turn-1', {
itemId: 'approval-item',
expectedRevision: 3
})
})
it('sends nothing for the card when no turn is running', () => {
mocks.promptItems = [NEWER_APPROVAL]
render(
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="newer-approval-tab"
sessionId="newer-approval-session"
target={{ kind: 'local' }}
agent="claude"
/>
)
mocks.approvalCardProps?.onCancel?.()
expect(mocks.cancel).not.toHaveBeenCalled()
})
it('cancels a Pi extension dialog outside a model turn', () => {
mocks.promptItems = [NEWER_APPROVAL]
render(
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="pi-dialog-tab"
sessionId="pi-dialog-session"
target={{ kind: 'local' }}
agent="pi"
/>
)
mocks.approvalCardProps?.onCancel?.()
expect(mocks.cancel).toHaveBeenCalledWith(undefined, {
itemId: 'approval-item',
expectedRevision: 3
})
})
function renderSession() {
render(
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="newer-approval-tab"
sessionId="newer-approval-session"
target={{ kind: 'local' }}
agent="claude"
/>
)
}
it('keeps the composer open and writable beside a card this build cannot answer', () => {
mocks.promptItems = [NEWER_APPROVAL]
renderSession()
expect(mocks.composerProps).not.toBeNull()
expect(mocks.approvalCardProps).toMatchObject({ shouldFocus: false })
})
it('gives a card this build can answer the composer slot', () => {
const plan: AgentJournalRenderItem = JSON.parse(
JSON.stringify(NEWER_APPROVAL).replace(
'{"kind":"diff","path":"a.ts"}',
'{"kind":"plan","text":"do it"}'
)
)
mocks.promptItems = [plan]
renderSession()
expect(mocks.composerProps).toBeNull()
expect(mocks.approvalCardProps).toMatchObject({ shouldFocus: true })
})
}
{
const SESSION_ID = 'start-failure-session'
const START_FAILED: AgentSessionFailureFact = { kind: 'providerStartFailed' }
@@ -228,33 +116,6 @@ afterEach(() => {
})
}
it('renders the terminal refusal once beside the rejected message', async () => {
const fact: AgentSessionFailureFact = { kind: 'conversationOpenInTerminal' }
renderPane([rejected('terminal-held', 'Host fallback words.', fact)])
const row = await notice('terminal-held')
expect(row.textContent).toContain(
'Claude is already running this conversation in another terminal. Quit Claude there or close that terminal to continue here.'
)
expect(within(row).queryByRole('button', { name: 'Retry' })).toBeNull()
})
// The start row owns the failure reason; resending a rejected message creates a new message.
it("says only 'not sent', with no Retry, on each message the failed start's row explains", async () => {
mocks.journalItems = [startFailureRow(START_FAILED)]
renderPane([
rejected('first', START_FAILED_REASON, START_FAILED),
rejected('second', START_FAILED_REASON, START_FAILED)
])
for (const id of ['first', 'second']) {
const row = await notice(id)
expect(within(row).getByText('Your message was not sent.')).toBeTruthy()
expect(within(row).queryByRole('button', { name: 'Retry' })).toBeNull()
}
expect(screen.queryByText(/stopped before it finished starting/)).toBeNull()
})
it('keeps the full notice on a message rejected for a reason no start-failure row states', async () => {
mocks.journalItems = [startFailureRow(START_FAILED)]
const providerRejected: AgentSessionFailureFact = {
@@ -272,43 +133,4 @@ afterEach(() => {
within(await notice('other')).getByText("Claude didn't accept this message: Image type .bmp.")
).toBeTruthy()
})
it("keeps the start failure's own words when its row is not loaded", async () => {
renderPane([rejected('first', START_FAILED_REASON, START_FAILED)])
expect(within(await notice('first')).getByText(START_FAILED_REASON)).toBeTruthy()
})
it.each([null, '0.100.0', '0.135.0'])(
'states a missing or too-old Codex (installed version %s) in the existing failure notice',
async (installedVersion) => {
const failure: AgentSessionFailureFact = {
kind: installedVersion === null ? 'cliMissing' : 'startFailed',
...(installedVersion === null
? {}
: {
refusal: {
code: 'agent_session_operation_invalid',
details: {
reason: 'attachFailed',
codexInstallation: { installedVersion, minimumVersion: '0.136.0' }
}
}
})
}
const { text } = agentSessionFailureWords(failure, { agentName: 'Codex', surface: 'row' })
renderPane([rejected('codex-message', text, failure)], 'codex')
const row = await notice('codex-message')
expect(within(row).getByText(text)).toBeTruthy()
expect(text).toContain(
installedVersion === null
? "Codex wasn't found on the computer running this chat. Install it, or check its Command in Settings → Agents."
: `Codex ${installedVersion} is too old for chats. Update to 0.136.0 or newer.`
)
if (installedVersion !== null) {
expect(text).not.toContain('Settings → Agents')
}
expect(text).not.toContain('codex update')
}
)
}
@@ -1,305 +0,0 @@
// @vitest-environment happy-dom
import type * as LaunchRegistry from '@/lib/agent-session/launch/structured-agent-session-launch-registry'
import type * as LaunchOptions from '@/lib/agent-session/launch/structured-agent-session-launch-options'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
import type {
AgentJournalRenderItem,
AgentJournalThreadGoal
} from '../../../../shared/agent-session/journal/agent-session-journal-types'
const { mocks, moduleFactories, resetStructuredSessionMocks, launchModule } = await vi.hoisted(
async () => {
const setup = (
await import('./NativeChatStructuredSession.test-harness')
).createStructuredSessionMocks()
return {
...setup,
launchModule: setup.moduleFactories.structuredAgentSessionLaunch()
}
}
)
vi.mock('@/lib/agent-session/launch/structured-agent-session-launch', () => ({
retryStructuredAgentSessionLaunch: launchModule.retryStructuredAgentSessionLaunch,
relaunchFailedStructuredAgentSessionForMessage:
launchModule.relaunchFailedStructuredAgentSessionForMessage
}))
vi.mock(
'@/lib/agent-session/launch/structured-agent-session-launch-registry',
async (importOriginal) => ({
...(await importOriginal<typeof LaunchRegistry>()),
getStructuredAgentSessionLaunchLifecycle: launchModule.getStructuredAgentSessionLaunchLifecycle,
getStructuredAgentSessionLaunchResumes: launchModule.getStructuredAgentSessionLaunchResumes,
useStructuredAgentSessionLaunchLifecycle: launchModule.useStructuredAgentSessionLaunchLifecycle,
useStructuredAgentSessionLaunchFailure: launchModule.useStructuredAgentSessionLaunchFailure
})
)
vi.mock(
'@/lib/agent-session/launch/structured-agent-session-launch-options',
async (importOriginal) => ({
...(await importOriginal<typeof LaunchOptions>()),
useStructuredAgentSessionLaunchSelection: launchModule.useStructuredAgentSessionLaunchSelection
})
)
vi.mock('@/runtime/structured-agent-session-client', () =>
moduleFactories.structuredAgentSessionClient()
)
vi.mock('./use-structured-agent-session', () => moduleFactories.useStructuredAgentSession())
vi.mock('./use-native-chat-font-size', () => moduleFactories.useNativeChatFontSize())
vi.mock('./use-native-chat-file-link-context', () => moduleFactories.useNativeChatFileLinkContext())
vi.mock('./use-native-chat-file-link-click', () => moduleFactories.useNativeChatFileLinkClick())
vi.mock('./NativeChatMessageList', () => moduleFactories.nativeChatMessageList())
vi.mock('./NativeChatComposer', () => moduleFactories.nativeChatComposer())
vi.mock('./NativeChatEmptyState', () => moduleFactories.nativeChatEmptyState())
vi.mock('./NativeChatApprovalCard', () => moduleFactories.nativeChatApprovalCard())
vi.mock('./NativeChatQuestionCard', () => moduleFactories.nativeChatQuestionCard())
import { NativeChatStructuredSession } from './NativeChatStructuredSession'
import { claudeGroupedQuestionPromptItems } from './native-chat-structured-question-test-fixtures'
const STRIP = '[data-native-chat-background-tasks]'
const GOAL = '[data-native-chat-thread-goal]'
// Docking depends on adjacent task/goal DOM elements and their group markers.
const STRIP_DOCK_RULE = /^\[\[data-native-chat-background-tasks\]:has\(\+\[([a-z-]+)\]\)_&\]:(.+)$/
const GOAL_DOCK_RULE = /^group-\[\[([a-z-]+)\]\+&\]\/goal:(.+)$/
function dockRules(root: Element, rule: RegExp): { attribute: string; utility: string }[] {
return [root, ...root.querySelectorAll('*')].flatMap((element) =>
[...element.classList].flatMap((token) => {
const match = rule.exec(token)
return match ? [{ attribute: match[1], utility: match[2] }] : []
})
)
}
function goal(status: AgentJournalThreadGoal['status']): AgentJournalThreadGoal {
return {
objective: 'Ship the parser',
status,
tokenBudget: null,
tokensUsed: 0,
timeUsedSeconds: 60,
createdAt: 1,
updatedAt: 1
}
}
function sessionView(agent: 'codex' | 'claude' = 'codex'): React.JSX.Element {
return (
<TooltipProvider>
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="structured-tab-goal"
sessionId="session-goal"
target={{ kind: 'local' }}
agent={agent}
/>
</TooltipProvider>
)
}
function showStripAndGoal(status: AgentJournalThreadGoal['status'] = 'active'): void {
mocks.monitoringBackgroundTasks = true
mocks.backgroundTasks = [{ id: 'task-agent', kind: 'agent' }]
mocks.threadGoal = { goal: goal(status), pending: false, hostChanges: true, change: vi.fn() }
}
describe('NativeChatStructuredSession task strip on the goal tab', () => {
afterEach(() => {
cleanup()
localStorage.clear()
resetStructuredSessionMocks()
})
it('reveals a Claude goal cleared from the strip while preserving the composer draft', () => {
const change = vi.fn(async () => true)
mocks.threadGoal = { goal: goal('active'), pending: false, hostChanges: false, change }
const view = render(sessionView('claude'))
const composer = screen.getByRole('textbox', { name: 'Message' })
fireEvent.change(composer, { target: { value: 'Keep this draft' } })
const clear = screen.getByRole('button', { name: 'Clear goal' })
mocks.sendOut = true
view.rerender(sessionView('claude'))
expect(clear).toHaveProperty('disabled', true)
fireEvent.click(clear)
expect(change).not.toHaveBeenCalled()
expect(mocks.revealLatest).not.toHaveBeenCalled()
mocks.sendOut = false
mocks.isWorking = true
view.rerender(sessionView('claude'))
expect(mocks.turnId).toBeNull()
expect(clear).toHaveProperty('disabled', true)
fireEvent.click(clear)
expect(change).not.toHaveBeenCalled()
expect(mocks.revealLatest).not.toHaveBeenCalled()
mocks.isWorking = false
view.rerender(sessionView('claude'))
expect(clear).toHaveProperty('disabled', false)
fireEvent.click(clear)
expect(change).toHaveBeenCalledWith({ kind: 'clear' })
expect(mocks.revealLatest).toHaveBeenCalledOnce()
expect(composer).toHaveProperty('value', 'Keep this draft')
})
it('stacks the strip directly on the goal tab at the tab width, sharing one edge', () => {
showStripAndGoal()
render(sessionView())
const strip = document.querySelector(STRIP)
const goalTab = document.querySelector(GOAL)
if (!strip || !goalTab) {
throw new Error('expected both the task strip and the goal tab')
}
expect(strip.nextElementSibling).toBe(goalTab)
// Group variants are inert without their group marker on the adjacent element.
expect(strip.classList).toContain('group/tasks')
expect(goalTab.classList).toContain('group/goal')
const stripRules = dockRules(strip, STRIP_DOCK_RULE)
// Its other rules join a tab strip stacked on another tab strip.
const goalRules = dockRules(goalTab, GOAL_DOCK_RULE).filter(
(rule) => rule.attribute === 'data-native-chat-background-tasks'
)
for (const { attribute } of stripRules) {
expect(goalTab.hasAttribute(attribute)).toBe(true)
}
for (const { attribute } of goalRules) {
expect(strip.hasAttribute(attribute)).toBe(true)
}
const stripUtilities = stripRules.map((rule) => rule.utility)
expect(stripUtilities).toEqual(expect.arrayContaining(['rounded-b-none', 'shadow-none']))
expect(goalRules.map((rule) => rule.utility)).toEqual(['rounded-t-none', 'border-t-0'])
const goalInset = goalTab.firstElementChild
expect(goalInset?.classList).toContain('px-2')
expect(stripUtilities).toContain('px-2')
})
it('leaves the strip undocked when the goal tab does not render', () => {
showStripAndGoal('complete')
const { rerender } = render(sessionView())
expect(document.querySelector(GOAL)).toBeNull()
expect(document.querySelector(STRIP)?.nextElementSibling?.matches(GOAL) ?? false).toBe(false)
const approval: AgentJournalRenderItem = {
itemId: 'approval-item',
revision: 1,
sequence: 1,
observedAt: 1,
body: {
kind: 'approval',
title: 'Allow command?',
detail: 'pnpm test',
options: [{ id: 'allow', label: 'Allow' }],
resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null }
}
}
showStripAndGoal('active')
mocks.promptItems = [approval]
rerender(sessionView())
expect(document.querySelector(GOAL)).toBeNull()
expect(document.querySelector(STRIP)).not.toBeNull()
})
it('restores the goal tab top edge when the strip goes away', () => {
showStripAndGoal()
const { rerender } = render(sessionView())
expect(document.querySelector(GOAL)?.previousElementSibling?.matches(STRIP)).toBe(true)
mocks.monitoringBackgroundTasks = false
rerender(sessionView())
expect(document.querySelector(STRIP)).toBeNull()
expect(document.querySelector(GOAL)?.previousElementSibling?.matches(STRIP) ?? false).toBe(
false
)
})
})
describe('NativeChatStructuredSession task strip above a pending prompt', () => {
afterEach(() => {
cleanup()
localStorage.clear()
resetStructuredSessionMocks()
})
it.each([
['question', '[data-native-chat-question-card-mock]'],
['approval', '[data-native-chat-approval-card-mock]']
] as const)('renders the strip before the %s card, where the composer sat', (kind, card) => {
mocks.monitoringBackgroundTasks = true
mocks.backgroundTasks = [{ id: 'task-agent', kind: 'agent' }]
mocks.promptItems =
kind === 'question'
? claudeGroupedQuestionPromptItems
: [
{
itemId: 'approval-item',
revision: 1,
sequence: 1,
observedAt: 1,
body: {
kind: 'approval',
title: 'Allow command?',
detail: 'pnpm test',
options: [{ id: 'allow', label: 'Allow' }],
resolution: {
state: 'pending',
selectedOptionId: null,
resolvedBy: null,
resolvedAt: null
}
}
}
]
render(sessionView())
const strip = document.querySelector(STRIP)
const promptCard = document.querySelector(card)
if (!strip || !promptCard) {
throw new Error(`expected both the task strip and the ${kind} card`)
}
expect(strip.compareDocumentPosition(promptCard) & Node.DOCUMENT_POSITION_FOLLOWING).toBe(
Node.DOCUMENT_POSITION_FOLLOWING
)
})
})
describe('NativeChatStructuredSession queued messages above the task strip', () => {
afterEach(() => {
cleanup()
localStorage.clear()
resetStructuredSessionMocks()
})
it('stacks queued messages above the strip, so running shells and agents sit next to the composer', () => {
showStripAndGoal()
mocks.queuedCards = [
{
messageId: 'draft-1',
position: 1,
text: 'Reply with the word banana.',
state: 'waiting',
hold: 'turn'
}
]
render(sessionView())
const queued = document.querySelector('[data-queued-message-id="draft-1"]')
const strip = document.querySelector(STRIP)
if (!queued || !strip) {
throw new Error('expected both a queued message and the task strip')
}
expect(queued.compareDocumentPosition(strip) & Node.DOCUMENT_POSITION_FOLLOWING).toBe(
Node.DOCUMENT_POSITION_FOLLOWING
)
expect(strip.nextElementSibling).toBe(document.querySelector(GOAL))
})
})
@@ -4,8 +4,7 @@ import type * as LaunchRegistry from '@/lib/agent-session/launch/structured-agen
import type * as LaunchOptions from '@/lib/agent-session/launch/structured-agent-session-launch-options'
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
import type { AgentJournalRenderItem } from '../../../../shared/agent-session/journal/agent-session-journal-types'
import { NativeChatStructuredSession } from './NativeChatStructuredSession'
const { mocks, moduleFactories, resetStructuredSessionMocks, launchModule } = await vi.hoisted(
@@ -82,52 +81,6 @@ afterEach(() => {
}
describe('NativeChatStructuredSession before its first read settles', () => {
it.each(['idle', 'loading'] as const)(
'shows a reopened chat only a textless loading cue while its read is %s',
(status) => {
mocks.messages = []
mocks.status = status
render(sessionView())
const cue = screen.getByRole('status', { name: 'Loading chat' })
expect(cue).toBe(loadingCue())
expect(cue.textContent).toBe('')
expect(screen.queryAllByText(LOADING_TEXT)).toHaveLength(0)
expect(screen.queryByText(START_A_CHAT)).toBeNull()
expect(screen.getByTestId('structured-composer')).toBeTruthy()
}
)
it('drops the cue for the empty state once the read settles with nothing in it', () => {
mocks.messages = []
mocks.status = 'idle'
const { rerender } = render(sessionView())
expect(screen.queryByText(START_A_CHAT)).toBeNull()
mocks.status = 'ready'
rerender(sessionView())
expect(screen.getByText(START_A_CHAT)).toBeTruthy()
expect(loadingCue()).toBeNull()
})
it('shows no cue over a transcript, even while a read is still loading', () => {
mocks.status = 'loading'
render(sessionView())
expect(screen.getByTestId('message-list')).toBeTruthy()
expect(loadingCue()).toBeNull()
})
it('does not hold a cancelled resume on the cue', () => {
mocks.messages = []
mocks.launchLifecycle = 'cancelled'
mocks.launchResumes = true
mocks.status = 'ready'
render(sessionView())
expect(loadingCue()).toBeNull()
})
it('keeps a chat this view started on its empty state through publish and the first read', () => {
mocks.messages = []
mocks.launchLifecycle = 'pending'
@@ -145,122 +98,5 @@ afterEach(() => {
expect(loadingCue()).toBeNull()
}
})
it.each([
['failed', 'Chat could not be started.'],
['visibility-unknown', 'Chat connection could not be confirmed.']
] as const)(
'leaves a %s resume blank beside its Retry line, since nothing is reading it',
(lifecycle, line) => {
mocks.messages = []
mocks.launchLifecycle = lifecycle
mocks.launchResumes = true
mocks.status = 'ready'
render(sessionView())
expect(loadingCue()).toBeNull()
expect(screen.queryByText(START_A_CHAT)).toBeNull()
expect(screen.getByText(line)).toBeTruthy()
expect(screen.getByRole('button', { name: 'Retry' })).toBeTruthy()
}
)
it('does not invite a first message into a resumed chat whose history is not read yet', () => {
mocks.messages = []
mocks.launchLifecycle = 'pending'
mocks.launchResumes = true
mocks.status = 'ready'
render(sessionView())
expect(screen.queryByText(START_A_CHAT)).toBeNull()
expect(screen.queryAllByText(LOADING_TEXT)).toHaveLength(0)
expect(loadingCue()).not.toBeNull()
})
})
}
{
// Final read refusals replace even loaded content: every action would only be refused again.
const APPROVAL: AgentJournalRenderItem = JSON.parse(
JSON.stringify({
itemId: 'approval-item',
revision: 3,
sequence: 1,
observedAt: 1,
body: {
kind: 'approval',
title: 'Run the plan?',
detail: null,
subject: { kind: 'plan', text: 'x' },
options: [{ id: 'allow', label: 'Approve' }],
resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null }
}
})
)
/** Fail a loaded chat with an approval, goal, queued draft and background task still present. */
function renderLoadedChatThenFailedRead(
reason: 'journalCorrupt' | 'journalUnavailable' | 'journalWrittenByNewerOrca'
): void {
mocks.status = 'error'
mocks.readRefusal = { code: 'agent_session_journal_unreadable', details: { reason } }
mocks.turnId = 'turn-1'
mocks.promptItems = [APPROVAL]
mocks.threadGoal = {
goal: {
objective: 'Ship',
status: 'active',
tokenBudget: null,
tokensUsed: 0,
timeUsedSeconds: 1,
createdAt: 1,
updatedAt: 1
},
pending: false,
hostChanges: true,
change: vi.fn()
}
mocks.queuedCards = [
{ messageId: 'draft-1', position: 1, text: 'queued text', state: 'waiting', hold: 'turn' }
]
mocks.monitoringBackgroundTasks = true
mocks.backgroundTasks = [{ id: 'task-agent', kind: 'agent' }]
render(
<TooltipProvider>
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="final-read-failure-tab"
sessionId="final-read-failure-session"
target={{ kind: 'environment', environmentId: 'env-1' }}
agent="codex"
/>
</TooltipProvider>
)
}
it.each([
['journalCorrupt', 'Unable to load this chat.'],
['journalWrittenByNewerOrca', 'This chat was saved by a newer Orca. Update Orca to open it.']
] as const)('a %s read after the transcript loaded leaves only its words', (reason, words) => {
renderLoadedChatThenFailedRead(reason)
expect(screen.getAllByText(words)).toHaveLength(1)
expect(screen.queryByTestId('message-list')).toBeNull()
expect(mocks.composerProps).toBeNull()
expect(mocks.approvalCardProps).toBeNull()
expect(document.querySelector('[data-native-chat-thread-goal]')).toBeNull()
expect(document.querySelector('[data-queued-message-id="draft-1"]')).toBeNull()
expect(document.querySelector('[data-native-chat-background-tasks]')).toBeNull()
})
it('a failed read that can clear keeps the transcript and every control', () => {
renderLoadedChatThenFailedRead('journalUnavailable')
expect(screen.getByTestId('message-list')).toBeTruthy()
expect(mocks.approvalCardProps).not.toBeNull()
expect(document.querySelector('[data-queued-message-id="draft-1"]')).not.toBeNull()
expect(document.querySelector('[data-native-chat-background-tasks]')).not.toBeNull()
})
}
@@ -146,16 +146,6 @@ afterEach(() => {
})
})
it('adds no host line and no read line to a loaded chat while its host stays connected', () => {
renderPane()
act(() => {
vi.advanceTimersByTime(NATIVE_CHAT_HOST_RECONNECTING_GRACE_MS * 2)
})
expect(screen.getByTestId('message-list')).toBeTruthy()
expect(screen.queryByText(/reconnect|offline|couldn't be loaded/i)).toBeNull()
})
it('names a reconnecting host above the composer only once the grace has passed', () => {
renderPane()
// Mounted before its words arrive, so a screen reader announces them.
@@ -184,24 +174,6 @@ it('names a reconnecting host above the composer only once the grace has passed'
expect(screen.getByRole('list')).toBe(region)
})
it('says a disconnected host is offline at once and offers Reconnect', async () => {
mocks.hasOlder = true
renderPane()
setHost(hostStatus('blocked', 'disconnected', true))
expect(screen.getByText('Build box is offline')).toHaveClass('text-destructive')
expect(document.querySelector('.lucide-server-off')).toHaveClass('text-destructive')
expect(mocks.messageListProps?.session?.hasMore).toBe(false)
reconnectHost.mockResolvedValue(true)
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Reconnect' }))
})
expect(reconnectHost).toHaveBeenCalledWith('remote-host')
setHost(hostStatus('verified', 'ready'))
expect(mocks.messageListProps?.session?.hasMore).toBe(true)
})
it('disables Reconnect until its attempt settles and allows another attempt after failure', async () => {
let finish: (reachable: boolean) => void = () => {}
reconnectHost.mockImplementation(
@@ -230,33 +202,6 @@ it('disables Reconnect until its attempt settles and allows another attempt afte
})
})
it('offers no Reconnect for a host that refused us, and promises no delivery in the composer', () => {
renderPane()
setHost(hostStatus('blocked', 'disconnected'))
expect(screen.getByText('Build box is offline')).toBeTruthy()
expect(screen.queryByRole('button', { name: 'Reconnect' })).toBeNull()
// A send now fails and waits for its own Retry, so nothing may say it will go out later.
expect(mocks.composerProps?.structuredTransport).not.toHaveProperty('placeholder')
expect(screen.queryByText(/Messages send when/)).toBeNull()
})
it.each(['approval', 'question'] as const)(
'keeps the empty %s notice region mounted when its first host row arrives',
(kind) => {
mocks.promptItems = [promptItem(kind)]
renderPane()
const region = screen.getByRole('list')
expect(region.textContent).toBe('')
expect(region).toHaveAttribute('aria-live', 'polite')
setHost(hostStatus('blocked', 'disconnected', true))
expect(screen.getByRole('list')).toBe(region)
expect(region.textContent).toBe('Build box is offlineReconnect')
expect(screen.getByRole('listitem')).toHaveAttribute('data-notice-kind', 'host')
expect(screen.queryByRole('status')).toBeNull()
}
)
it.each(['approval', 'question'] as const)(
'keeps the host row and pending Reconnect when a %s takes the composer slot',
async (kind) => {
@@ -291,39 +236,3 @@ it.each(['approval', 'question'] as const)(
expect(region).toHaveAttribute('aria-live', 'polite')
}
)
it('says the outage once: a lost read beside messages adds no line of its own', () => {
mocks.status = 'error'
renderPane()
setHost(hostStatus('blocked', 'disconnected'))
expect(screen.getByTestId('message-list')).toBeTruthy()
expect(screen.getByText('Build box is offline')).toBeTruthy()
expect(screen.queryByText(/couldn't be loaded/)).toBeNull()
expect(document.querySelectorAll('[data-notice-kind="host"]')).toHaveLength(1)
expect(document.querySelectorAll('[data-notice-kind="error"]')).toHaveLength(0)
})
it('says the outage once: a chat that never loaded waits quietly instead of failing', () => {
mocks.status = 'error'
mocks.messages = []
renderPane()
setHost(hostStatus('blocked', 'disconnected'))
expect(screen.getByText('Build box is offline')).toBeTruthy()
expect(screen.queryByText('Could not load conversation')).toBeNull()
expect(screen.queryByText(/keeps trying/)).toBeNull()
})
it("still words a refusal the host itself sent beside the host's outage", () => {
mocks.status = 'error'
mocks.readRefusal = {
code: 'agent_session_journal_unreadable',
details: { reason: 'journalUnavailable' }
} as const
renderPane()
setHost(hostStatus('blocked', 'disconnected'))
expect(screen.getByText('Build box is offline')).toBeTruthy()
expect(screen.getByText("Orca couldn't open this chat's history right now.")).toBeTruthy()
})
@@ -1,394 +0,0 @@
// @vitest-environment happy-dom
import type * as LaunchRegistry from '@/lib/agent-session/launch/structured-agent-session-launch-registry'
import type * as LaunchOptions from '@/lib/agent-session/launch/structured-agent-session-launch-options'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
import type { ManagedRuntimeInstallState } from '../../../../shared/managed-runtime-install'
import type { ManagedRuntimeInstallObservation } from '@/runtime/use-managed-runtime-install'
const { mocks, moduleFactories, resetStructuredSessionMocks, launchModule } = await vi.hoisted(
async () => {
const setup = (
await import('./NativeChatStructuredSession.test-harness')
).createStructuredSessionMocks()
return {
...setup,
launchModule: setup.moduleFactories.structuredAgentSessionLaunch()
}
}
)
const install = vi.hoisted((): { observation: ManagedRuntimeInstallObservation } => ({
observation: { kind: 'inactive' }
}))
vi.mock('@/lib/agent-session/launch/structured-agent-session-launch', () => ({
retryStructuredAgentSessionLaunch: launchModule.retryStructuredAgentSessionLaunch,
relaunchFailedStructuredAgentSessionForMessage:
launchModule.relaunchFailedStructuredAgentSessionForMessage
}))
vi.mock(
'@/lib/agent-session/launch/structured-agent-session-launch-registry',
async (importOriginal) => ({
...(await importOriginal<typeof LaunchRegistry>()),
getStructuredAgentSessionLaunchLifecycle: launchModule.getStructuredAgentSessionLaunchLifecycle,
getStructuredAgentSessionLaunchResumes: launchModule.getStructuredAgentSessionLaunchResumes,
useStructuredAgentSessionLaunchLifecycle: launchModule.useStructuredAgentSessionLaunchLifecycle,
useStructuredAgentSessionLaunchFailure: launchModule.useStructuredAgentSessionLaunchFailure
})
)
vi.mock(
'@/lib/agent-session/launch/structured-agent-session-launch-options',
async (importOriginal) => ({
...(await importOriginal<typeof LaunchOptions>()),
useStructuredAgentSessionLaunchSelection: launchModule.useStructuredAgentSessionLaunchSelection
})
)
vi.mock('@/lib/agent-session/launch/structured-agent-session-launch-message', () =>
moduleFactories.structuredAgentSessionLaunchMessage()
)
vi.mock('@/runtime/structured-agent-session-client', () =>
moduleFactories.structuredAgentSessionClient()
)
vi.mock('./use-structured-agent-session', () => moduleFactories.useStructuredAgentSession())
vi.mock('./use-native-chat-font-size', () => moduleFactories.useNativeChatFontSize())
vi.mock('./use-native-chat-file-link-context', () => moduleFactories.useNativeChatFileLinkContext())
vi.mock('./use-native-chat-tab-owner', () => moduleFactories.useNativeChatTabOwner())
vi.mock('./use-native-chat-file-link-click', () => moduleFactories.useNativeChatFileLinkClick())
vi.mock('./NativeChatMessageList', () => moduleFactories.nativeChatMessageList())
vi.mock('./NativeChatComposer', () => moduleFactories.nativeChatComposer())
vi.mock('./NativeChatEmptyState', () => moduleFactories.nativeChatEmptyState())
vi.mock('./NativeChatApprovalCard', () => moduleFactories.nativeChatApprovalCard())
vi.mock('./NativeChatQuestionCard', () => moduleFactories.nativeChatQuestionCard())
vi.mock('@/runtime/use-managed-runtime-install', () => ({
useManagedRuntimeInstall: () => install.observation
}))
import { NativeChatStructuredSession } from './NativeChatStructuredSession'
import { agentSessionRefusalFailure } from '../../../../shared/agent-session/failures/agent-session-write-failure'
import { structuredLaunchFailure } from '@/lib/agent-session/launch/structured-agent-session-launch-failure'
import { StructuredAgentSessionCreateRefusalError } from '@/lib/agent-session/launch/structured-agent-session-launch-errors'
import {
readNativeChatComposerDraft,
structuredAgentSessionDraftScopeKey,
updateNativeChatComposerDraft
} from './native-chat-composer-draft-store'
const NOT_SIGNED_IN = {
kind: 'refused',
code: 'agent_session_operation_invalid',
details: { reason: 'notSignedIn' }
} as const
// Retry beside it is the resend, so the words keep only the step before it.
const NOT_SIGNED_IN_TEXT = "Codex isn't signed in. Run `codex login`."
function sessionView(agent: 'codex' | 'grok' = 'codex'): React.JSX.Element {
return (
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="structured-tab-1"
sessionId="session-1"
target={{ kind: 'local' }}
agent={agent}
/>
)
}
function composerSend() {
const send = mocks.composerProps?.structuredTransport?.send
if (typeof send !== 'function') {
throw new Error('Structured composer transport was not installed')
}
return send
}
describe('NativeChatStructuredSession launch lifecycle', () => {
afterEach(() => {
cleanup()
localStorage.clear()
resetStructuredSessionMocks()
install.observation = { kind: 'inactive' }
})
it('shows Install over a disabled composer until the host has the program', () => {
const notInstalled: ManagedRuntimeInstallState = {
agent: 'codex',
revision: 1,
available: true,
version: '1.3.0',
installedVersion: null,
installedCurrent: false,
downloadBytes: 111_500_000,
source: null,
operation: null
}
const hostSays = (state: ManagedRuntimeInstallState | null): void => {
install.observation = {
kind: 'active',
subject: { target: { kind: 'local' }, runtimeId: null, agent: 'codex' },
snapshot: state ? { kind: 'known', state } : { kind: 'unknown' },
removable: false
}
}
const draft = structuredAgentSessionDraftScopeKey('session-1')
updateNativeChatComposerDraft(draft, { text: 'hello' }, 'immediate')
mocks.messages = []
// Before the first answer the composer stays (its sends held) and Install doesn't flash.
hostSays(null)
const { rerender } = render(sessionView())
expect(screen.getByTestId('structured-composer')).toBeTruthy()
expect(screen.queryByRole('button', { name: 'Install Codex' })).toBeNull()
expect(mocks.composerProps?.unavailableHint).toBeUndefined()
hostSays(notInstalled)
rerender(sessionView())
expect(screen.getByRole('button', { name: 'Install Codex' })).toBeTruthy()
expect(screen.getByText(/One-time 112 MB download/)).toBeTruthy()
expect(screen.getByTestId('structured-composer')).toBeTruthy()
expect(mocks.composerProps?.unavailableHint).toBe('Install Codex to start chatting')
for (const lifecycle of ['pending', 'failed'] as const) {
mocks.launchLifecycle = lifecycle
mocks.launchResumes = true
rerender(<div key={lifecycle}>{sessionView()}</div>)
expect(screen.getAllByRole('button', { name: 'Install' })).toHaveLength(1)
expect(mocks.composerProps?.unavailableHint).toBe('Install Codex to continue')
expect(screen.queryByRole('button', { name: 'Retry' })).toBeNull()
}
mocks.launchLifecycle = null
hostSays({ ...notInstalled, available: false })
rerender(sessionView())
expect(mocks.composerProps?.unavailableHint).toBe('Codex is not available on this host.')
expect(screen.queryByRole('button', { name: /^Install/ })).toBeNull()
hostSays(notInstalled)
mocks.messages = null
rerender(sessionView())
expect(screen.getByRole('button', { name: 'Install' })).toBeTruthy()
expect(screen.queryByText(/One-time 112 MB download/)).toBeNull()
hostSays({ ...notInstalled, revision: 2, installedVersion: '1.3.0', source: 'managed' })
rerender(sessionView())
expect(screen.getByTestId('structured-composer')).toBeTruthy()
expect(screen.queryByRole('button', { name: /^Install/ })).toBeNull()
expect(mocks.composerProps?.unavailableHint).toBeUndefined()
expect(readNativeChatComposerDraft(draft).text).toBe('hello')
})
it('shows the ordinary usable chat without a startup label while launch is pending', () => {
mocks.launchLifecycle = 'pending'
render(sessionView())
expect(screen.getByTestId('structured-composer')).toBeTruthy()
expect(mocks.controllerProps).toMatchObject({ transportEnabled: false })
expect(screen.queryByText(/Starting (Claude|Codex) chat/i)).toBeNull()
expect(screen.queryByRole('button', { name: 'Retry' })).toBeNull()
})
it('keeps treating the chat as its launch after the launch record is deleted on publish', () => {
mocks.launchLifecycle = 'pending'
const { rerender } = render(sessionView())
expect(mocks.controllerProps).toMatchObject({ launch: { kind: 'new' } })
mocks.launchLifecycle = null
rerender(sessionView())
expect(mocks.controllerProps).toMatchObject({ launch: { kind: 'new' }, transportEnabled: true })
})
it('marks a launch that resumes a conversation from history as a resume', () => {
mocks.launchLifecycle = 'pending'
mocks.launchResumes = true
render(sessionView())
expect(mocks.controllerProps).toMatchObject({ launch: { kind: 'resume' } })
})
it('does not treat a reopened chat as a launch', () => {
mocks.launchLifecycle = null
render(sessionView())
expect(mocks.controllerProps).not.toHaveProperty('launch')
})
it.each([
['failed', 'Chat could not be started.'],
['visibility-unknown', 'Chat connection could not be confirmed.']
] as const)('offers launch Retry for %s without naming the provider', (lifecycle, message) => {
mocks.launchLifecycle = lifecycle
render(sessionView())
expect(screen.getByText(message)).toBeTruthy()
expect(screen.queryByText(/Starting (Claude|Codex) chat/i)).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
expect(mocks.retryLaunch).toHaveBeenCalledWith('wt-1', 'session-1')
})
it('keys a floating chat launch by its owner even before any path context exists', () => {
mocks.ownerWorktreeId = FLOATING_TERMINAL_WORKTREE_ID
mocks.fileLinkContext = null
mocks.launchLifecycle = 'failed'
render(sessionView())
expect(mocks.lifecycleLookup).toHaveBeenCalledWith(FLOATING_TERMINAL_WORKTREE_ID, 'session-1')
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
expect(mocks.retryLaunch).toHaveBeenCalledWith(FLOATING_TERMINAL_WORKTREE_ID, 'session-1')
})
it('words why a failed launch failed beside Retry from the refusal, never its code', () => {
mocks.launchLifecycle = 'failed'
mocks.launchFailure = NOT_SIGNED_IN
render(sessionView())
expect(screen.getByText(`Chat could not be started. ${NOT_SIGNED_IN_TEXT}`)).toBeTruthy()
expect(screen.queryByText(/agent_session_/)).toBeNull()
})
it('renders the host-composed auth startup diagnostic beside Retry', () => {
const message = 'Sign in to Grok with `grok login`. Provider diagnostic: {{agent}} key expired.'
mocks.launchLifecycle = 'failed'
mocks.launchFailure =
structuredLaunchFailure(
new StructuredAgentSessionCreateRefusalError(message, 'agent_session_operation_invalid', {
code: 'agent_session_operation_invalid',
details: { reason: 'notSignedIn' }
})
) ?? null
render(sessionView('grok'))
expect(screen.getByText(`Chat could not be started. ${message}`)).toBeTruthy()
expect(screen.queryByText(/send your message again/i)).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
expect(mocks.retryLaunch).toHaveBeenCalledWith('wt-1', 'session-1')
})
it("keeps a step the Retry doesn't take, and drops one it does", () => {
mocks.launchLifecycle = 'failed'
mocks.launchFailure = {
kind: 'refused',
code: 'agent_session_conflict',
details: { reason: 'claimConflicted' }
}
const { rerender } = render(sessionView())
expect(
screen.getByText(
'Chat could not be started. This chat is still open in a terminal agent. Quit that agent to continue the chat here.'
)
).toBeTruthy()
mocks.launchFailure = {
kind: 'refused',
code: 'agent_session_journal_unreadable',
details: { reason: 'journalUnavailable' }
}
rerender(sessionView())
expect(
screen.getByText(
"Chat could not be started. Orca couldn't open this chat's history right now."
)
).toBeTruthy()
})
it('says only that the chat could not start when the refusal names no reason', () => {
mocks.launchLifecycle = 'failed'
mocks.launchFailure = { kind: 'refused', code: 'agent_session_operation_invalid' }
render(sessionView())
expect(screen.getByText('Chat could not be started.')).toBeTruthy()
expect(screen.queryByText(/agent_session_/)).toBeNull()
})
it('shows the saved Arguments cause and correction beside launch Retry', () => {
mocks.launchLifecycle = 'failed'
mocks.launchFailure = agentSessionRefusalFailure({
code: 'agent_session_operation_invalid',
details: {
reason: 'attachFailed',
argumentProblem: { agent: 'Codex', option: '--remote', problem: 'unsupportedOption' }
}
})
render(sessionView())
expect(
screen.getByText(
"Codex couldn't start. Saved Arguments contain an unsupported option (--remote). Edit them in Settings > Agents > Arguments."
)
).toBeTruthy()
expect(screen.queryByText('Chat could not be started.')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
expect(mocks.retryLaunch).toHaveBeenCalledWith('wt-1', 'session-1')
})
it('keeps a stale reason off a launch that is no longer failed', () => {
mocks.launchLifecycle = 'visibility-unknown'
mocks.launchFailure = NOT_SIGNED_IN
render(sessionView())
expect(screen.getByText('Chat connection could not be confirmed.')).toBeTruthy()
})
it('relaunches a failed start on send, with the message as its first message', () => {
mocks.launchLifecycle = 'failed'
render(sessionView())
expect(composerSend()('restart and say hi', [])).toBe(true)
// The relaunch is launch Retry's own: a new create operation under the same session.
expect(mocks.retryLaunch).toHaveBeenCalledExactlyOnceWith('wt-1', 'session-1')
expect(mocks.relaunchWithMessage).toHaveBeenCalledWith(
'wt-1',
'session-1',
'restart and say hi'
)
expect(mocks.send).not.toHaveBeenCalled()
})
it.each(['staged file', 'selected skill'] as const)(
'keeps a %s draft when Send retries a failed chat',
(kind) => {
mocks.launchLifecycle = 'failed'
render(sessionView())
const prepareAttachments = vi.fn(async (input: { text: string }) => input)
const request =
kind === 'staged file'
? { prepareAttachments }
: { skills: [{ name: 'review', path: '/skills/review/SKILL.md' }] }
expect(composerSend()('@"browser-file:context.txt"', [], request)).toBe(false)
expect(mocks.retryLaunch).toHaveBeenCalledExactlyOnceWith('wt-1', 'session-1')
expect(mocks.relaunchWithMessage).not.toHaveBeenCalled()
expect(mocks.send).not.toHaveBeenCalled()
expect(prepareAttachments).not.toHaveBeenCalled()
}
)
it.each(['pending', 'visibility-unknown'] as const)(
'takes no send while the chat is %s: Send is off and the text stays',
(lifecycle) => {
mocks.launchLifecycle = lifecycle
render(sessionView())
expect(composerSend()('sent while starting', [])).toBe(false)
expect(mocks.composerProps?.structuredTransport?.sendOut).toBe(true)
expect(mocks.retryLaunch).not.toHaveBeenCalled()
expect(mocks.send).not.toHaveBeenCalled()
}
)
it('brings the latest into view when launch Retry relaunches a failed chat', async () => {
mocks.launchLifecycle = 'failed'
render(sessionView())
await screen.findByRole('button', { name: 'Retry' })
mocks.revealLatest.mockClear()
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
expect(mocks.revealLatest).toHaveBeenCalledOnce()
})
it.each([null, 'published'] as const)(
'enables provider transport for lifecycle %s',
(lifecycle) => {
mocks.launchLifecycle = lifecycle
render(sessionView())
expect(mocks.controllerProps).toMatchObject({ transportEnabled: true })
expect(screen.queryByRole('button', { name: 'Retry' })).toBeNull()
}
)
})
@@ -1,16 +1,10 @@
import * as terminalThemeSelection from '../../../../shared/terminal-themes/terminal-theme-selection'
import { getDefaultSettings } from '../../../../shared/constants'
// @vitest-environment happy-dom
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { AgentJournalRenderItem } from '../../../../shared/agent-session/journal/agent-session-journal-types'
import { useAppStore } from '@/store'
import { TooltipProvider } from '@/components/ui/tooltip'
import {
claudeGroupedQuestionPromptItems,
legacySingleQuestionPromptItems
} from './native-chat-structured-question-test-fixtures'
const { mocks, moduleFactories, resetStructuredSessionMocks } = await vi.hoisted(async () =>
(await import('./NativeChatStructuredSession.test-harness')).createStructuredSessionMocks()
@@ -31,8 +25,6 @@ vi.mock('./NativeChatApprovalCard', () => moduleFactories.nativeChatApprovalCard
vi.mock('./NativeChatQuestionCard', () => moduleFactories.nativeChatQuestionCard())
import { NativeChatStructuredSession } from './NativeChatStructuredSession'
import { structuredAgentSessionPaneKey } from '../../../../shared/agent-session/view/structured-agent-session-projection'
import { structuredAgentSessionDraftScopeKey } from './native-chat-composer-draft-store'
describe('NativeChatStructuredSession', () => {
it('isolates mounted chats from unrelated settings and renders their live follow-up choice', async () => {
@@ -88,385 +80,12 @@ describe('NativeChatStructuredSession', () => {
}
})
it('applies persisted appearance at the chat root and updates it live', () => {
const original = useAppStore.getState().settings
useAppStore.setState({
settings: {
...getDefaultSettings('/tmp'),
theme: 'dark',
nativeChatAppearance: {
fontSize: 18,
codeFontSize: 11,
width: 'wide',
matchTerminalInterface: true,
contrast: 120
},
terminalFontFamily: 'Consolas',
terminalFontSize: 16,
terminalColorOverrides: { background: '#112233', foreground: '#ddeeff' }
}
})
const { container } = render(
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="appearance-tab"
sessionId="appearance-session"
target={{ kind: 'local' }}
agent="codex"
/>
)
const root = container.querySelector<HTMLElement>('[data-native-chat-root]')
expect(root?.style.colorScheme).toBe('dark')
expect(root?.style.color).toBe('var(--foreground)')
expect(root?.dataset.nativeChatScheme).toBe('dark')
expect(root?.style.getPropertyValue('--chat-source-background')).toBe('#112233')
expect(root?.style.getPropertyValue('--chat-source-foreground')).toBe('#ddeeff')
expect(root?.style.getPropertyValue('--chat-code-font-family')).toContain('Consolas')
expect(root?.style.getPropertyValue('--chat-font-family')).toContain('Consolas')
expect(root?.style.getPropertyValue('--chat-foreground-mix')).toBe('100%')
expect(root?.style.getPropertyValue('--chat-font-size')).toBe('16px')
expect(root?.style.getPropertyValue('--chat-code-font-size')).toBe('16px')
expect(root?.style.getPropertyValue('--chat-content-max-width')).toBe('60rem')
act(() =>
useAppStore.setState({
settings: {
...getDefaultSettings('/tmp'),
theme: 'dark',
nativeChatAppearance: { width: 'full' }
}
})
)
expect(root?.style.colorScheme).toBe('')
expect(root?.style.color).toBe('')
expect(root?.dataset.nativeChatScheme).toBeUndefined()
expect(root?.style.getPropertyValue('--chat-source-background')).toBe('')
expect(root?.style.getPropertyValue('--chat-source-foreground')).toBe('')
expect(root?.style.getPropertyValue('--chat-font-family')).toBe('')
expect(root?.style.getPropertyValue('--chat-foreground-mix')).toBe('78%')
expect(root?.style.getPropertyValue('--chat-font-size')).toBe('14px')
expect(root?.style.getPropertyValue('--chat-content-max-width')).toBe('none')
act(() => useAppStore.setState({ settings: original }))
})
it('skips appearance work on unrelated settings writes and updates the root for appearance changes', () => {
const original = useAppStore.getState().settings
const settings = {
...getDefaultSettings('/tmp'),
theme: 'dark' as const,
nativeChatAppearance: { matchTerminalInterface: true }
}
useAppStore.setState({ settings })
const resolveColors = vi.spyOn(terminalThemeSelection, 'resolveConfiguredTerminalColors')
const view = render(
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="appearance-subscription-tab"
sessionId="appearance-subscription-session"
target={{ kind: 'local' }}
agent="codex"
/>
)
const initialResolutions = resolveColors.mock.calls.length
act(() =>
useAppStore.setState({
settings: { ...settings, terminalFontSize: settings.terminalFontSize + 1 }
})
)
expect(resolveColors).toHaveBeenCalledTimes(initialResolutions + 1)
expect(
view.container
.querySelector<HTMLElement>('[data-native-chat-root]')
?.style.getPropertyValue('--chat-font-size')
).toBe(`${settings.terminalFontSize + 1}px`)
act(() => useAppStore.setState({ settings: { ...settings, terminalFontFamily: 'Menlo' } }))
expect(resolveColors).toHaveBeenCalledTimes(initialResolutions + 2)
expect(
view.container
.querySelector<HTMLElement>('[data-native-chat-root]')
?.style.getPropertyValue('--chat-code-font-family')
).toContain('Menlo')
view.unmount()
useAppStore.setState({ settings: original })
})
afterEach(() => {
cleanup()
resetStructuredSessionMocks()
vi.restoreAllMocks()
})
it("gives the composer this pane shows the conversation's own draft, and Stop returns text there", () => {
render(
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="structured-tab-1"
sessionId="session-1"
target={{ kind: 'local' }}
agent="codex"
/>
)
const paneKey = structuredAgentSessionPaneKey('structured-tab-1', 'session-1')
// The pane routes drops and pickers; the draft belongs to the conversation, whatever pane shows it.
expect(mocks.composerProps).toMatchObject({
paneKey,
draftScopeKey: structuredAgentSessionDraftScopeKey('session-1')
})
expect(mocks.controllerProps).toMatchObject({
composerScopeKey: structuredAgentSessionDraftScopeKey('session-1')
})
})
it('routes app-menu paste to the structured composer', () => {
render(
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="draft-tab"
sessionId="draft-session"
target={{ kind: 'local' }}
agent="codex"
/>
)
const composer = screen.getByTestId('structured-composer')
composer.focus()
window.dispatchEvent(new Event('orca-app-menu-paste', { cancelable: true }))
expect(mocks.pasteFromClipboard).toHaveBeenCalledOnce()
})
it('focuses the chat on a click outside its controls and sends typing there to the composer', () => {
const { container } = render(
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="structured-tab-1"
sessionId="session-1"
target={{ kind: 'local' }}
agent="claude"
/>
)
const root = container.querySelector('[data-native-chat-root]')
if (!(root instanceof HTMLElement)) {
throw new Error('chat root missing')
}
fireEvent.pointerDown(root, { button: 0 })
expect(document.activeElement).toBe(root)
expect(fireEvent.keyDown(root, { key: 'h' })).toBe(false)
})
it('wires remote structured file links through the host-aware native chat opener', () => {
render(
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="structured-tab-1"
sessionId="session-1"
target={{ kind: 'environment', environmentId: 'env-1' }}
agent="codex"
/>
)
expect(mocks.messageListProps?.allowFileUriLinks).toBe(true)
const event = { preventDefault: vi.fn(), stopPropagation: vi.fn() }
mocks.messageListProps?.onLinkClick?.(event, 'file:///repo/src/a.ts')
expect(mocks.fileLinkClick).toHaveBeenCalledWith(event, 'file:///repo/src/a.ts')
})
// Visibility defaults true in the list; dropping the prop can rearm following for a hidden detached pane.
it.each([true, false])('tells the transcript the pane is visible: %s', (isVisible) => {
render(
<NativeChatStructuredSession
isVisible={isVisible}
isFocusedGroup
tabId="structured-tab-visibility"
sessionId="session-visibility"
target={{ kind: 'local' }}
agent="codex"
/>
)
expect(mocks.messageListProps?.isVisible).toBe(isVisible)
})
// Forward both page results and generation so failed paging can rearm after reset.
it('hands the list the controller older-history state, generation, and page result', async () => {
mocks.hasOlder = true
mocks.loadingOlder = true
mocks.olderHistoryGeneration = 3
mocks.loadOlder.mockResolvedValueOnce('failed')
render(
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="structured-tab-older"
sessionId="session-older"
target={{ kind: 'local' }}
agent="codex"
/>
)
expect(mocks.messageListProps?.session).toMatchObject({
hasMore: true,
loadingEarlier: true,
olderHistoryGeneration: 3
})
await expect(mocks.messageListProps?.session?.loadEarlier()).resolves.toBe('failed')
expect(mocks.loadOlder).toHaveBeenCalledOnce()
})
// What the transcript marks as new rests on this: without it the list never counts as watched.
it('tells the list whether the read has caught up with the host', () => {
// A fresh element each time: React skips a rerender handed the very same one.
const pane = () => (
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="structured-tab-current"
sessionId="session-current"
target={{ kind: 'local' }}
agent="codex"
/>
)
const { rerender } = render(pane())
expect(mocks.messageListProps?.session).toMatchObject({ readCurrent: false })
mocks.readCurrent = true
rerender(pane())
expect(mocks.messageListProps?.session).toMatchObject({ readCurrent: true })
})
// Image runtime context comes from the tab and must remain available for every supported agent.
it('hands the transcript the image runtime context', () => {
render(
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="structured-tab-parity"
sessionId="session-parity"
target={{ kind: 'local' }}
agent="codex"
/>
)
expect(mocks.messageListProps?.runtimeContext).not.toBeUndefined()
})
it('suppresses live turn activity for a pending question without ending the turn', () => {
mocks.isWorking = true
mocks.turnId = 'turn-question'
mocks.promptItems = legacySingleQuestionPromptItems
const view = () => (
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="structured-question"
sessionId="session-question"
target={{ kind: 'local' }}
agent="codex"
/>
)
const { rerender } = render(view())
expect(mocks.messageListProps).toMatchObject({
isWorking: true,
awaitingInput: 'shown'
})
expect(
document
.querySelector('[data-native-chat-root="true"]')
?.getAttribute('data-native-chat-working')
).toBe('true')
expect(mocks.questionCardProps).not.toBeNull()
expect(screen.queryByTestId('structured-composer')).toBeNull()
act(() => mocks.questionCardProps?.onCancel())
expect(mocks.cancel).toHaveBeenCalledWith('turn-question', {
itemId: 'legacy-question-item',
expectedRevision: 1
})
expect(mocks.messageListProps?.awaitingInput).toBe('shown')
mocks.promptItems = []
rerender(view())
expect(mocks.messageListProps).toMatchObject({
isWorking: true,
awaitingInput: null
})
expect(screen.getByTestId('structured-composer')).toBeTruthy()
expect(mocks.composerProps?.isWorking).toBe(true)
})
it('suppresses live turn activity for a pending approval but keeps background work visible', async () => {
const approvalItems: AgentJournalRenderItem[] = [
{
itemId: 'approval-item',
revision: 1,
sequence: 1,
observedAt: 1,
body: {
kind: 'approval',
title: 'Allow command?',
blockedPath: '/outside/repo/.git/config',
matchedAskRule: { source: 'projectSettings', toolName: 'Bash' },
detail: 'pnpm test',
options: [
{ id: 'allow', label: 'Allow' },
{ id: 'deny', label: 'Deny' }
],
resolution: {
state: 'pending',
selectedOptionId: null,
resolvedBy: null,
resolvedAt: null
}
}
}
]
mocks.isWorking = true
mocks.turnId = 'turn-approval'
mocks.promptItems = approvalItems
mocks.monitoringBackgroundTasks = true
render(
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="structured-approval"
sessionId="session-approval"
target={{ kind: 'local' }}
agent="claude"
/>
)
expect(mocks.messageListProps).toMatchObject({
isWorking: true,
awaitingInput: 'shown'
})
expect(mocks.approvalCardProps?.approval.title).toBe('Allow command?')
// The card decides whether to show the path; the ask rule never reaches it.
expect(mocks.approvalCardProps?.approval.blockedPath).toBe('/outside/repo/.git/config')
expect(mocks.approvalCardProps?.approval).not.toHaveProperty('matchedAskRule')
expect(screen.queryByTestId('structured-composer')).toBeNull()
expect(document.querySelector('[data-native-chat-background-tasks="true"]')).not.toBeNull()
act(() => mocks.approvalCardProps?.onChoose('allow'))
expect(mocks.respond).toHaveBeenCalledWith(approvalItems[0], {
kind: 'option',
optionId: 'allow'
})
await waitFor(() => expect(mocks.revealLatest).toHaveBeenCalledOnce())
expect(mocks.messageListProps?.awaitingInput).toBe('shown')
act(() => mocks.approvalCardProps?.onCancel?.())
expect(mocks.cancel).toHaveBeenCalledWith('turn-approval', {
itemId: 'approval-item',
expectedRevision: 1
})
})
// Create fresh elements for rerenders; React can skip an identical element.
const claudeSessionView = (tabId: string, sessionId: string) => (
<NativeChatStructuredSession
@@ -525,43 +144,6 @@ describe('NativeChatStructuredSession', () => {
expect(screen.getByRole('list', { name: 'Agents' })).toBeTruthy()
})
it('offers Stop before any turn opens when the controller can stop, and stops through it', () => {
mocks.canStop = true
render(claudeSessionView('structured-tab-pre-turn', 'session-pre-turn'))
expect(mocks.composerProps?.isWorking).toBe(true)
act(() => mocks.composerProps?.onStop?.())
expect(mocks.stop).toHaveBeenCalledOnce()
expect(mocks.cancel).not.toHaveBeenCalled()
})
it("shows the queue's coming send as a Stop that is not live until a turn can be stopped", () => {
mocks.queueSendsNext = true
render(claudeSessionView('structured-tab-sends-next', 'session-sends-next'))
expect(mocks.composerProps?.isWorking).toBe(true)
expect(mocks.composerProps?.onStop).toBeUndefined()
})
it('keeps the strip mounted through a running turn, with the turn owning the voice', () => {
// Background work remains mounted during a turn, while the turn owns the live indicator.
mocks.showBackgroundTasks = true
mocks.monitoringBackgroundTasks = false
mocks.isWorking = true
mocks.turnId = 'turn-midturn'
mocks.backgroundTasks = [{ id: 'task-monitor', kind: 'monitor', description: 'watcher' }]
render(claudeSessionView('structured-tab-midturn', 'session-midturn'))
const status = document.querySelector('[data-native-chat-background-tasks="true"]')
if (!status) {
throw new Error('background task status was not rendered during a running turn')
}
expect(mocks.composerProps?.isWorking).toBe(true)
expect(status.querySelector('.lucide-activity')?.classList).toContain('text-yellow-500/40')
fireEvent.click(screen.getByRole('button', { name: '1 monitor — monitoring' }))
expect(screen.getByText('watcher')).toBeTruthy()
})
it('tracks concurrent task stops independently: a confirmed one holds its row, a failed one clears', async () => {
mocks.monitoringBackgroundTasks = true
mocks.supportsBackgroundTaskStop = true
@@ -652,223 +234,4 @@ describe('NativeChatStructuredSession', () => {
await act(async () => finishCurrent({ cancelled: false }))
await waitFor(() => expect((currentStop as HTMLButtonElement).disabled).toBe(false))
})
it('keeps the expanded all-task stop fallback for a taskless older host', async () => {
mocks.monitoringBackgroundTasks = true
mocks.stopBackgroundTask.mockResolvedValue({ cancelled: true })
render(claudeSessionView('structured-tab-taskless-background', 'session-taskless-background'))
expect(screen.queryByRole('button', { name: 'Stop background tasks' })).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Monitoring background tasks' }))
expect(screen.getByText('Task details are unavailable for this session.')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Stop background tasks' }))
await waitFor(() =>
expect(mocks.stopBackgroundTask).toHaveBeenCalledWith(
'session-taskless-background',
undefined
)
)
})
it('routes a bare model command to the native option picker', async () => {
render(
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="structured-tab-1"
sessionId="session-1"
target={{ kind: 'local' }}
agent="codex"
/>
)
const dispatchCommand = mocks.composerProps?.structuredTransport?.dispatchCommand as
| ((text: string) => Promise<{ accepted: boolean }>)
| undefined
await act(async () => {
await expect(dispatchCommand?.('/model')).resolves.toMatchObject({ accepted: true })
})
const request = mocks.composerProps?.structuredTransport?.optionPickerRequest
expect(request).toMatchObject({ id: 'model' })
act(() => request?.consume())
expect(mocks.composerProps?.structuredTransport?.optionPickerRequest).toBeNull()
})
it('passes Claude grouped questions and one shared answer through the card', () => {
mocks.promptItems = claudeGroupedQuestionPromptItems
render(
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="structured-tab-questions"
sessionId="session-questions"
target={{ kind: 'local' }}
agent="claude"
/>
)
const card = mocks.questionCardProps
if (!card) {
throw new Error('question card was not rendered')
}
expect(card.prompt.questions).toHaveLength(2)
expect(card.prompt.questions[0]).toMatchObject({
question: 'Which targets?',
multiSelect: true,
options: [{ label: 'Web' }, { label: 'Mobile' }]
})
expect(card.allowOther).toEqual([true, true])
card.onAnswer([
{ indices: [0, 1], other: '' },
{ indices: [], other: 'SSH host' }
])
expect(mocks.respond).toHaveBeenCalledWith(mocks.promptItems[0], {
kind: 'answers',
answers: [
{ questionId: 'q1', optionIds: ['target-web', 'target-mobile'] },
{ questionId: 'q2', optionIds: [], other: 'SSH host' }
]
})
})
it('answers a single-question item as its one question', () => {
mocks.promptItems = legacySingleQuestionPromptItems
render(
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="structured-tab-legacy-question"
sessionId="session-legacy-question"
target={{ kind: 'local' }}
agent="claude"
/>
)
const card = mocks.questionCardProps
if (!card) {
throw new Error('question card was not rendered')
}
expect(card.prompt.questions).toEqual([
{
question: 'Pick a library',
multiSelect: false,
options: [{ label: 'React' }, { label: 'Vue' }]
}
])
card.onAnswer([{ indices: [1], other: '' }])
expect(mocks.respond).toHaveBeenLastCalledWith(mocks.promptItems[0], {
kind: 'answers',
answers: [{ questionId: 'q1', optionIds: ['q1:choice-2'] }]
})
card.onAnswer([{ indices: [], other: ' Svelte ' }])
expect(mocks.respond).toHaveBeenLastCalledWith(mocks.promptItems[0], {
kind: 'answers',
answers: [{ questionId: 'q1', optionIds: [], other: 'Svelte' }]
})
})
it('gives a question card the keyboard, holds it while its answer is with the host, and frees it on a refusal', async () => {
mocks.promptItems = legacySingleQuestionPromptItems
render(
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="structured-tab-hold"
sessionId="session-hold"
target={{ kind: 'local' }}
agent="claude"
/>
)
let refuse: (result: null) => void = () => {}
mocks.respond.mockReturnValueOnce(
new Promise<null>((resolve) => {
refuse = resolve
})
)
// The card owns this pane's keyboard, which is what turns its number keys on.
expect(mocks.questionCardProps?.shouldFocus).toBe(true)
act(() => mocks.questionCardProps?.onAnswer([{ indices: [1], other: '' }]))
expect(mocks.questionCardProps?.isSubmitting).toBe(true)
await act(async () => refuse(null))
expect(mocks.questionCardProps?.isSubmitting).toBe(false)
})
it('brings the latest into view at the press for the submits this pane makes', async () => {
mocks.promptItems = legacySingleQuestionPromptItems
const { rerender } = render(
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="structured-tab-reveal"
sessionId="session-reveal"
target={{ kind: 'local' }}
agent="claude"
/>
)
// An answer reveals as it is pressed, before the host decides on it.
mocks.respond.mockReturnValueOnce(new Promise(() => {}))
mocks.questionCardProps?.onAnswer([{ indices: [1], other: '' }])
expect(mocks.respond).toHaveBeenCalledOnce()
expect(mocks.revealLatest).toHaveBeenCalledOnce()
mocks.promptItems = []
rerender(
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="structured-tab-reveal"
sessionId="session-reveal"
target={{ kind: 'local' }}
agent="claude"
/>
)
const steerHead = mocks.composerProps?.steerHead
const onSubmitted = mocks.composerProps?.structuredTransport?.onSubmitted
if (!steerHead || typeof onSubmitted !== 'function') {
throw new Error('Structured composer was not wired')
}
mocks.revealLatest.mockClear()
expect(steerHead()).toBe(false)
expect(mocks.revealLatest).not.toHaveBeenCalled()
// The composer's sends reveal through its transport.
onSubmitted()
expect(mocks.revealLatest).toHaveBeenCalledOnce()
mocks.queuedSteerHead.mockReturnValue(true)
expect(steerHead()).toBe(true)
expect(mocks.revealLatest).toHaveBeenCalledTimes(2)
})
it("brings the latest into view when a queued card's Steer sends it now", () => {
mocks.isWorking = true
mocks.queuedCards = [
{ messageId: 'draft-1', position: 1, text: 'Also check SSH', state: 'waiting', hold: 'turn' }
]
render(
<TooltipProvider>
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="structured-tab-queue"
sessionId="session-queue"
target={{ kind: 'local' }}
agent="claude"
/>
</TooltipProvider>
)
fireEvent.click(screen.getByRole('button', { name: /Steer/ }))
expect(mocks.queuedSteer).toHaveBeenCalledWith('draft-1')
expect(mocks.revealLatest).toHaveBeenCalledOnce()
})
})