mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 10:27:33 +00:00
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:
@@ -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,
|
||||
|
||||
-452
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
+2
-318
@@ -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
-179
@@ -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')
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
-305
@@ -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))
|
||||
})
|
||||
})
|
||||
+1
-165
@@ -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()
|
||||
})
|
||||
}
|
||||
|
||||
-91
@@ -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()
|
||||
})
|
||||
|
||||
-394
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user