From fd04f150262a5b84f45463acaee607c6ec0cc240 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Wed, 4 Nov 2026 23:02:33 -0800 Subject: [PATCH] test: remove low-value chat session and picker tests (#38417) Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- config/flaky-test-pattern-baseline.txt | 1 - .../scripts/ci/sharding/ci-shard-timings.json | 3 - ...SessionOptionPickers.focus-return.test.tsx | 452 ------------ .../NativeChatSessionOptionPickers.test.tsx | 664 +----------------- ...eChatStructuredAsyncQuestion.send.test.tsx | 320 +-------- ...ession.approval-and-start-failure.test.tsx | 180 +---- ...veChatStructuredSession.goal-dock.test.tsx | 305 -------- ...ucturedSession.history-read-state.test.tsx | 166 +---- ...ChatStructuredSession.host-outage.test.tsx | 91 --- ...tructuredSession.launch-lifecycle.test.tsx | 394 ----------- .../NativeChatStructuredSession.test.tsx | 639 +---------------- 11 files changed, 10 insertions(+), 3205 deletions(-) delete mode 100644 src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.focus-return.test.tsx delete mode 100644 src/renderer/src/components/native-chat/NativeChatStructuredSession.goal-dock.test.tsx delete mode 100644 src/renderer/src/components/native-chat/NativeChatStructuredSession.launch-lifecycle.test.tsx diff --git a/config/flaky-test-pattern-baseline.txt b/config/flaky-test-pattern-baseline.txt index ac37046fe56..16379f20e46 100644 --- a/config/flaky-test-pattern-baseline.txt +++ b/config/flaky-test-pattern-baseline.txt @@ -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 diff --git a/config/scripts/ci/sharding/ci-shard-timings.json b/config/scripts/ci/sharding/ci-shard-timings.json index f80a5328623..c0b7e7b05c9 100644 --- a/config/scripts/ci/sharding/ci-shard-timings.json +++ b/config/scripts/ci/sharding/ci-shard-timings.json @@ -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, diff --git a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.focus-return.test.tsx b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.focus-return.test.tsx deleted file mode 100644 index 2876a7109a0..00000000000 --- a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.focus-return.test.tsx +++ /dev/null @@ -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) => - 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 => - 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( - - - - ) - const trigger = screen.getByRole('button', { name: 'Model' }) - expect(trigger).toHaveProperty('disabled', true) - fireEvent.click(trigger) - fireEvent.keyDown(trigger, { key: 'ArrowDown' }) - view.rerender( - - - - ) - await settle() - expect(consume).toHaveBeenCalledOnce() - expect(screen.queryByRole('dialog')).toBeNull() - view.rerender( - - - - ) - 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 => - 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 { - const messageBox = document.createElement('textarea') - render( - - messageBox.focus()} - /> - - ) - 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({ - id: props.requestId, - consume: () => setRequest(null) - }) - return ( - - {props.composerShown === false ? null : ( - - )} - - ) -} - -const pill = (): HTMLElement => screen.getByRole('button', { name: /^Model / }) - -const openPicker = async (): Promise => { - const trigger = pill() - fireEvent.click(trigger) - await settle() - return trigger -} - -const pressEscape = async (): Promise => { - 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() - await settle() - expect(screen.queryByRole('dialog')).not.toBeNull() - - rerender() - await settle() - expect(screen.queryByRole('button', { name: /^Model / })).toBeNull() - rerender() - await settle() - expect(screen.queryByRole('dialog')).toBeNull() - }) - - it('focuses the effort for /effort once, not again after a prompt card', async () => { - const { rerender } = render() - await settle() - expect(document.activeElement).toBe(screen.getByRole('radio', { name: 'Low' })) - - rerender() - await settle() - rerender() - 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.') - }) -}) diff --git a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx index 4fd0ed90b6a..7d4fe0633e5 100644 --- a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx @@ -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( - '../../../../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 }) =>
{children}
})) -// 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 }) => (
@@ -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 => 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( - - ) - expect(isOpen(pill('Model Opus 4.8, Effort High'))).toBe(false) - expect(demandModelCatalog).not.toHaveBeenCalled() - - rerender( - {} }} - /> - ) - 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( - - ) - expect(isOpen(pill('Model Opus 4.8, Effort High'))).toBe(true) - - rerender( - - ) - 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({ - id: 'model', - consume: () => setRequest(null) - }) - return props.composerShown === false ? null : ( - - ) - } - const { rerender } = render() - await waitFor(() => expect(isOpen(pill('Model Opus 4.8, Effort High'))).toBe(true)) - - rerender() - expect(screen.queryByRole('button', { name: /^Model/ })).toBeNull() - rerender() - 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( - - ) - 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( - - ) - - 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( - - ) - expect(pill('Model Opus 4.8, Effort High · Fast').textContent).toBe('Opus 4.8High · Fast') - - rerender( - - ) - expect(pill('Model Opus 4.8').textContent).toBe('Opus 4.8') - - rerender( - - ) - 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( - - ) - 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( - - ) - expect(screen.queryByRole('button', { name: /^Model/ })).toBeNull() - expect(screen.queryAllByRole('button', { name: '' })).toEqual([]) - }) - - it('names the control once when the model is unknown', () => { - render( - - ) - 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( - - ) - expect(screen.getAllByText('Sent to the agent — not confirmed').length).toBeGreaterThan(0) - }) - - it('does not hedge a dispatched value the structured transport produced', () => { - render( - - ) - expect(screen.queryByText(/not confirmed/)).toBeNull() - }) - - it.each(['catalog'] as const)( - 'does not hedge a reported value on the %s transport', - (transport) => { - render( - - ) - expect(screen.queryByText(/not confirmed/)).toBeNull() - } - ) - - it('hedges the pill for a dispatched option as it does for the model', () => { - render( - - ) - 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( - - ) - 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( - - ) - 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( - - ) - // 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( - - ) - 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( - - ) - 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( - - ) - 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( - - ) - 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( - - ) - 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( - - ) - 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( - {} }} - /> - ) - 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( - - ) - rerender( - - ) - 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) - }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredAsyncQuestion.send.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredAsyncQuestion.send.test.tsx index a5c5763d927..a228dbec571 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredAsyncQuestion.send.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredAsyncQuestion.send.test.tsx @@ -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 @@ -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 ( - sends.send(text, [], request)} - /> - ) - } - act(() => root.render()) - 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('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( - - ) - ) - expect(selected.disabled).toBe(true) - act(() => - root.render( - - ) - ) - } else { - act(() => root.render()) - } - 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 ( - - sends.send(text, [], { - ...request, - onSettled: (outcome) => { - completed(outcome) - request?.onSettled?.(outcome) - } - }) - } - /> - ) - } - act(() => root.render()) - 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('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('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( - - ) - ) - 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((_text, _images, request) => { diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.approval-and-start-failure.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.approval-and-start-failure.test.tsx index 134a3f65d37..017ad1c0472 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.approval-and-start-failure.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.approval-and-start-failure.test.tsx @@ -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( - - ) - 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( - - ) - mocks.approvalCardProps?.onCancel?.() - expect(mocks.cancel).not.toHaveBeenCalled() - }) - - it('cancels a Pi extension dialog outside a model turn', () => { - mocks.promptItems = [NEWER_APPROVAL] - render( - - ) - mocks.approvalCardProps?.onCancel?.() - expect(mocks.cancel).toHaveBeenCalledWith(undefined, { - itemId: 'approval-item', - expectedRevision: 3 - }) - }) - - function renderSession() { - render( - - ) - } - - 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') - } - ) } diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.goal-dock.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.goal-dock.test.tsx deleted file mode 100644 index a6074311120..00000000000 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.goal-dock.test.tsx +++ /dev/null @@ -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()), - 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()), - 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 ( - - - - ) -} - -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)) - }) -}) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.history-read-state.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.history-read-state.test.tsx index 864f3ac3721..ae633916070 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.history-read-state.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.history-read-state.test.tsx @@ -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( - - - - ) - } - - 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() }) } diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.host-outage.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.host-outage.test.tsx index 164f89c9695..09eaa88d2ee 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.host-outage.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.host-outage.test.tsx @@ -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() -}) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.launch-lifecycle.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.launch-lifecycle.test.tsx deleted file mode 100644 index 08e4abf80a5..00000000000 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.launch-lifecycle.test.tsx +++ /dev/null @@ -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()), - 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()), - 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 ( - - ) -} - -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(
{sessionView()}
) - 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() - } - ) -}) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx index 77b187e2039..4b2762e4817 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx @@ -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( - - ) - const root = container.querySelector('[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( - - ) - const initialResolutions = resolveColors.mock.calls.length - act(() => - useAppStore.setState({ - settings: { ...settings, terminalFontSize: settings.terminalFontSize + 1 } - }) - ) - expect(resolveColors).toHaveBeenCalledTimes(initialResolutions + 1) - expect( - view.container - .querySelector('[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('[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( - - ) - 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( - - ) - 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( - - ) - 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( - - ) - - 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( - - ) - - 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( - - ) - - 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 = () => ( - - ) - 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( - - ) - - 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 = () => ( - - ) - 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( - - ) - - 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) => ( { 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( - - ) - 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( - - ) - - 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( - - ) - - 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( - - ) - let refuse: (result: null) => void = () => {} - mocks.respond.mockReturnValueOnce( - new Promise((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( - - ) - // 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( - - ) - 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( - - - - ) - - fireEvent.click(screen.getByRole('button', { name: /Steer/ })) - - expect(mocks.queuedSteer).toHaveBeenCalledWith('draft-1') - expect(mocks.revealLatest).toHaveBeenCalledOnce() - }) })