test: remove low-value attachment and tool presentation tests (#38420)

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
OrcaWin
2026-11-04 23:08:45 -08:00
committed by GitHub
co-authored by OrcaWin
parent fd04f15026
commit ffff2d9ea9
9 changed files with 7 additions and 2741 deletions
@@ -1,502 +0,0 @@
// @vitest-environment happy-dom
import { useRef, useMemo } from 'react'
import { NativeChatMarkdownImageOwnerContext } from './native-chat-markdown-image-owner'
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import { NativeChatPromptEditor } from './NativeChatPromptEditor'
import {
NativeChatTextAttachmentScope,
NativeChatTextAttachments
} from './NativeChatTextAttachments'
import { useNativeChatPastedTextFile } from './use-native-chat-pasted-text-file'
import {
insertNativeChatPastedText,
type NativeChatComposerInput
} from './native-chat-composer-input'
import { promptEditor, promptValue } from './native-chat-prompt-editor.test-support'
import {
clearNativeChatComposerDraftsForTests,
readNativeChatComposerDraft,
updateNativeChatComposerDraft
} from './native-chat-composer-draft-store'
import { resolveNativeChatPastedText } from './native-chat-composer-pasted-text-references'
import { nativeChatPastedTextOperations } from './native-chat-pasted-text-reference'
const mocks = vi.hoisted(() => ({
save: vi.fn(),
rpc: vi.fn(),
write: vi.fn(),
resolved: false,
start: vi.fn(),
append: vi.fn(),
finish: vi.fn(),
cancel: vi.fn()
}))
vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: mocks.rpc }))
vi.mock('./native-chat-visual-markdown-extension', () => ({
useNativeChatVisualMarkdownExtension: () => undefined
}))
vi.mock('./use-native-chat-file-link-existence', () => ({
useNativeChatFileLinkExists: () => undefined
}))
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
vi.mock('./NativeChatAutocompleteMenus', () => ({
NativeChatMentionMenu: () => null,
NativeChatPickerMenu: () => null
}))
vi.mock('./use-native-chat-skills', () => ({
useNativeChatSkills: () => ({ status: 'ready', skills: [], error: null, retry: () => {} })
}))
const scope = 'agent-session:consumer35636'
const path =
'/host/agent-session-attachments/12345678-1234-1234-1234-123456789abc/orca-paste-123-abc.txt'
let paste: (text: string) => boolean
function Composer({ initialValue = '' }: { initialValue?: string }) {
const ref = useRef<NativeChatComposerInput>(null)
paste = useNativeChatPastedTextFile({
scopeKey: scope,
scopeEditor: () => ref.current,
resolveAttachmentOwner: () => ({ kind: 'local' }),
insertTypedText: (text) => insertNativeChatPastedText(ref.current, text),
setNotice: () => {}
})
const resolved = mocks.resolved
const owner = useMemo(
() => ({
runtimeContext: resolved
? { target: { kind: 'local' as const }, worktreeId: 'w', worktreePath: '/repo' }
: null,
isVisible: true,
fileLinkContext: null
}),
[resolved]
)
return (
<NativeChatMarkdownImageOwnerContext.Provider value={owner}>
<NativeChatTextAttachmentScope enabled>
<NativeChatPromptEditor
scopeKey={scope}
inputRef={ref}
initialValue={initialValue}
disabled={false}
placeholder="Message"
onChange={() => {}}
onSelect={() => {}}
/>
</NativeChatTextAttachmentScope>
</NativeChatMarkdownImageOwnerContext.Provider>
)
}
async function openSource() {
fireEvent.click(await screen.findByRole('button', { name: 'Pasted text' }))
await waitFor(() =>
expect(screen.getByRole('button', { name: 'Copy contents' }).hasAttribute('disabled')).toBe(
false
)
)
}
afterEach(() => {
cleanup()
clearNativeChatComposerDraftsForTests()
nativeChatPastedTextOperations.clear()
vi.clearAllMocks()
})
it('opens draft and sent sources, exports exact bytes and scrolls a giant-line paste to the end', async () => {
const text = `\ufeff${Array.from({ length: 5000 }, (_, index) => `🙂paste${index}\r\n`).join('')}END35636`
const ending = Promise.withResolvers<void>()
let heldRange = false
let viewportHeight = 600
let viewportWidth = 390
let horizontalOccupation = 0
let rowHeight = 20
const nativeRect = HTMLElement.prototype.getBoundingClientRect
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (
this: HTMLElement
) {
return this.classList.contains('invisible')
? new DOMRect(0, 0, 8, rowHeight)
: nativeRect.call(this)
})
const positions = new WeakMap<HTMLElement, number>()
const horizontal = new WeakMap<HTMLElement, number>()
const scrollWrites: HTMLElement[] = []
let deliverScroll = true
const later = Promise.withResolvers<void>()
const laterStarted = Promise.withResolvers<void>()
let holdGiant = false
const arrayBuffer = Blob.prototype.arrayBuffer
vi.spyOn(Blob.prototype, 'arrayBuffer').mockImplementation(async function (this: Blob) {
if (holdGiant) {
laterStarted.resolve()
await later.promise
}
return arrayBuffer.call(this)
})
vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(function (
this: HTMLElement
) {
return this.classList.contains('scrollbar-editor')
? Number.parseFloat(this.style.width) || viewportWidth
: viewportWidth
})
vi.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockImplementation(function (
this: HTMLElement
) {
const track = this.lastElementChild
return Math.max(
this.clientWidth,
track instanceof HTMLElement ? Number.parseFloat(track.style.minWidth) || 0 : 0,
...(track instanceof HTMLElement && track.style.overflow === 'clip'
? []
: Array.from(this.querySelectorAll<HTMLElement>('[data-text-segment]')).map(
(row) => Array.from(row.textContent || '').length * 8
))
)
})
vi.spyOn(HTMLElement.prototype, 'scrollLeft', 'get').mockImplementation(function (
this: HTMLElement
) {
const left = Math.min(horizontal.get(this) || 0, this.scrollWidth - this.clientWidth)
horizontal.set(this, left)
return left
})
vi.spyOn(HTMLElement.prototype, 'scrollLeft', 'set').mockImplementation(function (
this: HTMLElement,
value
) {
horizontal.set(this, Math.max(0, Math.min(value, this.scrollWidth - this.clientWidth)))
})
const measurements = new Set<() => void>()
vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(function (
this: HTMLElement
) {
return this.classList.contains('scrollbar-editor')
? this.offsetHeight - (this.scrollWidth > this.clientWidth ? horizontalOccupation : 0)
: viewportHeight
})
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(function (
this: HTMLElement
) {
return Number.parseFloat(this.style.height) || viewportHeight
})
vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(function (
this: HTMLElement
) {
const track = this.lastElementChild
return Math.max(
this.clientHeight,
track instanceof HTMLElement ? Number.parseFloat(track.style.height) || 0 : 0,
...(track instanceof HTMLElement && track.style.overflow === 'clip'
? []
: Array.from(this.querySelectorAll<HTMLElement>('[data-text-segment]')).map(
(row) => Number.parseFloat(row.style.top) + Number.parseFloat(row.style.height)
))
)
})
vi.spyOn(HTMLElement.prototype, 'scrollTop', 'get').mockImplementation(function (
this: HTMLElement
) {
const top = Math.min(positions.get(this) || 0, this.scrollHeight - this.clientHeight)
positions.set(this, top)
return top
})
vi.spyOn(HTMLElement.prototype, 'scrollTop', 'set').mockImplementation(function (
this: HTMLElement,
value
) {
scrollWrites.push(this)
const top = Math.max(0, Math.min(value, this.scrollHeight - this.clientHeight))
if (top !== this.scrollTop) {
positions.set(this, top)
if (deliverScroll) {
queueMicrotask(() => this.dispatchEvent(new Event('scroll')))
}
}
})
vi.stubGlobal(
'ResizeObserver',
class {
readonly notify: () => void
constructor(callback: ResizeObserverCallback) {
this.notify = () => callback([], this)
measurements.add(this.notify)
}
observe() {}
unobserve() {}
disconnect() {
measurements.delete(this.notify)
}
}
)
try {
mocks.start.mockResolvedValue({ canceled: false, transferId: 'output' })
mocks.finish.mockResolvedValue({ canceled: false })
mocks.resolved = false
const upload = Promise.withResolvers<string>()
mocks.save.mockReturnValue(upload.promise)
mocks.rpc.mockImplementation(async (_target, method, request) => {
if (method === 'status.get') {
return { capabilities: ['native-chat.text-attachment-read.v1'] }
}
const bytes = Buffer.from(text)
if (request.offset > 0) {
heldRange = true
await ending.promise
}
const chunk = bytes.subarray(request.offset, request.offset + request.length)
return {
snapshot: 'a'.repeat(64),
size: bytes.length,
bytesRead: chunk.length,
contentBase64: chunk.toString('base64'),
eof: request.offset + chunk.length === bytes.length
}
})
Object.defineProperty(window, 'api', {
configurable: true,
value: {
ui: { savePastedTextAsFile: mocks.save, writeClipboardText: mocks.write },
fs: {
startDownloadedFile: mocks.start,
appendDownloadedFileChunk: mocks.append,
finishDownloadedFile: mocks.finish,
cancelDownloadedFile: mocks.cancel
}
}
})
let view = render(<Composer />)
await act(async () => {
expect(paste(text)).toBe(true)
})
expect(screen.getByRole('button', { name: 'Pasted text' })).toBeTruthy()
await openSource()
const dialog = screen.getByRole('dialog', { name: 'Pasted text' })
expect(dialog.getAttribute('data-slot')).toBe('dialog-content')
expect(document.getElementById(dialog.getAttribute('aria-describedby')!)?.textContent).toBe(
`${Buffer.byteLength(text).toLocaleString('en-US')} bytes`
)
await waitFor(() => expect(dialog.contains(document.activeElement)).toBe(true))
fireEvent.click(screen.getByRole('button', { name: 'Copy contents' }))
await waitFor(() => expect(mocks.write).toHaveBeenCalledWith(text))
expect(mocks.rpc).not.toHaveBeenCalled()
fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
mocks.resolved = true
await act(async () => upload.resolve(path))
view.rerender(<Composer />)
expect(nativeChatPastedTextOperations.size).toBe(0)
fireEvent.click(screen.getByRole('button', { name: 'Pasted text' }))
await waitFor(() =>
expect(mocks.rpc).toHaveBeenCalledWith(
expect.anything(),
'agentSessionAttachment.readTextChunk',
expect.objectContaining({ path }),
expect.anything()
)
)
const measured = document.querySelector('.scrollbar-editor')
if (!(measured instanceof HTMLElement)) {
throw new Error('Expected real committed text scroller')
}
await waitFor(() =>
expect(measured.querySelectorAll('[data-text-segment]').length).toBeGreaterThan(0)
)
fireEvent.keyDown(measured, { key: 'End' })
await waitFor(() => expect(heldRange).toBe(true))
await act(async () => ending.resolve())
await waitFor(() => {
expect(measured.textContent).toContain('END35636')
expect(measured.scrollTop + measured.clientHeight).toBe(measured.scrollHeight)
})
const oldEnd = measured.scrollTop
viewportHeight = 3000
expect.soft(measured.scrollTop).toBe(oldEnd)
act(() => measurements.forEach((notify) => notify()))
await waitFor(() =>
expect(measured.scrollTop + measured.clientHeight).toBe(measured.scrollHeight)
)
fireEvent.keyDown(measured, { key: 'Home' })
await waitFor(() => {
expect(measured.scrollTop).toBe(0)
expect(measured.querySelector('[data-text-segment]')?.textContent).toContain('🙂paste0')
})
fireEvent.keyDown(measured, { key: 'PageDown' })
await waitFor(() => {
expect(measured.querySelector('[data-text-segment]')?.textContent).toBeTruthy()
expect(measured.querySelector('[data-text-segment]')?.textContent).not.toContain('🙂paste0')
})
const visible = measured.querySelector('[data-text-segment]')?.textContent
viewportHeight = 400
rowHeight = 24
act(() => measurements.forEach((notify) => notify()))
await waitFor(() =>
expect(measured.querySelector('[data-text-segment]')?.textContent).toBe(visible)
)
fireEvent.keyDown(measured, { key: 'Home' })
await waitFor(() => expect(measured.scrollTop).toBe(0))
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
const editor = promptEditor(screen.getByRole('textbox'))
const token = promptValue(screen.getByRole('textbox')).trim()
act(() => {
editor.commands.selectAll()
editor.commands.deleteSelection()
})
expect(screen.queryByRole('button', { name: 'Pasted text' })).toBeNull()
await act(async () => {
editor.commands.undo()
})
expect(screen.getByRole('button', { name: 'Pasted text' })).toBeTruthy()
const saved = structuredClone(readNativeChatComposerDraft(scope))
expect(saved.text).toContain(token)
const sent = resolveNativeChatPastedText(scope, token)
expect(sent).toBe(`@${path}`)
view.unmount()
clearNativeChatComposerDraftsForTests()
updateNativeChatComposerDraft(
scope,
{ text: saved.text, document: saved.document, pastedTextFiles: saved.pastedTextFiles },
'immediate'
)
expect(readNativeChatComposerDraft(scope).text).toBe(saved.text)
view = render(
<NativeChatTextAttachments
content={sent}
structured
runtimeContext={{ target: { kind: 'local' }, worktreeId: 'w', worktreePath: '/repo' }}
renderText={(content) => <span>{content}</span>}
/>
)
await openSource()
fireEvent.click(screen.getByRole('button', { name: 'Copy contents' }))
await waitFor(() => expect(mocks.write).toHaveBeenCalledTimes(2))
expect(mocks.write.mock.calls[1]).toEqual([text])
await waitFor(() =>
expect(screen.getByRole('button', { name: 'Save file' }).hasAttribute('disabled')).toBe(false)
)
fireEvent.click(screen.getByRole('button', { name: 'Save file' }))
await waitFor(() => expect(mocks.finish).toHaveBeenCalledOnce())
expect(
Buffer.concat(
mocks.append.mock.calls.map(([args]) => Buffer.from(args.contentBase64, 'base64'))
).equals(Buffer.from(text))
).toBe(true)
view.unmount()
const giant = `${'x'.repeat(25 * 1024 * 1024)}TAIL`
mocks.resolved = false
const heldUpload = Promise.withResolvers<string>()
mocks.save.mockReturnValueOnce(heldUpload.promise)
viewportHeight = 600
view = render(<Composer />)
await act(async () => {
expect(paste(giant)).toBe(true)
})
fireEvent.click(screen.getByRole('button', { name: 'Pasted text' }))
await waitFor(() =>
expect(screen.getByRole('button', { name: 'Copy contents' }).hasAttribute('disabled')).toBe(
true
)
)
const scroller = document.querySelector('.scrollbar-editor')
if (!(scroller instanceof HTMLElement)) {
throw new Error('Expected real text scroller')
}
await waitFor(() =>
expect(scroller.querySelectorAll('[data-text-segment]').length).toBeGreaterThan(0)
)
const track = scroller.lastElementChild
if (!(track instanceof HTMLElement)) {
throw new Error('Expected virtual track')
}
expect(Number.parseFloat(track.style.height)).toBeLessThan(8_000_000)
scroller.scrollLeft = 500
holdGiant = true
await act(async () => {
scroller.scrollTop = 4000
})
await laterStarted.promise
expect.soft(scroller.scrollLeft).toBe(500)
deliverScroll = false
scroller.scrollTop += 80
const liveTop = scroller.scrollTop
const writes = scrollWrites.filter((node) => node === scroller).length
holdGiant = false
await act(async () => later.resolve())
await waitFor(() =>
expect(scroller.querySelectorAll('[data-text-segment]').length).toBeGreaterThan(0)
)
expect.soft(scrollWrites.filter((node) => node === scroller).length).toBe(writes)
expect.soft(scroller.scrollTop).toBe(liveTop)
expect.soft(scroller.scrollLeft).toBe(500)
deliverScroll = true
fireEvent.scroll(scroller)
fireEvent.scroll(scroller, {
target: { scrollTop: Number.parseFloat(track.style.height) - 600 }
})
await waitFor(() => expect(scroller.textContent).toContain('TAIL'))
expect(scroller.querySelectorAll('[data-text-segment]').length).toBeLessThanOrEqual(
Math.ceil(viewportHeight / rowHeight) + 4
)
fireEvent.scroll(scroller, { target: { scrollTop: 0 } })
await waitFor(() =>
expect(scroller.querySelector('[data-text-segment]')?.textContent).toBe('x'.repeat(256))
)
viewportHeight = 3000
rowHeight = 10
act(() => measurements.forEach((measure) => measure()))
await waitFor(() => {
const rows = scroller.querySelectorAll('[data-text-segment]')
expect(rows.length).toBeGreaterThanOrEqual(Math.ceil(viewportHeight / rowHeight))
expect(rows.length).toBeLessThanOrEqual(Math.ceil(viewportHeight / rowHeight) + 4)
expect(scroller.scrollTop).toBe(0)
})
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
await act(async () => heldUpload.resolve(path))
view.unmount()
viewportHeight = 600
viewportWidth = 400
rowHeight = 20
horizontalOccupation = 6
const dense = `HEAD${'X'.repeat(56)}\n${'\n'.repeat(50 * 1024 * 1024 - 61)}`
mocks.save.mockReturnValueOnce(new Promise(() => {}))
view = render(<Composer />)
await act(async () => {
expect(paste(dense)).toBe(true)
})
fireEvent.click(screen.getByRole('button', { name: 'Pasted text' }))
const classic = document.querySelector('.scrollbar-editor')
if (!(classic instanceof HTMLElement)) {
throw new Error('Expected native classic-scrollbar host')
}
await waitFor(() =>
expect(classic.querySelector('[data-text-segment]')?.textContent).toContain('HEAD')
)
act(() => measurements.forEach((notify) => notify()))
// Visit real source blocks through the shipping wheel/demand/read path, without a prefilled index.
for (let block = 1; block < 100; block++) {
await act(async () => {
fireEvent.wheel(classic, { deltaY: (block === 1 ? 15292 : 15352) * rowHeight })
})
}
fireEvent.keyDown(classic, { key: 'End' })
await waitFor(() => expect(classic.scrollHeight).toBe(8_000_593))
await waitFor(() => expect(classic.scrollTop).toBe(7_999_999))
const classicEnd = classic.scrollTop
viewportWidth = 650
expect.soft(classic.scrollTop).toBe(classicEnd)
act(() => measurements.forEach((notify) => notify()))
await waitFor(() => expect(classic.scrollTop + classic.clientHeight).toBe(classic.scrollHeight))
view.unmount()
} finally {
ending.resolve()
later.resolve()
cleanup()
vi.restoreAllMocks()
vi.unstubAllGlobals()
}
})
@@ -1,293 +0,0 @@
// @vitest-environment happy-dom
import type { ReactNode } from 'react'
import type { TerminalActivationController } from '../use-terminal-activation-actions'
import type { NativeChatStructuredViewProps } from './native-chat-view-types'
import { useContext, useState } from 'react'
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store'
import { registerTabGroupBody } from '../tab-group/tab-group-body-geometry'
import { useTerminalKeyboardShortcuts } from '../use-terminal-keyboard-shortcuts'
import { useTerminalWorkspaceFoundation } from '../use-terminal-workspace-foundation'
import StructuredAgentSessionPaneOverlayLayer from './StructuredAgentSessionPaneOverlayLayer'
import {
NativeChatTextAttachmentScope,
NativeChatTextAttachmentOpener
} from './NativeChatTextAttachments'
import { nativeChatFanoutFixture } from './native-chat-workspace-test-fixtures'
import { getDefaultSettings } from '../../../../shared/constants'
const mocks = vi.hoisted(() => ({
rpc: vi.fn(),
write: vi.fn(),
start: vi.fn(),
append: vi.fn(),
finish: vi.fn(),
cancel: vi.fn(),
fallback: false,
web: false
}))
vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: mocks.rpc }))
vi.mock('@/lib/web-client-location', () => ({ isWebClientLocation: () => mocks.web }))
vi.mock('sonner', () => ({ toast: { success: vi.fn() } }))
// Keep the production overlay, NativeChatView and its visibility provider; agent execution is outside this UI contract.
vi.mock('./NativeChatStructuredSession', () => ({
NativeChatStructuredSession: (props: NativeChatStructuredViewProps) => <ChatBody {...props} />
}))
vi.mock('./NativeChatPaneFileDropSurface', () => ({
NativeChatPaneFileDropSurface: ({ children }: { children: ReactNode }) => <>{children}</>
}))
const path = '/host/agent-session-attachments/12345678-1234-1234-1234-123456789abc/log.txt'
const bytes = Buffer.from('selected source')
const chunk = () => ({
snapshot: 'a'.repeat(64),
size: bytes.length,
contentBase64: bytes.toString('base64'),
bytesRead: bytes.length,
eof: true
})
const ws = 'folder:workspace-0'
const initial = useAppStore.getInitialState()
let unregister: () => void
let anchor: HTMLElement
function Editor({ tabId }: { tabId: string }) {
const [draft, setDraft] = useState('')
const open = useContext(NativeChatTextAttachmentOpener)
return (
<>
<input
aria-label={`draft-${tabId}`}
value={draft}
onChange={(event) => setDraft(event.target.value)}
/>
<button onClick={() => open?.(path)}>Open source</button>
</>
)
}
function ChatBody({ tabId }: NativeChatStructuredViewProps) {
return (
<NativeChatTextAttachmentScope enabled>
<Editor tabId={tabId} />
</NativeChatTextAttachmentScope>
)
}
function Host() {
const visible = useAppStore(
(state) => state.activeWorktreeId === ws && state.activeView === 'terminal'
)
const controller: TerminalActivationController = {
...useAppStore.getState(),
...useTerminalWorkspaceFoundation(),
activeWorktreeId: ws,
keybindings: {},
mobileEmulatorEnabled: false,
terminalShortcutPolicy: 'orca-first',
tabBarOrder: undefined,
activityTerminalPortals: [],
foregroundTerminalTabIds: [],
tabs: [],
titlebarTabsTarget: null,
worktreeFiles: [],
worktreeBrowserTabs: [],
worktreeClientHostedBrowserRows: [],
getEffectiveLayoutForWorktree: vi.fn(),
effectiveActiveLayout: undefined,
activeWorktreeBrowserTabIdsKey: '',
terminalProviderSnapshotCapabilityRevision: 0,
saveDialogFileId: null,
setSaveDialogFileId: vi.fn(),
saveDialogFile: null,
pendingEditorCloseQueueRef: { current: [] },
closedReactionsRef: { current: new Map() },
inFlightSaveFileIdRef: { current: null },
isClosingRef: { current: false },
closeDialogDebounceTimersRef: { current: new Set() },
releaseCloseDialogGuardAfterDebounce: vi.fn(),
windowCloseDialogOpen: false,
setWindowCloseDialogOpen: vi.fn(),
windowCloseDialogKind: 'running',
windowCloseAfterDirtyRef: { current: null },
confirmNativeWindowClose: vi.fn(),
proceedToNativeWindowClose: vi.fn(),
waitForFileClosed: vi.fn(),
settleQueuedClose: vi.fn(),
getNextQueuedEditorClose: vi.fn(),
advanceEditorCloseQueue: vi.fn(),
queueEditorCloseRequests: vi.fn(),
handleCloseFile: vi.fn(),
handleSaveDialogSave: vi.fn(),
handleSaveDialogDiscard: vi.fn(),
handleSaveDialogCancel: vi.fn(),
measurableBackgroundWorktreeTimersRef: { current: new Map() },
backgroundMountRevision: 0,
setBackgroundMountRevision: vi.fn(),
terminalParkingRevision: 0,
setTerminalParkingRevision: vi.fn(),
browserGuestRetentionRevision: 0,
setBrowserGuestRetentionRevision: vi.fn(),
parkedTerminalWorktreeIds: new Set(),
setParkedTerminalWorktreeIds: vi.fn(),
manuallyParkedTerminalWorktreeIds: new Set(),
effectiveParkedTerminalWorktreeIds: new Set(),
forceParkedTerminalWorktreeIds: new Set(),
setForceParkedTerminalWorktreeIds: vi.fn(),
evictionExemptTerminalTabIds: new Set(),
setEvictionExemptTerminalTabIds: vi.fn(),
parkedCaptureDoneRef: { current: new Set() },
backgroundMountTabIdsByWorktreeRef: { current: new Map() },
activationDeferredMountTabIdsByWorktreeRef: { current: new Map() },
lastActivationWorktreeIdRef: { current: null },
prewarmLeasesRef: { current: new Map() },
startupTerminalTabHoldRef: { current: null },
activationDeferralPlanRevisionRef: { current: 0 },
anyMountedWorktreeHasLayout: false,
activationDeferralPlanRevision: 0,
startupTerminalTabHold: null,
handleCloseTab: vi.fn(),
handleCloseBrowserTab: vi.fn(),
handlePtyExit: vi.fn(),
closeTabBarTabs: vi.fn(),
handleCloseOthers: vi.fn(),
handleCloseTabsToRight: vi.fn(),
handleCloseTabsToLeft: vi.fn(),
handleCloseAllFiles: vi.fn(),
handleNewBrowserTab: vi.fn(),
handleNewBrowserTabSplitRight: vi.fn(),
handleNewFile: vi.fn(),
handleNewSimulatorTab: vi.fn(),
handleNewTab: vi.fn(),
handleOpenEntry: vi.fn(),
handleDuplicateBrowserTab: vi.fn(),
handleActivateTab: vi.fn(),
handleTogglePaneExpand: vi.fn(),
handleActivateBrowserTab: vi.fn()
}
useTerminalKeyboardShortcuts(controller)
return <StructuredAgentSessionPaneOverlayLayer worktreeId={ws} isWorktreeActive={visible} />
}
beforeEach(() => {
vi.stubGlobal('reportError', (error: unknown) => {
throw error
})
vi.stubGlobal('__ORCA_WEB_CLIENT__', true)
vi.stubGlobal(
'ResizeObserver',
class {
observe() {}
unobserve() {}
disconnect() {}
}
)
vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Linux')
anchor = document.createElement('div')
anchor.getBoundingClientRect = () => new DOMRect(0, 32, 800, 568)
document.body.append(anchor)
unregister = registerTabGroupBody('g', anchor)
const fixture = nativeChatFanoutFixture(2, 1, 0)
fixture.unifiedTabsByWorktree[ws] = fixture.unifiedTabsByWorktree[ws].map((tab) => ({
...tab,
groupId: 'g'
}))
useAppStore.setState(
{
...initial,
...fixture,
settings: getDefaultSettings('/test35636'),
activeWorktreeId: ws,
activeWorkspaceExecutionHostId: 'local',
activeView: 'terminal',
activeTabType: 'agent-session',
groupsByWorktree: {
[ws]: [{ id: 'g', worktreeId: ws, activeTabId: 'chat-0', tabOrder: ['chat-0', 'chat-1'] }]
},
activeGroupIdByWorktree: { [ws]: 'g' }
},
true
)
mocks.web = false
mocks.write.mockReset().mockResolvedValue(undefined)
mocks.start.mockReset().mockResolvedValue({ canceled: false, transferId: 'output35636' })
mocks.append.mockReset().mockResolvedValue(undefined)
mocks.finish.mockReset().mockResolvedValue({ canceled: false })
mocks.cancel.mockReset().mockResolvedValue(undefined)
mocks.rpc
.mockReset()
.mockImplementation(async (_target, method) =>
method === 'status.get'
? { runtimeId: 'host', capabilities: ['native-chat.text-attachment-read.v1'] }
: chunk()
)
Object.defineProperty(window, 'api', {
configurable: true,
value: {
ui: { writeClipboardText: mocks.write },
fs: {
startDownloadedFile: mocks.start,
appendDownloadedFileChunk: mocks.append,
finishDownloadedFile: mocks.finish,
cancelDownloadedFile: mocks.cancel
}
}
})
})
afterEach(() => {
cleanup()
unregister()
anchor.remove()
useAppStore.setState(initial, true)
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
it('retires delayed Copy on a genuine keyboard tab hide, preserves the editor and reopens', async () => {
const view = render(<Host />)
const input = screen.getByRole<HTMLInputElement>('textbox', { name: 'draft-chat-0' })
fireEvent.change(input, { target: { value: 'editor state survives hide' } })
input.focus()
const open = () => fireEvent.click(screen.getByRole('button', { name: 'Open source' }))
const ready = () =>
waitFor(() =>
expect(screen.getByRole('button', { name: 'Copy contents' }).hasAttribute('disabled')).toBe(
false
)
)
open()
await ready()
const delayed = Promise.withResolvers<unknown>()
const waiting = Promise.withResolvers<void>()
mocks.rpc.mockImplementationOnce(() => {
waiting.resolve()
return delayed.promise
})
fireEvent.click(screen.getByRole('button', { name: 'Copy contents' }))
await waiting.promise
const dispatched = mocks.rpc.mock.calls.length
fireEvent.keyDown(screen.getByRole('dialog'), {
key: ']',
code: 'BracketRight',
ctrlKey: true,
altKey: true
})
expect(useAppStore.getState().getActiveTab(ws)?.id).toBe('chat-1')
await act(async () => {})
expect(
view.container
.querySelector('[data-structured-agent-session-overlay-tab-id="chat-0"]')
?.hasAttribute('inert')
).toBe(true)
expect(screen.queryByRole('dialog')).toBeNull()
await act(async () => delayed.resolve(chunk()))
expect(mocks.write).not.toHaveBeenCalled()
expect(mocks.rpc).toHaveBeenCalledTimes(dispatched)
fireEvent.keyDown(document, { key: '[', code: 'BracketLeft', ctrlKey: true, altKey: true })
const returned = screen.getByRole<HTMLInputElement>('textbox', { name: 'draft-chat-0' })
expect(returned).toBe(input)
expect(returned.value).toBe('editor state survives hide')
expect(screen.queryByRole('dialog')).toBeNull()
open()
await ready()
fireEvent.click(screen.getByRole('button', { name: 'Copy contents' }))
await waitFor(() => expect(mocks.write).toHaveBeenCalledExactlyOnceWith('selected source'))
})
@@ -1,10 +1,8 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { NativeChatToolLine } from './NativeChatToolLine'
import { NativeChatToolRun } from './NativeChatToolRun'
import type { NativeChatToolCallBlock } from '../../../../shared/native-chat/native-chat-types'
vi.mock('@/lib/syntax-highlighting/oniguruma', async () => ({
loadOniguruma: (await import('@/lib/syntax-highlighting/oniguruma-test-harness'))
@@ -14,166 +12,6 @@ vi.mock('@/lib/syntax-highlighting/oniguruma', async () => ({
afterEach(cleanup)
describe('tool sentence rows', () => {
it.each([
['Bash', { command: 'pnpm test' }, 'Ran', 'pnpm test', false],
['Read', { file_path: '/repo/src/main.ts' }, 'Read', 'main.ts', true],
['Edit', { file_path: 'C:\\repo\\main.ts' }, 'Edited', 'main.ts', true],
['Write', { file_path: '/repo/new.ts' }, 'Edited', 'new.ts', true],
['MultiEdit', { file_path: '/repo/main.ts' }, 'Edited', 'main.ts', true],
['Grep', { pattern: 'TODO', path: '/repo' }, 'Searched', 'TODO', true],
['Glob', { pattern: '**/*.ts' }, 'Searched', '**/*.ts', true],
['search', { query: 'settings', command: 'rg settings' }, 'Searched', 'settings', true],
['web_search', { query: 'react docs' }, 'Searched the web', 'react docs', true],
['WebFetch', { url: 'https://example.com/docs' }, 'Fetched', 'example.com/docs', true],
['CreateWidget', { description: 'a widget' }, 'CreateWidget', 'a widget', true],
['list', { directory: '/repo' }, 'Listed', '/repo', true],
['exec', { command: 'git diff' }, 'Ran', 'git diff', false],
['local_shell', { command: 'pwd' }, 'Ran', 'pwd', false]
])('describes %s with a verb and target', (name, input, verb, target, verbShown) => {
const { container } = render(
<NativeChatToolLine block={{ type: 'tool-call', name, input, state: 'completed' }} />
)
// A command is its own row: the run's header already says it ran.
expect(screen.queryByText(verb, { selector: 'span:not(.sr-only)' }) !== null).toBe(verbShown)
expect(screen.getByText(target, { selector: 'span:not(.sr-only)' })).toHaveClass(
'text-chat-foreground'
)
expect(screen.getByRole('button')).toHaveAccessibleName(new RegExp(name))
expect(screen.getByRole('button')).toHaveAccessibleName(new RegExp(verb))
expect(container.querySelector('.font-semibold')).toBeNull()
})
it('retains full paths in titles and accessible targets', () => {
render(
<NativeChatToolLine
block={{ type: 'tool-call', name: 'Read', input: { file_path: '/repo/src/main.ts' } }}
/>
)
expect(screen.getByTitle('/repo/src/main.ts')).toHaveTextContent('main.ts')
expect(screen.getByTitle('/repo/src/main.ts')).toHaveAttribute('aria-hidden', 'true')
expect(screen.getByRole('button')).toHaveAccessibleName(/Read.*\/repo\/src\/main.ts/)
})
it('keeps integration identity in plain text even for a command-shaped tool name', () => {
render(
<NativeChatToolLine
block={{
type: 'tool-call',
name: 'shell',
input: { command: 'inspect' },
state: 'completed',
mcpIdentity: { server: 'my_server', tool: 'shell' }
}}
/>
)
expect(screen.getByText('My server')).toBeInTheDocument()
expect(screen.getByText('shell')).toBeInTheDocument()
expect(screen.queryByText('Ran')).toBeNull()
expect(screen.getByText('inspect')).not.toHaveClass('font-mono')
expect(screen.getByText('inspect')).not.toHaveAttribute('data-native-chat-code-content')
})
it('counts changed lines rather than unchanged lines in an edit', () => {
const block: NativeChatToolCallBlock = {
type: 'tool-call',
name: 'Edit',
input: { file_path: 'main.ts', old_string: 'same\nold\n', new_string: 'same\nnew\nextra\n' },
state: 'completed'
}
render(<NativeChatToolRun blocks={[block]} expandSignal />)
expect(screen.getByRole('button', { name: /^Edited main\.ts/ })).toBeInTheDocument()
expect(screen.getByText('+2')).toBeInTheDocument()
expect(screen.getByText('-1')).toBeInTheDocument()
})
it('omits counts when an edit supplies no diff', () => {
render(
<NativeChatToolLine
block={{
type: 'tool-call',
name: 'Edit',
input: { file_path: 'main.ts' },
state: 'completed'
}}
/>
)
expect(screen.queryByText(/^[+-]\d+$/)).toBeNull()
})
it.each([
['Bash', { command: 'pnpm test' }, 'Running'],
['exec', { command: 'git diff' }, 'Running'],
['local_shell', { command: 'pwd' }, 'Running'],
['Read', { file_path: '/repo/a.ts' }, 'Reading'],
['Write', { file_path: '/repo/a.ts', content: 'new' }, 'Editing'],
['Grep', { pattern: 'TODO' }, 'Searching'],
['list', { directory: '/repo' }, 'Listing'],
['web_search', { query: 'docs' }, 'Searching the web'],
['WebFetch', { url: 'https://example.com/docs' }, 'Fetching']
])('uses present tense for a running %s', (name, input, verb) => {
render(<NativeChatToolLine block={{ type: 'tool-call', name, input, state: 'running' }} />)
expect(screen.getByText(verb)).toBeInTheDocument()
})
it.each(['failed', undefined] as const)('never claims an edit landed with state %s', (state) => {
render(
<NativeChatToolLine
block={{
type: 'tool-call',
name: 'Edit',
input: { file_path: '/repo/a.ts', old_string: 'missing', new_string: 'new' },
state
}}
/>
)
expect(screen.getByRole('button')).toHaveAccessibleName(
state === 'failed' ? 'Edit Tried to edit /repo/a.ts' : 'Edit /repo/a.ts'
)
expect(screen.queryByText('Edited')).toBeNull()
expect(screen.queryByText(/^[+-]\d+$/)).toBeNull()
})
it('does not treat an error result as a completed edit', () => {
render(
<NativeChatToolLine
block={{
type: 'tool-call',
name: 'Edit',
input: { file_path: '/repo/a.ts' },
state: 'completed'
}}
result={{ type: 'tool-result', output: 'String to replace not found', isError: true }}
/>
)
expect(screen.getByRole('button')).toHaveAccessibleName('Edit Tried to edit /repo/a.ts')
expect(screen.queryByText('Edited')).toBeNull()
})
it('uses a successful legacy result as completion evidence', () => {
render(
<NativeChatToolLine
block={{ type: 'tool-call', name: 'Bash', input: { command: 'pwd' } }}
result={{ type: 'tool-result', output: '/repo' }}
/>
)
expect(screen.getByText('Ran')).toBeInTheDocument()
})
it.each([false, true])(
'keeps a long Unicode fetch target unchanged (JSON input: %s)',
(jsonInput) => {
const url = `https://example.com/日本語/${'segment/'.repeat(25)}end`
const input = jsonInput ? JSON.stringify({ url }) : { url }
render(
<NativeChatToolLine
block={{ type: 'tool-call', name: 'WebFetch', input, state: 'completed' }}
/>
)
expect(screen.getByTitle(url)).toHaveTextContent(url.slice('https://'.length))
expect(screen.getByTitle(url)).not.toHaveTextContent('%E2%80%A6')
}
)
it('keeps metadata and errors while toggling output behind the sentence', () => {
render(
<NativeChatToolLine
@@ -202,243 +40,4 @@ describe('tool sentence rows', () => {
fireEvent.click(button)
expect(screen.queryByText('command failed')).toBeNull()
})
it.each([
['Bash', { type: 'tool-result', output: 'failed', isError: true }, undefined],
['exec', { type: 'tool-result', output: 'failed', isError: true }, 1],
['local_shell', undefined, 2],
['Bash', { type: 'tool-result', output: 'ok' }, undefined]
] as const)(
'says Ran for %s with execution evidence, including failures',
(name, result, exitCode) => {
render(
<NativeChatToolLine
block={{
type: 'tool-call',
name,
input: { command: 'false' },
state: 'failed',
exitCode
}}
result={result}
/>
)
expect(screen.getByText('Ran')).toBeInTheDocument()
expect(screen.queryByText('Running')).toBeNull()
}
)
it.each([
['Read', { file_path: '/repo/a.ts' }, 'Tried to read'],
['Grep', { pattern: 'TODO' }, 'Tried to search'],
['list', { directory: '/repo' }, 'Tried to list'],
['WebFetch', { url: 'https://example.com' }, 'Tried to fetch'],
['web_search', { query: 'docs' }, 'Tried to search the web']
])('describes a failed %s without claiming success', (name, input, verb) => {
render(
<NativeChatToolLine
block={{ type: 'tool-call', name, input, state: 'completed' }}
result={{ type: 'tool-result', output: 'unavailable', isError: true }}
/>
)
expect(screen.getByText(verb)).toBeInTheDocument()
})
it.each([
['Bash', { command: 'pnpm test' }],
['exec', JSON.stringify({ cmd: 'pnpm test' })],
['Bash', 'pnpm test']
])('omits duplicate input for a complete %s command row', (name, input) => {
const { container } = render(
<NativeChatToolLine
block={{ type: 'tool-call', name, input, state: 'completed' }}
result={{ type: 'tool-result', output: 'all passed' }}
/>
)
fireEvent.click(screen.getByRole('button'))
expect(container.querySelectorAll('pre')).toHaveLength(1)
expect(container.querySelector('pre')).toHaveTextContent('all passed')
})
it.each([
[{ command: 'pnpm test', description: 'Run the suite' }],
[{ command: ['printf', '%s', 'a b'] }],
[{ command: '/bin/zsh -lc "pnpm test"' }]
])('opens a command to its output alone, never its raw arguments', (input) => {
const { container } = render(
<NativeChatToolLine
block={{ type: 'tool-call', name: 'Bash', input, state: 'completed' }}
result={{ type: 'tool-result', output: 'all passed' }}
/>
)
fireEvent.click(screen.getByRole('button'))
expect([...container.querySelectorAll('pre')].map((pre) => pre.textContent)).toEqual([
'all passed'
])
})
it('does not offer empty detail for a short command without output', () => {
const { container } = render(
<NativeChatToolLine
block={{ type: 'tool-call', name: 'Bash', input: { command: 'pwd' }, state: 'completed' }}
/>
)
expect(screen.getByRole('button')).not.toHaveAttribute('aria-expanded')
expect(container.querySelector('pre')).toBeNull()
})
it.each([false, true])(
'shows the full abbreviated command as plain text (JSON: %s)',
(jsonInput) => {
const command = `pnpm test ${'src/renderer/tests/long-path/'.repeat(170)}end.test.ts`
const input = jsonInput ? JSON.stringify({ command }) : { command }
const { container } = render(
<NativeChatToolLine
block={{ type: 'tool-call', name: 'exec', input, state: 'completed' }}
result={{ type: 'tool-result', output: 'all passed' }}
/>
)
expect(screen.getByTitle(command)).toHaveTextContent(/…$/)
expect(container.querySelector('pre')).toBeNull()
fireEvent.click(screen.getByRole('button'))
const detail = container.querySelector('pre')
expect(detail?.textContent).toBe(command)
expect(detail).toHaveClass('font-mono', 'bg-chat-code-surface')
expect(container.querySelectorAll('pre')).toHaveLength(2)
}
)
it.each([
['PowerShell', 'Get-ChildItem', true],
['execute', 'Get-ChildItem', false]
])('colors the full %s command only when its shell is known', async (name, verb, colored) => {
const command = `${verb} -Path 'C:\\repo\\src\\renderer' -Recurse -File | Where-Object { $_.Length -gt 10 }`
const { container } = render(
<NativeChatToolLine
block={{ type: 'tool-call', name, input: { command }, state: 'completed' }}
/>
)
fireEvent.click(screen.getByRole('button'))
const detail = container.querySelector('pre')
if (colored) {
// The first PowerShell block in this file loads the grammar and its regex engine.
await waitFor(
() => {
expect(detail?.querySelector('span[style*="--syntax-dark"]')).not.toBeNull()
},
{ timeout: 10_000 }
)
} else {
expect(detail?.querySelector('span')).toBeNull()
}
expect(detail?.textContent).toBe(command)
})
it('keeps a long plain command with surrounding whitespace complete', () => {
const command = ` pnpm test ${'src/renderer/tests/long-path/'.repeat(170)}end.test.ts `
const { container } = render(
<NativeChatToolLine
block={{ type: 'tool-call', name: 'Bash', input: { command }, state: 'completed' }}
/>
)
fireEvent.click(screen.getByRole('button'))
expect(container.querySelector('pre')?.textContent).toBe(command)
})
it('opens an edit that has not landed to the change it proposes', () => {
const { container } = render(
<NativeChatToolLine
block={{
type: 'tool-call',
name: 'Edit',
state: 'running',
input: { file_path: '/repo/a.ts', old_string: 'was', new_string: 'now' }
}}
/>
)
expect(container.textContent).not.toContain('now')
fireEvent.click(screen.getByRole('button'))
expect(container.textContent).toContain('now')
})
it.each(['exec', 'shell', 'Bash'])('offers nothing to open on a running %s command', (name) => {
render(
<NativeChatToolLine
block={{ type: 'tool-call', name, input: { command: 'ls' }, state: 'running' }}
/>
)
expect(screen.getByRole('button')).not.toHaveAttribute('aria-expanded')
})
it('offers nothing to open on a call that has produced no output', () => {
render(
<NativeChatToolLine
block={{ type: 'tool-call', name: 'lookup_ticket', input: { ticket: 'ORC-1' } }}
/>
)
expect(screen.getByRole('button')).not.toHaveAttribute('aria-expanded')
})
it('shows a read as its output alone, not the arguments its row already names', () => {
const { container } = render(
<NativeChatToolLine
block={{ type: 'tool-call', name: 'Read', input: { file_path: '/repo/a.ts', offset: 10 } }}
result={{ type: 'tool-result', output: 'const a = 1' }}
/>
)
fireEvent.click(screen.getByRole('button'))
expect([...container.querySelectorAll('pre')].map((pre) => pre.textContent)).toEqual([
'const a = 1'
])
})
it.each(['Task', 'Agent'])(
'uses the unchanged description for live and settled %s calls',
(name) => {
const description = `explore settings search entries ${'without rewriting '.repeat(8)}end`
const input = {
description,
prompt: 'longer private instructions',
query: 'not the description'
}
const { rerender } = render(
<NativeChatToolLine block={{ type: 'tool-call', name, input, state: 'running' }} />
)
expect(screen.getByText('Subagent')).toBeInTheDocument()
expect(screen.getByTitle(description).textContent).toBe(description)
rerender(
<NativeChatToolLine
block={{ type: 'tool-call', name, input: JSON.stringify(input), state: 'completed' }}
/>
)
expect(screen.getByText('Subagent')).toBeInTheDocument()
expect(screen.getByTitle(description).textContent).toBe(description)
expect(screen.getByRole('button')).toHaveAccessibleName(new RegExp(name))
}
)
it('preserves integration identity for an integration named Agent', () => {
render(
<NativeChatToolLine
block={{
type: 'tool-call',
name: 'Agent',
input: { description: 'inspect' },
state: 'completed',
mcpIdentity: { server: 'my_server', tool: 'Agent' }
}}
/>
)
expect(screen.getByText('My server')).toBeInTheDocument()
expect(screen.queryByText('Subagent')).toBeNull()
})
it('still renders result-only rows', () => {
render(<NativeChatToolLine block={{ type: 'tool-result', output: 'first\nsecond' }} />)
fireEvent.click(screen.getByRole('button', { name: /Result/ }))
expect(screen.getByText('first second', { selector: 'pre' })).toHaveClass(
'text-chat-foreground'
)
})
})
@@ -10,8 +10,6 @@ import type { NativeChatBlock } from '../../../../shared/native-chat/native-chat
import { NativeChatToolRun } from './NativeChatToolRun'
import { nativeChatAppearanceStyle } from './native-chat-appearance-style'
import { openToolRunMembers } from './native-chat-tool-run-members-test-support'
import {
@@ -20,43 +18,8 @@ import {
useNativeChatDisclosures
} from './native-chat-disclosure-store'
import { revealNativeChatToolRunMember } from './NativeChatToolRunMembers'
import { i18n } from '@/i18n/i18n'
{
describe('tool-run summary in a matching chat', () => {
afterEach(cleanup)
it('keeps a long command available in the expanded detail', () => {
const command = `printf ${'x'.repeat(5000)}`
const style = nativeChatAppearanceStyle({
terminalFontFamily: 'Menlo',
nativeChatAppearance: { matchTerminalInterface: true }
})
const { container } = render(
<div className="native-chat-appearance" style={style}>
<NativeChatToolRun
blocks={[{ type: 'tool-call', name: 'shell', input: { command }, state: 'completed' }]}
expandSignal={false}
/>
</div>
)
const summary = container.querySelector('span.line-clamp-2')
expect(summary).toHaveClass('line-clamp-2', 'break-words')
expect(summary?.textContent).toContain('printf')
expect(summary?.textContent?.length).toBeLessThan(command.length)
expect(container.querySelector('pre')).toBeNull()
fireEvent.click(screen.getByRole('button'))
openToolRunMembers()
expect(container.querySelector('pre')).toHaveTextContent(command)
})
})
}
{
const QUESTION = 'What would you like me to do next in this repo?'
const ASK_INPUT = { questions: [{ question: QUESTION }] }
@@ -107,29 +70,6 @@ import { i18n } from '@/i18n/i18n'
describe('NativeChatToolRun awaiting-input row', () => {
afterEach(cleanup)
it('does not revive stale tool state after a turn stops', () => {
render(
<NativeChatToolRun blocks={askBlocks('running')} expandSignal activeTurnIsWorking={false} />
)
expect(screen.queryByText('Awaiting user input:')).toBeNull()
expect(screen.getByText('Asked:')).toBeInTheDocument()
})
it('keeps a pending question visible alongside another active tool', () => {
render(
<NativeChatToolRun
blocks={[
...askBlocks('running'),
{ type: 'tool-call', name: 'Read', input: { file_path: 'a.ts' }, state: 'running' }
]}
expandSignal
activeTurnIsWorking
/>
)
expect(screen.getByText('Awaiting user input:')).toBeInTheDocument()
expect(screen.getByText('Reading 1 file')).toBeInTheDocument()
expect(screen.getByText('Read a.ts')).toBeInTheDocument()
})
it('preserves errors from failed question calls', () => {
render(
@@ -147,79 +87,6 @@ import { i18n } from '@/i18n/i18n'
expect(screen.queryByText('Asked:')).toBeNull()
expect(screen.getAllByText('Question rejected').length).toBeGreaterThan(0)
})
it('replaces a running ask call with the awaiting row', () => {
const { container } = render(
<NativeChatToolRun blocks={askBlocks('running')} expandSignal activeTurnIsWorking />
)
expect(screen.getByText('Awaiting user input:')).toHaveClass(
'animate-pulse',
'motion-reduce:animate-none'
)
expect(screen.getByText(QUESTION)).toBeInTheDocument()
expect(container.querySelector('.lucide-message-square-more')).toBeInTheDocument()
expect(screen.queryByText(/Running AskUserQuestion/)).toBeNull()
expect(screen.queryByText(/AskUserQuestion/)).toBeNull()
})
it('reports a settled ask without the pulse or a tool-count header', () => {
const { container } = render(
<NativeChatToolRun
blocks={askBlocks('completed')}
expandSignal
activeTurnIsWorking={false}
/>
)
expect(screen.getByText('Asked:')).not.toHaveClass('animate-pulse')
expect(screen.getByText(QUESTION)).toBeInTheDocument()
expect(container.querySelector('[data-native-chat-tool-run-state]')).toBeNull()
})
it('keeps a question that fits on its line collapsed but expandable', () => {
render(
<NativeChatToolRun
blocks={askBlocks('completed')}
expandSignal
activeTurnIsWorking={false}
/>
)
expect(screen.getByText(QUESTION)).toHaveClass('truncate')
expect(screen.getByRole('button', { name: /Asked:/ })).toHaveAttribute(
'aria-expanded',
'false'
)
})
it('opens a clipped question below the toggle and folds it back', () => {
const restore = clipEveryLine()
try {
render(
<NativeChatToolRun
blocks={askBlocks('completed')}
expandSignal
activeTurnIsWorking={false}
/>
)
const toggle = screen.getByRole('button', { name: /Asked:/ })
expect(toggle).toHaveAttribute('aria-expanded', 'false')
fireEvent.click(toggle)
expect(toggle).toHaveAttribute('aria-expanded', 'true')
const full = screen.getByText(QUESTION)
expect(full).not.toHaveClass('truncate')
// Outside the button, so it selects like prose and a click in it keeps it open.
expect(full.closest('button')).toBeNull()
fireEvent.click(full)
expect(toggle).toHaveAttribute('aria-expanded', 'true')
fireEvent.click(toggle)
expect(toggle).toHaveAttribute('aria-expanded', 'false')
expect(screen.getByText(QUESTION)).toHaveClass('truncate')
} finally {
restore()
}
})
it('keeps an opened question open when the windowed row remounts', () => {
const restore = clipEveryLine()
@@ -244,51 +111,6 @@ import { i18n } from '@/i18n/i18n'
restore()
}
})
it('offers no expansion when the row names only a question count', () => {
const restore = clipEveryLine()
try {
const input = { questions: [{ question: 'First?' }, { question: 'Second?' }] }
render(
<NativeChatToolRun
blocks={[{ type: 'tool-call', name: 'AskUserQuestion', input, state: 'completed' }]}
expandSignal
activeTurnIsWorking={false}
/>
)
expect(screen.getByText('2 questions')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Asked:/ })).toBeNull()
} finally {
restore()
}
})
it('counts only the work that ran in the header beside the ask', () => {
const blocks: NativeChatBlock[] = [
{ type: 'tool-call', name: 'Read', input: { file_path: 'a.ts' }, state: 'completed' },
{ type: 'tool-call', name: 'AskUserQuestion', input: ASK_INPUT, state: 'running' }
]
render(<NativeChatToolRun blocks={blocks} expandSignal activeTurnIsWorking />)
expect(screen.getByText('Awaiting user input:')).toBeInTheDocument()
// Waiting for an answer settles the work summary; the question itself adds no call count.
expect(screen.getByText('Read 1 file')).toBeInTheDocument()
expect(screen.queryByText('Reading 1 file')).toBeNull()
})
it('draws the row from the tool name when the payload names no question', () => {
render(
<NativeChatToolRun
blocks={[{ type: 'tool-call', name: 'request_user_input', input: {}, state: 'running' }]}
expandSignal
activeTurnIsWorking
/>
)
expect(screen.getByText('Awaiting user input')).toBeInTheDocument()
expect(screen.queryByText(/request_user_input/)).toBeNull()
})
})
}
@@ -463,57 +285,6 @@ import { i18n } from '@/i18n/i18n'
fireEvent.click(returnedHeader)
expect(membersBox(container).scrollTop).toBe(0)
})
it('opens a settled run at its first call', () => {
layOut({ current: 600 })
const { container } = render(
<NativeChatToolRun blocks={calls(3)} expandSignal activeTurnIsWorking={false} />
)
expect(membersBox(container).scrollTop).toBe(0)
})
it('brings a revealed member to the top of the box and reports the box', () => {
layOut({ current: 600 })
const { container } = render(
<NativeChatToolRun blocks={calls(3)} expandSignal activeTurnIsWorking={false} />
)
const box = membersBox(container)
const member = box.querySelectorAll('button')[2]!
vi.spyOn(box, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 40, 300, VIEWPORT))
vi.spyOn(member, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 340, 300, 26))
expect(revealNativeChatToolRunMember(member)).toBe(box)
expect(box.scrollTop).toBe(300)
})
})
describe('NativeChatToolRun asides', () => {
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
it('draws the rows it is handed among its calls, in order', () => {
const blocks = calls(2)
const { container } = render(
<NativeChatToolRun
blocks={blocks}
asides={{
before: new Map([[blocks[1]!, [<p key="between">between</p>]]]),
after: [<p key="last">last</p>]
}}
expandSignal
activeTurnIsWorking={false}
/>
)
const rows = [...membersBox(container).querySelectorAll('button, p')].map(
(row) => row.textContent ?? ''
)
expect(rows).toHaveLength(4)
expect(rows[0]).toContain('file-0.ts')
expect(rows[1]).toBe('between')
expect(rows[2]).toContain('file-1.ts')
expect(rows[3]).toBe('last')
})
})
}
@@ -531,44 +302,6 @@ import { i18n } from '@/i18n/i18n'
cleanup()
await i18n.changeLanguage('en')
})
it.each(['Task', 'Agent'])('separates failure counts in command and %s runs', (name) => {
const blocks: NativeChatBlock[] = [
{ type: 'tool-call', name: 'Bash', input: { command: 'false' }, state: 'failed' },
{ type: 'tool-result', output: 'exit 1', isError: true },
{
type: 'tool-call',
name,
input: { description: 'explored settings search entries' },
state: 'completed'
}
]
const { container } = render(<NativeChatToolRun blocks={blocks} expandSignal={false} />)
const header = runHeader(container)
expect(header).toHaveTextContent('Ran 1 command and ran 1 agent · 1 failed')
expect(header).toHaveAccessibleName(/Failed tool calls: 1/)
expect(header).not.toHaveAccessibleName(/·/)
fireEvent.click(header)
expect(screen.getByText('Subagent')).toBeInTheDocument()
expect(screen.getByTitle('explored settings search entries')).toBeInTheDocument()
})
it('uses the agent glyph for a lone Agent transcript call', () => {
const { container } = render(
<NativeChatToolRun
blocks={[
{
type: 'tool-call',
name: 'Agent',
input: { description: 'inspect settings' },
state: 'completed'
}
]}
expandSignal
/>
)
expect(runHeader(container)).toHaveTextContent('Ran 1 agent')
expect(container.querySelectorAll('button .lucide-bot')).toHaveLength(2)
})
it('relabels an already-mounted run and its rows when the UI language changes', async () => {
const blocks: NativeChatBlock[] = [
@@ -2,11 +2,7 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { NativeChatToolRun } from './NativeChatToolRun'
import type {
NativeChatBlock,
NativeChatToolCallBlock,
NativeChatToolResultBlock
} from '../../../../shared/native-chat/native-chat-types'
import type { NativeChatToolCallBlock } from '../../../../shared/native-chat/native-chat-types'
import {
NativeChatDisclosureContext,
useNativeChatDisclosures
@@ -17,11 +13,6 @@ vi.mock('./NativeChatDiffCard', () => ({ NativeChatDiffCard: () => null }))
vi.mock('./NativeChatDiffView', () => ({ NativeChatDiffView: () => null }))
const disclosureWrite = vi.fn()
const capturedDisclosures = {
read: (_key: string) => undefined,
write: (key: string, open: boolean) => disclosureWrite(key, open),
runScroll: new Map()
}
afterEach(() => {
cleanup()
@@ -37,12 +28,6 @@ const shell: NativeChatToolCallBlock = {
durationMs: 400
}
const shellResult: NativeChatToolResultBlock = {
type: 'tool-result',
output: 'command not found',
isError: true
}
function ToolRunDisclosureHarness({ expandSignal }: { expandSignal: boolean }) {
const disclosures = useNativeChatDisclosures()
return (
@@ -73,175 +58,6 @@ describe('inline tool annotations', () => {
screen.getByRole('button', { name: /missing-command/ }).getAttribute('aria-expanded')
).toBe('false')
})
it('resynchronizes a standalone run when the toolbar signal flips', () => {
const { rerender } = render(
<NativeChatToolRun blocks={[shell]} expandSignal={false} activeTurnIsWorking={false} />
)
expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('false')
rerender(<NativeChatToolRun blocks={[shell]} expandSignal activeTurnIsWorking={false} />)
expect(
screen.getAllByRole('button', { name: /missing-command/ })[0].getAttribute('aria-expanded')
).toBe('true')
})
it('uses provider call identities for byte-identical line disclosure keys', () => {
const blocks: NativeChatBlock[] = [
{ ...shell, callId: 'call-a' },
shellResult,
{ ...shell, callId: 'call-b' },
shellResult
]
render(
<NativeChatDisclosureContext.Provider value={capturedDisclosures}>
<NativeChatToolRun blocks={blocks} expandSignal disclosureId="message-1" />
</NativeChatDisclosureContext.Provider>
)
fireEvent.click(screen.getAllByRole('button')[2]!)
expect(disclosureWrite).toHaveBeenCalledExactlyOnceWith('line:message-1:call:call-b', true)
})
it('keeps occurrence identity as the fallback for calls without provider IDs', () => {
render(
<NativeChatDisclosureContext.Provider value={capturedDisclosures}>
<NativeChatToolRun
blocks={[shell, shellResult, shell, shellResult]}
expandSignal
disclosureId="message-1"
/>
</NativeChatDisclosureContext.Provider>
)
fireEvent.click(screen.getAllByRole('button')[2]!)
expect(disclosureWrite).toHaveBeenCalledExactlyOnceWith(
'line:message-1:tool-call:shell:{"command":"missing-command"}:1',
true
)
})
it('keeps occurrence identity for whitespace-only provider IDs', () => {
render(
<NativeChatDisclosureContext.Provider value={capturedDisclosures}>
<NativeChatToolRun
blocks={[{ ...shell, callId: ' ' }, shellResult, { ...shell, callId: '\t' }, shellResult]}
expandSignal
disclosureId="message-1"
/>
</NativeChatDisclosureContext.Provider>
)
fireEvent.click(screen.getAllByRole('button')[2]!)
expect(disclosureWrite).toHaveBeenCalledExactlyOnceWith(
'line:message-1:tool-call:shell:{"command":"missing-command"}:1',
true
)
})
it('keeps command completion annotations on the collapsed tool line', () => {
render(
<NativeChatToolRun blocks={[shell, shellResult]} expandSignal activeTurnIsWorking={false} />
)
expect(screen.getByText('exit 127').closest('button')).toBe(
screen.getByText('400ms').closest('button')
)
expect(screen.getByText('exit 127').closest('button')?.getAttribute('aria-expanded')).toBe(
'false'
)
expect(screen.queryByText('0s')).toBeNull()
})
it('renders a legacy command without invented metadata', () => {
render(
<NativeChatToolRun
blocks={[{ type: 'tool-call', name: 'shell', input: null }]}
expandSignal
/>
)
expect(screen.queryByText(/exit \d/)).toBeNull()
expect(screen.queryByText(/\d+ms/)).toBeNull()
})
it('shows distinct MCP names while retaining the raw identifier', () => {
const name = 'mcp__linear__list_issues'
render(<NativeChatToolRun blocks={[{ type: 'tool-call', name, input: null }]} expandSignal />)
expect(screen.getByText('Linear')).toBeTruthy()
expect(screen.getByText('list issues')).toBeTruthy()
expect(screen.getByTitle(name)).toBeTruthy()
})
it('reveals safe result links only inside row disclosure and routes clicks through chat', () => {
const onLinkClick = vi.fn((event) => event.preventDefault())
const block: NativeChatToolCallBlock = {
type: 'tool-call',
name: 'web_search',
input: { query: 'docs' },
state: 'completed',
webSearchResults: [{ title: 'Reference docs', url: 'https://example.com/docs' }]
}
render(<NativeChatToolRun blocks={[block]} expandSignal onLinkClick={onLinkClick} />)
expect(screen.queryByRole('link')).toBeNull()
fireEvent.click(screen.getByText('Searched the web').closest('button')!)
const link = screen.getByRole('link', { name: /Reference docs/ })
expect(link.getAttribute('href')).toBe('https://example.com/docs')
expect(link.closest('button')).toBeNull()
fireEvent.click(link)
expect(onLinkClick).toHaveBeenCalledWith(expect.anything(), 'https://example.com/docs')
fireEvent(link, new MouseEvent('auxclick', { button: 1, bubbles: true }))
expect(onLinkClick).toHaveBeenCalledTimes(2)
})
})
it.each(['tools/read', 'browser.open', 'package.lock', 'linear/list_issues'])(
'keeps an ordinary tool name %s intact',
(name) => {
render(<NativeChatToolRun blocks={[{ type: 'tool-call', name, input: null }]} expandSignal />)
expect(screen.getByText(name)).toBeTruthy()
expect(document.querySelector('.lucide-plug')).toBeNull()
}
)
it.each(['running', 'completed'] as const)(
'renders provider MCP identity on %s rows and headers',
(state) => {
const name = 'my_server/ns.tool'
render(
<NativeChatToolRun
blocks={[
{
type: 'tool-call',
name,
input: null,
state,
mcpIdentity: { server: 'my_server', tool: 'ns.tool' }
}
]}
expandSignal
activeTurnIsWorking={state === 'running'}
/>
)
expect(screen.getByText('My server')).toBeTruthy()
expect(screen.getByText('ns.tool')).toBeTruthy()
expect(screen.getByTitle(name)).toBeTruthy()
// Run and member glyphs must agree on MCP identity.
expect(document.querySelectorAll('.lucide-plug')).toHaveLength(2)
}
)
it.each([
[{ exitCode: 0 }, 'exit 0', '400ms'],
[{ durationMs: 400 }, '400ms', 'exit 0']
])('renders independently optional command metadata %j', (metadata, present, absent) => {
render(
<NativeChatToolRun
blocks={[{ type: 'tool-call', name: 'shell', input: null, ...metadata }]}
expandSignal
/>
)
expect(screen.getByText(present)).toBeTruthy()
expect(screen.queryByText(absent)).toBeNull()
})
it('filters untrusted persisted result URLs at the renderer boundary', () => {
@@ -13,15 +13,6 @@ import { openToolRunMembers } from './native-chat-tool-run-members-test-support'
afterEach(cleanup)
/** Read lucide classes so identity-changing glyph swaps remain observable. */
function leadingGlyphs(container: HTMLElement): (string | null)[] {
return [...container.querySelectorAll('button')].map(
(button) =>
button
.querySelector('svg')
?.getAttribute('class')
?.match(/lucide-[a-z0-9-]+/)?.[0] ?? null
)
}
/** The first button is the header; summary members render as separate pills. */
function runHeader(container: HTMLElement): HTMLElement {
@@ -33,49 +24,6 @@ function runHeader(container: HTMLElement): HTMLElement {
}
describe('NativeChatToolRun', () => {
it('uses the shared clean label for a desktop tool row', () => {
const blocks: NativeChatBlock[] = [
{
type: 'tool-call',
name: 'Read',
input: '{"file_path":"src/index.ts","offset":10}'
}
]
render(<NativeChatToolRun blocks={blocks} expandSignal />)
expect(screen.getByTitle('src/index.ts')).toHaveTextContent('index.ts')
expect(screen.queryByTitle('{"file_path":"src/index.ts","offset":10}')).toBeNull()
})
it('renders structured apply_patch changes as a reviewable diff instead of JSON', () => {
const blocks: NativeChatBlock[] = [
{
type: 'tool-call',
name: 'apply_patch',
// This lane carries its patch on the call; provider completion proves the edit landed.
state: 'completed',
input: {
changes: [
{
path: '/repo/src/app.ts',
kind: { type: 'update', move_path: null },
diff: '@@ -1 +1 @@\n-before\n+after'
}
]
}
}
]
const { container } = render(<NativeChatToolRun blocks={blocks} expandSignal />)
openToolRunMembers()
expect(screen.getByText('after')).toBeInTheDocument()
expect(screen.getByText('before')).toBeInTheDocument()
expect(screen.getByText('Edited')).toBeInTheDocument()
expect(container.querySelector('pre')).toBeNull()
})
it('renders evidence-shaped projected patches as colored diffs without changes JSON', () => {
const item: AgentJournalRenderItem = {
itemId: 'apply-patch',
@@ -134,103 +82,6 @@ describe('NativeChatToolRun', () => {
expect(body).toHaveClass('text-destructive')
})
it('leaves a `git diff` command as a command row rather than an edit card', () => {
const blocks: NativeChatBlock[] = [
{ type: 'tool-call', name: 'exec', input: { command: 'git diff' }, state: 'completed' },
{
type: 'tool-result',
output: 'diff --git a/a.ts b/a.ts\n--- a/a.ts\n+++ b/a.ts\n@@ -1 +1 @@\n-was\n+now'
}
]
const { container } = render(<NativeChatToolRun blocks={blocks} expandSignal />)
const commandRow = screen.getByRole('button', { name: /^exec Ran git diff/ })
expect(within(commandRow).queryByText('Edited')).toBeNull()
expect(container).toHaveTextContent('git diff')
})
it('shows no gutter number for a snippet edit, which cannot locate itself', () => {
const blocks: NativeChatBlock[] = [
{
type: 'tool-call',
name: 'Edit',
input: { file_path: '/repo/a.ts', old_string: 'was', new_string: 'now' },
state: 'completed'
},
{ type: 'tool-result', output: 'ok' }
]
render(<NativeChatToolRun blocks={blocks} expandSignal />)
openToolRunMembers()
// Exact, because a snippet-relative number would sit ahead of the marker.
expect(screen.getByText('now').closest('div')?.textContent).toBe('+now')
expect(screen.getByText('was').closest('div')?.textContent).toBe('-was')
})
it('separates two regions of a file so the gutter jump is accounted for', () => {
const blocks: NativeChatBlock[] = [
{
type: 'tool-call',
name: 'Edit',
input: { file_path: '/repo/a.ts' },
state: 'completed'
},
{
type: 'tool-result',
output: 'ok',
editPatch: {
filePath: '/repo/a.ts',
hunks: [
{ oldStart: 42, oldLines: 1, newStart: 42, newLines: 1, lines: ['-was', '+now'] },
{ oldStart: 310, oldLines: 1, newStart: 310, newLines: 1, lines: ['-old', '+new'] }
]
}
}
]
render(<NativeChatToolRun blocks={blocks} expandSignal />)
openToolRunMembers()
const separators = screen.getAllByRole('separator')
expect(separators).toHaveLength(1)
expect(separators[0]).toHaveAccessibleName('Lines not shown')
})
it('offers no empty body for a delete, which names the file and nothing else', () => {
const blocks: NativeChatBlock[] = [
{
type: 'tool-call',
name: 'apply_patch',
input: { input: '*** Begin Patch\n*** Delete File: gone.ts\n*** End Patch' },
state: 'completed'
}
]
render(<NativeChatToolRun blocks={blocks} expandSignal />)
expect(screen.getByTitle('gone.ts')).toBeInTheDocument()
expect(screen.getByText('Deleted').closest('button')).not.toHaveAttribute('aria-expanded')
})
it('says a diff was clipped even while the card is collapsed', () => {
const blocks: NativeChatBlock[] = [
{
type: 'tool-call',
name: 'Diff',
input: { path: 'src/a.ts' },
state: 'completed'
},
{ type: 'tool-result', output: '@@ -1,3 +1,3 @@\n ctx\n-was\n+now\n… (48210 bytes)' }
]
render(<NativeChatToolRun blocks={blocks} expandSignal />)
expect(screen.getByText('Diff truncated')).toBeInTheDocument()
expect(screen.queryByText('was')).toBeNull()
})
it('copies the diff as signed rows, with the region breaks left out', () => {
const writeClipboardText = vi.fn()
Object.assign(window, { api: { ui: { writeClipboardText } } })
@@ -260,151 +111,6 @@ describe('NativeChatToolRun', () => {
expect(writeClipboardText).toHaveBeenCalledWith(' ctx\n-was\n+now\n+tail')
})
describe('reading a batch as a group', () => {
const batch: NativeChatBlock[] = [
{
type: 'tool-call',
name: 'mcp__linear__list_issues',
input: { query: 'todo' },
state: 'completed',
mcpIdentity: { server: 'linear', tool: 'list_issues' }
},
{ type: 'tool-call', name: 'Bash', input: { command: 'ls -la' }, state: 'completed' },
{ type: 'tool-call', name: 'read', input: { file_path: 'README.md' }, state: 'completed' }
]
// Summarize categories; separators also occur inside tool names and make joined call lists ambiguous.
it('summarizes a run by category, in the order the run first used each one', () => {
const { container } = render(<NativeChatToolRun blocks={batch} expandSignal={false} />)
expect(runHeader(container)).toHaveTextContent(
'Used 1 integration, ran 1 command, and read 1 file'
)
})
it('names no individual call in the header, however wide the run', () => {
const { container } = render(<NativeChatToolRun blocks={batch} expandSignal={false} />)
const header = runHeader(container)
expect(header).not.toHaveTextContent('mcp__linear__list_issues')
expect(header).not.toHaveTextContent('ls -la')
expect(header).not.toHaveTextContent('README.md')
})
it('counts every call rather than capping the list and marking a remainder', () => {
const wide: NativeChatBlock[] = [
...batch,
{ type: 'tool-call', name: 'Grep', input: { pattern: 'todo' }, state: 'completed' },
{ type: 'tool-call', name: 'Write', input: { file_path: 'a.ts' }, state: 'completed' }
]
const { container } = render(<NativeChatToolRun blocks={wide} expandSignal={false} />)
expect(runHeader(container)).not.toHaveTextContent('more')
expect(runHeader(container)).toHaveTextContent(
'Used 1 integration, ran 1 command, read 1 file, searched 1 time, and edited 1 file'
)
})
it('joins exactly two categories with "and", and no comma', () => {
const pair: NativeChatBlock[] = [
{ type: 'tool-call', name: 'Bash', input: { command: 'ls' }, state: 'completed' },
{ type: 'tool-call', name: 'Bash', input: { command: 'pwd' }, state: 'completed' },
{ type: 'tool-call', name: 'read', input: { file_path: 'a.ts' }, state: 'completed' }
]
const { container } = render(<NativeChatToolRun blocks={pair} expandSignal={false} />)
expect(runHeader(container)).toHaveTextContent('Ran 2 commands and read 1 file')
})
it('prints the split MCP name on the opened row', () => {
render(<NativeChatToolRun blocks={batch} expandSignal />)
expect(screen.getByText('Linear')).toBeInTheDocument()
})
it('gives a run whose tool it cannot name the generic category', () => {
const { container } = render(
<NativeChatToolRun
blocks={[{ type: 'tool-call', name: ' ', input: {}, state: 'completed' }]}
expandSignal={false}
/>
)
expect(runHeader(container)).toHaveTextContent('Used 1 tool')
})
it('keeps a lone command as the header, unwrapped from its login shell', () => {
const { container } = render(
<NativeChatToolRun
blocks={[
{
type: 'tool-call',
name: 'shell',
input: { command: `/bin/zsh -lc 'git push --force-with-lease'` },
state: 'completed'
}
]}
expandSignal={false}
/>
)
expect(runHeader(container)).toHaveTextContent('git push --force-with-lease')
expect(runHeader(container)).not.toHaveTextContent('/bin/zsh')
})
})
it('keeps a grouped active run to one stable row showing only the latest tool', () => {
const blocks: NativeChatBlock[] = [
{ type: 'tool-call', name: 'shell', input: { command: 'date' }, state: 'completed' },
{ type: 'tool-call', name: 'shell', input: { command: 'pwd' }, state: 'completed' },
{ type: 'tool-call', name: 'shell', input: { command: 'cat package.json' }, state: 'running' }
]
const { container } = render(<NativeChatToolRun blocks={blocks} expandSignal={false} />)
const header = runHeader(container)
expect(header).toHaveTextContent('Running 3 commands')
expect(header).toHaveTextContent('cat package.json')
expect(header).not.toHaveTextContent('date')
expect(header).not.toHaveTextContent('pwd')
expect(screen.getByText('Running 3 commands')).toHaveClass(
'animate-pulse',
'motion-reduce:animate-none'
)
expect(header.querySelector('.lucide-check')).toBeNull()
expect(container.querySelector('.animate-spin')).toBeNull()
})
it('treats legacy tool calls without lifecycle state as active while the turn works', () => {
render(
<NativeChatToolRun
blocks={[{ type: 'tool-call', name: 'shell', input: { command: 'sleep 5' } }]}
expandSignal={false}
activeTurnIsWorking
/>
)
expect(screen.getByText('Running 1 command')).toBeInTheDocument()
expect(screen.getByText('sleep 5')).toBeInTheDocument()
})
it('keeps a completed tool payload collapsed until the run is expanded', () => {
const blocks: NativeChatBlock[] = [
{
type: 'tool-call',
name: 'shell',
input: { command: 'printf hello' },
state: 'completed'
},
{ type: 'tool-result', output: 'hello' }
]
render(<NativeChatToolRun blocks={blocks} expandSignal={false} />)
expect(screen.queryByText('hello')).toBeNull()
})
// Call transitions must preserve one turn-owned header, its count, hover and status mark.
it('keeps one header element from a call starting until its turn ends', () => {
const running: NativeChatBlock[] = [
@@ -451,109 +157,6 @@ describe('NativeChatToolRun', () => {
expect(header.querySelector('.animate-pulse')).toBeNull()
})
it('settles a run the agent has moved past even while its last call still reports', () => {
const { container } = render(
<NativeChatToolRun
blocks={[
{ type: 'tool-call', name: 'shell', input: { command: 'sleep 1' }, state: 'running' }
]}
expandSignal={false}
activeTurnIsWorking
trailing={false}
/>
)
const header = runHeader(container)
expect(header).toHaveAttribute('data-native-chat-tool-run-state', 'settled')
expect(header).not.toHaveTextContent('Running')
expect(header.querySelector('.animate-pulse')).toBeNull()
expect(header.querySelector('.lucide-check')).toBeNull()
})
it('never animates a settled tool row with its completion check', () => {
const { container } = render(
<NativeChatToolRun
blocks={[
{ type: 'tool-call', name: 'shell', input: { command: 'pnpm test' }, state: 'completed' },
{ type: 'tool-result', output: 'passed' }
]}
expandSignal={false}
activeTurnIsWorking={false}
/>
)
const settledRow = runHeader(container)
expect(settledRow).toHaveTextContent('pnpm test')
expect(settledRow.querySelector('.lucide-check')).toBeInTheDocument()
// Windowing remounts settled rows on scroll; a mark that faded in would replay.
expect(settledRow.querySelector('.lucide-check')).not.toHaveClass('animate-in')
expect(settledRow.querySelector('.animate-pulse')).toBeNull()
expect(container.querySelector('.animate-pulse')).toBeNull()
})
it('refuses the completion mark to a collapsed run whose call failed', () => {
const blocks: NativeChatBlock[] = [
{ type: 'tool-call', name: 'shell', input: { command: 'false' }, state: 'failed' },
{ type: 'tool-result', output: 'exit 1', isError: true }
]
const { container } = render(<NativeChatToolRun blocks={blocks} expandSignal={false} />)
expect(container.querySelector('.lucide-check')).toBeNull()
expect(runHeader(container)).toHaveTextContent(' · 1 failed')
expect(runHeader(container)).toHaveAccessibleName(/Failed tool calls: 1/)
expect(container.querySelector('.lucide-circle-alert')).toBeNull()
expect(container.querySelector('[class*="destructive"]')).toBeNull()
expect(screen.queryByText('exit 1')).toBeNull()
})
it('counts every failed call in a run, not just the last one', () => {
const blocks: NativeChatBlock[] = [
{ type: 'tool-call', name: 'shell', input: { command: 'a' }, state: 'failed' },
{ type: 'tool-result', output: 'exit 1', isError: true },
{ type: 'tool-call', name: 'shell', input: { command: 'b' }, state: 'failed' },
{ type: 'tool-result', output: 'exit 2', isError: true },
{ type: 'tool-call', name: 'shell', input: { command: 'c' }, state: 'completed' },
{ type: 'tool-result', output: 'ok' }
]
const { container } = render(<NativeChatToolRun blocks={blocks} expandSignal={false} />)
expect(runHeader(container)).toHaveTextContent('2 failed')
expect(container.querySelector('.lucide-check')).toBeNull()
})
it('says nothing and keeps the mark when every call in the run succeeded', () => {
const blocks: NativeChatBlock[] = [
{ type: 'tool-call', name: 'shell', input: { command: 'a' }, state: 'completed' },
{ type: 'tool-result', output: 'ok' }
]
const { container } = render(<NativeChatToolRun blocks={blocks} expandSignal={false} />)
expect(runHeader(container)).not.toHaveTextContent('failed')
expect(container.querySelector('.lucide-check')).toBeInTheDocument()
})
// Keep output behind each call’s disclosure to avoid doubling expanded runs with Result rows.
it('keeps a call output behind the call row, not beside it', () => {
const blocks: NativeChatBlock[] = [
{ type: 'tool-call', name: 'shell', input: { command: 'git log -1' }, state: 'failed' },
{ type: 'tool-result', output: 'exit 128', isError: true }
]
render(<NativeChatToolRun blocks={blocks} expandSignal activeTurnIsWorking={false} />)
expect(screen.queryByText('Result')).toBeNull()
expect(screen.queryByText('exit 128')).toBeNull()
// Click the member, since this single-call header names the same command.
const rows = screen.getAllByText('git log -1')
fireEvent.click(rows.at(-1)!.closest('button')!)
expect(screen.getByText('exit 128')).toBeInTheDocument()
})
it('keeps a post-turn running call neutral until the item itself settles', () => {
const blocks: NativeChatBlock[] = [
{ type: 'tool-call', name: 'shell', input: { command: 'sleep 1' }, state: 'running' }
@@ -578,284 +181,7 @@ describe('NativeChatToolRun', () => {
expect(container.querySelector('.lucide-check')).toBeInTheDocument()
})
it('shows the category glyph beside the word a classified row is named by', () => {
const blocks: NativeChatBlock[] = [
{
type: 'tool-call',
name: 'read',
input: { command: "sed -n '1,200p' notes.txt", path: 'notes.txt' },
state: 'completed'
}
]
const { container } = render(<NativeChatToolRun blocks={blocks} expandSignal />)
const glyph = container.querySelector('.lucide-eye')
expect(glyph).toBeInTheDocument()
expect(glyph).toHaveAttribute('aria-hidden')
expect(screen.getByText('Read')).toBeInTheDocument()
})
it('holds one glyph for a category across running, completed, and failed', () => {
const searchCall = (state: 'running' | 'completed' | 'failed'): NativeChatBlock[] => [
{ type: 'tool-call', name: 'search', input: { query: 'beta' }, state }
]
const { container, rerender } = render(
<NativeChatToolRun blocks={searchCall('running')} expandSignal activeTurnIsWorking />
)
expect(leadingGlyphs(container)).toEqual(['lucide-search', 'lucide-search'])
for (const settled of ['completed', 'failed'] as const) {
rerender(
<NativeChatToolRun blocks={searchCall(settled)} expandSignal activeTurnIsWorking={false} />
)
expect(leadingGlyphs(container)).toEqual(['lucide-search', 'lucide-search'])
}
})
it('falls back to the generic tool glyph, not the terminal, for an unmodelled row', () => {
const blocks: NativeChatBlock[] = [
{
type: 'tool-call',
name: 'CreateWidget',
input: { prompt: 'which?' },
state: 'completed'
}
]
const { container } = render(<NativeChatToolRun blocks={blocks} expandSignal />)
expect(container.querySelector('.lucide-square-terminal')).toBeNull()
expect(container.querySelector('.lucide-wrench')).toBeInTheDocument()
})
it('agrees between the header and the row it names for an unmodelled tool', () => {
const blocks: NativeChatBlock[] = [
{
type: 'tool-call',
name: 'CreateWidget',
input: { prompt: 'which?' },
state: 'completed'
}
]
const { container } = render(<NativeChatToolRun blocks={blocks} expandSignal />)
expect(leadingGlyphs(container)).toEqual(['lucide-wrench', 'lucide-wrench'])
})
// Unpaired results have translated labels rather than a tool name to classify.
it('leaves an unpaired result row without a category glyph', () => {
const blocks: NativeChatBlock[] = [{ type: 'tool-result', output: 'first line' }]
const { container } = render(<NativeChatToolRun blocks={blocks} expandSignal />)
expect(runHeader(container)).toHaveAccessibleName('Result')
const resultRow = screen.getByRole('button', { name: 'Result first line' })
// Keying a category off 'Result' would resolve a different glyph per locale.
expect(
[...(resultRow?.querySelectorAll('svg') ?? [])].map(
(svg) => svg.getAttribute('class')?.match(/lucide-[a-z0-9-]+/)?.[0]
)
).toEqual(['lucide-chevron-right'])
})
it('heads a projected diff run with the file-change glyph, not the generic one', () => {
const projected = projectStructuredItemToNativeChat({
itemId: 'file-change',
revision: 1,
sequence: 1,
observedAt: 1,
body: {
kind: 'diff',
path: 'src/a.ts',
patch: {
head: '@@ -1 +1 @@\n-was\n+now',
truncated: false,
byteLength: 24,
digest: 'a'.repeat(64)
}
}
})
const { container } = render(
<NativeChatToolRun blocks={projected?.blocks ?? []} expandSignal />
)
expect(container.querySelector('.lucide-pencil')).toBeInTheDocument()
expect(container.querySelector('.lucide-wrench')).toBeNull()
})
describe('the settled header glyph over a whole run', () => {
// The header glyph describes the summarized run, not its last-finishing call.
const call = (name: string, input: unknown): NativeChatBlock => ({
type: 'tool-call',
name,
input,
state: 'completed'
})
it('heads a run that is all reads with the read glyph', () => {
const blocks: NativeChatBlock[] = [
call('read', { command: "sed -n '1,50p' a.ts", path: 'a.ts' }),
call('read', { command: "sed -n '1,50p' b.ts", path: 'b.ts' })
]
const { container } = render(
<NativeChatToolRun blocks={blocks} expandSignal activeTurnIsWorking={false} />
)
expect(leadingGlyphs(container)).toEqual(['lucide-eye', 'lucide-eye', 'lucide-eye'])
})
it('heads a run that is all shell with the terminal glyph, whatever each is named', () => {
const blocks: NativeChatBlock[] = [
call('shell', { command: 'npm test' }),
call('Bash', { command: 'git status' })
]
const { container } = render(
<NativeChatToolRun blocks={blocks} expandSignal activeTurnIsWorking={false} />
)
expect(leadingGlyphs(container)).toEqual([
'lucide-square-terminal',
'lucide-square-terminal',
'lucide-square-terminal'
])
})
it('heads a run spanning categories with the generic tool glyph', () => {
const blocks: NativeChatBlock[] = [
call('shell', { command: 'npm test' }),
call('read', { command: "sed -n '1,50p' a.ts", path: 'a.ts' })
]
const { container } = render(
<NativeChatToolRun blocks={blocks} expandSignal activeTurnIsWorking={false} />
)
expect(leadingGlyphs(container)).toEqual([
'lucide-wrench',
'lucide-square-terminal',
'lucide-eye'
])
})
it('heads a single-call run with that call\u2019s own glyph', () => {
const { container } = render(
<NativeChatToolRun
blocks={[call('Grep', { pattern: 'todo' })]}
expandSignal
activeTurnIsWorking={false}
/>
)
expect(leadingGlyphs(container)).toEqual(['lucide-search', 'lucide-search'])
})
it.each([1, 3])('labels a run of %s unpaired results without inventing tool calls', (count) => {
const blocks: NativeChatBlock[] = Array.from({ length: count }, (_, index) => ({
type: 'tool-result',
output: `first line ${index}`
}))
const { container } = render(
<NativeChatToolRun blocks={blocks} expandSignal activeTurnIsWorking={false} />
)
expect(runHeader(container)).toHaveAccessibleName('Result')
expect(runHeader(container)).not.toHaveTextContent(/tool calls?/)
expect(leadingGlyphs(container)).toEqual([
'lucide-check',
...blocks.map(() => 'lucide-chevron-right')
])
})
it('holds the run glyph across live and settled', () => {
const blocks: NativeChatBlock[] = [
call('read', { command: "sed -n '1,50p' a.ts", path: 'a.ts' }),
{ type: 'tool-call', name: 'shell', input: { command: 'npm test' }, state: 'running' }
]
const { container, rerender } = render(
<NativeChatToolRun blocks={blocks} expandSignal activeTurnIsWorking />
)
// Keep the run’s identity glyph across state transitions instead of using the latest call’s category.
expect(leadingGlyphs(container)[0]).toBe('lucide-wrench')
rerender(<NativeChatToolRun blocks={blocks} expandSignal activeTurnIsWorking={false} />)
expect(leadingGlyphs(container)[0]).toBe('lucide-wrench')
})
})
it('labels a bare list row by the command it ran rather than an invented path', () => {
const blocks: NativeChatBlock[] = [
{
type: 'tool-call',
name: 'list',
input: { command: 'ls', cwd: '/repo' },
state: 'completed'
}
]
const { container } = render(<NativeChatToolRun blocks={blocks} expandSignal />)
expect(container.querySelector('.lucide-folder')).toBeInTheDocument()
expect(screen.getByTitle('ls')).toHaveTextContent('ls')
})
// Unnamed group-hover matches the surrounding message too, revealing every member caret at once.
describe('hover reveal', () => {
const run: NativeChatBlock[] = [
{ type: 'tool-call', name: 'Bash', input: { command: 'ls -la' }, state: 'completed' },
{ type: 'tool-result', output: 'a\nb' },
{ type: 'tool-call', name: 'Bash', input: { command: 'pwd' }, state: 'completed' },
{ type: 'tool-result', output: '/tmp' }
]
/** Read class attributes because SVG className is an SVGAnimatedString. */
function hoverRevealed(container: HTMLElement): { element: Element; classes: string }[] {
return [...container.querySelectorAll('[class*="group-hover"]')].map((element) => ({
element,
classes: element.getAttribute('class') ?? ''
}))
}
it('scopes a row’s reveal to that row rather than the whole message', () => {
// Open run, collapsed children — the state the turn caret leaves behind.
const { container } = render(<NativeChatToolRun blocks={run} expandSignal />)
const revealed = hoverRevealed(container)
expect(revealed.length).toBeGreaterThan(0)
revealed.forEach(({ classes }) => {
expect(classes).not.toMatch(/(^|\s)group-hover:/)
})
})
it('puts every reveal inside the row button that governs it', () => {
const { container } = render(<NativeChatToolRun blocks={run} expandSignal />)
hoverRevealed(container).forEach(({ element, classes }) => {
const scope = /group-hover\/([a-z-]+):/.exec(classes)?.[1]
expect(scope).toBeDefined()
expect(element.closest(`.group\\/${scope}`)).not.toBeNull()
})
})
it('keeps every row’s chevron in view at the row’s right edge', () => {
const { container } = render(<NativeChatToolRun blocks={run} expandSignal />)
const chevrons = [...container.querySelectorAll('.border-l button svg.lucide-chevron-right')]
expect(chevrons.length).toBeGreaterThan(1)
chevrons.forEach((chevron) => {
expect(chevron.getAttribute('class')).not.toContain('opacity-0')
})
})
})
})
describe('NativeChatToolRun task lists', () => {
@@ -1,7 +1,7 @@
// @vitest-environment happy-dom
import { act, createElement } from 'react'
import { fireEvent, screen, within } from '@testing-library/react'
import { createRoot } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client-types'
@@ -146,103 +146,6 @@ describe('NativeChatImageAttachments', () => {
}
})
it('pools visibility observation across image refs', async () => {
class FakeIntersectionObserver {
static instances: FakeIntersectionObserver[] = []
readonly observe = vi.fn()
readonly unobserve = vi.fn()
readonly disconnect = vi.fn()
constructor(_callback: IntersectionObserverCallback) {
FakeIntersectionObserver.instances.push(this)
}
}
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver)
const container = document.createElement('div')
const root = createRoot(container)
await act(async () => {
root.render(
createElement(NativeChatImageAttachments, {
blocks: [
{ type: 'image-ref' as const, path: '/repo/one.png' },
{ type: 'image-ref' as const, path: '/repo/two.png' },
{ type: 'image-ref' as const, path: '/repo/three.png' }
],
runtimeContext: runtimeContext('wt-1')
})
)
await flushPromises()
})
expect(FakeIntersectionObserver.instances).toHaveLength(1)
expect(FakeIntersectionObserver.instances[0]?.observe).toHaveBeenCalledTimes(3)
root.unmount()
expect(FakeIntersectionObserver.instances[0]?.unobserve).toHaveBeenCalledTimes(3)
expect(FakeIntersectionObserver.instances[0]?.disconnect).toHaveBeenCalledOnce()
})
it('offers the full-size file of a sent image to the chat copy menu', async () => {
const container = document.createElement('div')
const root = createRoot(container)
await act(async () => {
root.render(
createElement(NativeChatImageAttachments, {
blocks: [{ type: 'image-ref' as const, path: '/repo/image.png' }],
runtimeContext: runtimeContext('wt-1')
})
)
await flushPromises()
})
expect(container.querySelector('button')?.getAttribute('data-native-chat-copy-image-src')).toBe(
'blob:owner-1'
)
root.unmount()
})
it.each([
'data:image/png;base64,AA==',
'blob:sent-original',
'http://example.test/original.png',
'https://example.test/original.svg'
])(
'offers the original displayed source %s in the thumbnail and full-size preview',
async (src) => {
const container = document.body.appendChild(document.createElement('div'))
const root = createRoot(container)
try {
await act(async () => {
root.render(
createElement(NativeChatImageAttachments, {
blocks: [{ type: 'image-ref', url: src, alt: 'Sent image' }],
runtimeContext: runtimeContext('wt-1')
})
)
await flushPromises()
})
const thumbnail = within(container).getByRole('button', { name: 'View image: Sent image' })
expect(thumbnail.getAttribute('data-native-chat-copy-image-src')).toBe(src)
fireEvent.click(thumbnail)
const preview = screen.getByRole('dialog', { name: 'Sent image' })
expect(
within(preview)
.getByTestId('pan-zoom-image-canvas')
.closest('[data-native-chat-copy-image-src]')
?.getAttribute('data-native-chat-copy-image-src')
).toBe(src)
expect(window.api.fs.readFile).not.toHaveBeenCalled()
fireEvent.click(within(preview).getByRole('button', { name: 'Close' }))
expect(screen.queryByRole('dialog')).toBeNull()
} finally {
await act(async () => root.unmount())
container.remove()
}
}
)
it('preserves same-image errors but retries when the runtime owner changes', async () => {
const container = document.createElement('div')
const root = createRoot(container)
@@ -292,62 +195,6 @@ describe('NativeChatImageAttachments', () => {
root.unmount()
})
it('keeps the shown image when an equal runtime context is rebuilt', async () => {
const container = document.createElement('div')
const root = createRoot(container)
const blocks = [{ type: 'image-ref' as const, path: '/repo/image.png' }]
await act(async () => {
root.render(
createElement(NativeChatImageAttachments, {
blocks,
runtimeContext: runtimeContext('wt-1')
})
)
await flushPromises()
})
const img = container.querySelector('img')
expect(img?.getAttribute('src')).toBe('blob:owner-1')
for (let update = 0; update < 3; update += 1) {
await act(async () => {
root.render(
createElement(NativeChatImageAttachments, {
blocks,
runtimeContext: runtimeContext('wt-1')
})
)
await flushPromises()
})
}
expect(URL.revokeObjectURL).not.toHaveBeenCalled()
expect(window.api.fs.readFile).toHaveBeenCalledOnce()
expect(container.querySelector('img')).toBe(img)
expect(img?.getAttribute('src')).toBe('blob:owner-1')
root.unmount()
})
it('shows a remounted row from the cache without re-reading the image', async () => {
const props = {
blocks: [{ type: 'image-ref' as const, path: '/repo/image.png' }],
runtimeContext: runtimeContext('wt-1')
}
for (let mount = 0; mount < 2; mount += 1) {
const container = document.createElement('div')
const root = createRoot(container)
await act(async () => {
root.render(createElement(NativeChatImageAttachments, props))
await flushPromises()
})
expect(container.querySelector('img')?.getAttribute('src')).toBe('blob:owner-1')
await act(async () => root.unmount())
}
expect(window.api.fs.readFile).toHaveBeenCalledOnce()
expect(URL.revokeObjectURL).not.toHaveBeenCalled()
})
it('retries a failed thumbnail after the image cache refreshes', async () => {
const container = document.createElement('div')
const root = createRoot(container)
@@ -375,70 +222,4 @@ describe('NativeChatImageAttachments', () => {
expect(container.querySelector('img')?.getAttribute('src')).toBe('blob:owner-2')
root.unmount()
})
it('keeps the observed element stable while a preview is materialized', async () => {
let callback: IntersectionObserverCallback | undefined
class FakeIntersectionObserver {
readonly observe = vi.fn()
readonly unobserve = vi.fn()
readonly disconnect = vi.fn()
constructor(nextCallback: IntersectionObserverCallback) {
callback = nextCallback
}
}
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver)
const container = document.createElement('div')
const root = createRoot(container)
await act(async () => {
root.render(
createElement(NativeChatImageAttachments, {
blocks: [{ type: 'image-ref' as const, path: '/repo/image.png' }],
runtimeContext: runtimeContext('wt-1')
})
)
await flushPromises()
})
const observedElement = container.firstElementChild
expect(observedElement).not.toBeNull()
if (!observedElement || !callback) {
throw new Error('image preview did not register visibility observation')
}
const notifyVisibility = callback
await act(async () => {
notifyVisibility(
[{ target: observedElement, isIntersecting: true } as IntersectionObserverEntry],
{} as IntersectionObserver
)
await flushPromises()
})
expect(container.firstElementChild).toBe(observedElement)
root.unmount()
})
it.each([
['a pasted screenshot in the temp folder', '/tmp/orca-paste-1.png'],
['an agent image outside the project', '/Users/me/.codex/generated/plot.png']
])('reads %s as a chat image, whoever sent it', async (_label, path) => {
const container = document.createElement('div')
const root = createRoot(container)
await act(async () => {
root.render(
createElement(NativeChatImageAttachments, {
blocks: [{ type: 'image-ref' as const, path }],
runtimeContext: runtimeContext('wt-1')
})
)
await flushPromises()
})
expect(vi.mocked(window.api.fs.readFile).mock.calls[0]?.[0]).toMatchObject({
filePath: path,
access: { kind: 'chat-image' }
})
root.unmount()
})
})
@@ -3,9 +3,9 @@ import '@testing-library/jest-dom/vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import type { NativeChatMessage } from '../../../../shared/native-chat/native-chat-types'
import { nativeChatTurnDiffs } from '../../../../shared/native-chat/tools/native-chat-turn-diffs'
import { TooltipProvider } from '../ui/tooltip'
import { NativeChatTurnDiffRollup } from './NativeChatTurnDiffRollup'
import { NativeChatMessageList } from './NativeChatMessageList'
import { session, stubLayout, stubResizeObserver } from './native-chat-windowing-test-harness'
@@ -96,29 +96,3 @@ it('keeps landed files visible after work folds, opens a patch in one click and
expect(screen.getByRole('button', { name: /status.ts/ })).toBeVisible()
expect(screen.queryByText('diagnostic-output')).toBeNull()
})
it('mounts a bounded file prefix and no patch bodies until a file is selected', () => {
const many: NativeChatMessage[] = Array.from({ length: 200 }, (_, index) => ({
id: `e${index}`,
role: 'assistant',
source: 'transcript',
timestamp: index,
blocks: [
{ type: 'tool-call', name: 'Diff', input: { path: `file-${index}.ts` } },
{ type: 'tool-result', output: `@@ -1 +1 @@\n-old-${index}\n+new-${index}` }
]
}))
const diff = nativeChatTurnDiffs(
many,
many.map(() => 'u')
).get('u')!
const { container } = render(<NativeChatTurnDiffRollup diff={diff} />)
expect(screen.getAllByRole('button', { name: /file-\d+.ts/ })).toHaveLength(20)
expect(container.querySelectorAll('[data-native-chat-code-content]')).toHaveLength(0)
fireEvent.click(screen.getByRole('button', { name: /file-0.ts/ }))
expect(screen.getByText('new-0')).toBeVisible()
expect(screen.queryByText('new-1')).toBeNull()
expect(container.querySelectorAll('[data-native-chat-code-content]')).toHaveLength(1)
fireEvent.click(screen.getByRole('button', { name: /Show 180 more/ }))
expect(screen.getByRole('button', { name: /file-199.ts/ })).toBeVisible()
})
@@ -7,13 +7,12 @@ import { NativeChatUsageLimitActionsProvider } from './NativeChatUsageLimitActio
import { NativeChatNoticeRow } from './NativeChatNoticeRow'
import type { NativeChatOrcaStopView } from './native-chat-orca-stop-context'
import { TooltipProvider } from '@/components/ui/tooltip'
import { useAppStore } from '@/store'
import { buildAgentSessionContinuationPrompt } from '@/lib/agent-session-continuation'
import type { NativeChatMessage } from '../../../../shared/native-chat/native-chat-types'
import type { AgentType } from '../../../../shared/agent-status/contracts/agent-status-types'
import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target'
import type { AgentSessionStatusSummary } from '../../../../shared/agent-session/agent-session-wire'
import { useNativeChatUsageLimitAutoContinue } from './use-native-chat-usage-limit-auto-continue'
import type { useNativeChatUsageLimitAutoContinue } from './use-native-chat-usage-limit-auto-continue'
const mocks = vi.hoisted(() => ({
readSnapshot: {
@@ -184,104 +183,6 @@ function expectInlineActions(accountName: string | null) {
expect(account.parentElement).toHaveClass('w-32', 'shrink-0')
}
}
function snapshot(count: number) {
return {
claude: {
activeAccountId: 'a0',
accounts: Array.from({ length: count }, (_, index) => ({
id: `a${index}`,
email: `account${index}@example.com`,
authMethod: 'subscription-oauth',
createdAt: 1,
updatedAt: 1,
lastAuthenticatedAt: 1
}))
}
}
}
it('offers host-owned recovery actions for the current usage-limit cut', async () => {
const target: RuntimeClientTarget = { kind: 'environment', environmentId: 'chat-host' }
mocks.snapshot.mockResolvedValue(snapshot(2))
mocks.select.mockResolvedValue(snapshot(2).claude)
const original = useAppStore.getState()
const openSettingsTarget = vi.fn()
useAppStore.setState({ openSettingsTarget, openSettingsPage: vi.fn() })
try {
const view = render(
row(target, true, 'claude', [], { readCurrent: false, canSwitchAccount: undefined })
)
expectInlineActions('Switch Account')
expect(screen.getByRole('button', { name: 'Switch Account' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Hand Off to Another Agent' })).toBeDisabled()
view.rerender(row(target))
expect(screen.getByRole('button', { name: 'Switch Account' })).toBeDisabled()
await waitFor(() =>
expect(screen.getByRole('button', { name: 'Switch Account' })).toBeEnabled()
)
expectInlineActions('Switch Account')
fireEvent.pointerDown(screen.getByRole('button', { name: 'Switch Account' }), {
button: 0,
ctrlKey: false,
pointerType: 'mouse'
})
fireEvent.click(await screen.findByRole('menuitem', { name: 'account1@example.com' }))
await waitFor(() =>
expect(mocks.select).toHaveBeenCalledWith(target, {
accountId: 'a1',
runtime: 'host',
wslDistro: null
})
)
fireEvent.click(screen.getByRole('button', { name: 'Hand Off to Another Agent' }))
await waitFor(() => expect(mocks.dialog).toHaveBeenCalled())
expect(mocks.dialog.mock.lastCall?.[0].request).toMatchObject({
worktreeId: 'folder',
avoidSourceAgent: true,
launchSource: 'native_chat',
source: { transcriptPath: '/work/chat.jsonl', sourceAgent: 'claude', sourceTitle: 'My chat' }
})
view.rerender(row(target, false))
expect(screen.queryByRole('button', { name: 'Continue Now' })).toBeNull()
expect(screen.queryByText('Continue this chat without waiting:')).toBeNull()
cleanup()
mocks.snapshot.mockResolvedValue(snapshot(1))
render(row(target))
await waitFor(() => expect(screen.queryByRole('button', { name: /Account/ })).toBeNull())
expect(
screen.getByText('Continue this chat without waiting:').parentElement?.querySelector('.w-32')
).toHaveClass('empty:hidden')
const watcher = mocks.watch.mock.lastCall?.[1]
watcher.onSnapshot(snapshot(2))
await waitFor(() =>
expect(screen.getByRole('button', { name: 'Switch Account' })).toBeEnabled()
)
expectInlineActions('Switch Account')
cleanup()
const local: RuntimeClientTarget = { kind: 'local' }
render(row(local))
fireEvent.click(await screen.findByRole('button', { name: 'Add Account…' }))
expect(openSettingsTarget).toHaveBeenCalledWith({
pane: 'accounts',
repoId: null,
sectionId: 'accounts-claude',
hostScope: local,
intent: 'native-accounts'
})
cleanup()
mocks.follows = false
render(row(target, true, 'codex'))
expect(screen.getByRole('button', { name: 'Hand Off to Another Agent' })).toBeEnabled()
expect(screen.queryByRole('button', { name: /Account/ })).toBeNull()
expectInlineActions(null)
} finally {
useAppStore.setState({
openSettingsPage: original.openSettingsPage,
openSettingsTarget: original.openSettingsTarget
})
}
})
it('admits one preparation and discards it after Continue supersedes the cut', async () => {
let answer: (value: typeof mocks.readSnapshot) => void = () => {}
mocks.options.mockImplementation(
@@ -311,72 +212,3 @@ it('admits one preparation and discards it after Continue supersedes the cut', a
fireEvent.click(screen.getByText('Cancel hand-off'))
expect(screen.queryByText('Cancel hand-off')).toBeNull()
})
it('hands off database-backed chats using labelled recent messages when no native file exists', async () => {
mocks.options.mockResolvedValue({ canSwitchAccount: false })
render(
row({ kind: 'local' }, true, 'opencode', [
{
id: 'recent',
role: 'user',
timestamp: 1,
source: 'transcript',
blocks: [{ type: 'text', text: 'Keep the recent task context' }]
}
])
)
fireEvent.click(screen.getByRole('button', { name: 'Hand Off to Another Agent' }))
await waitFor(() => expect(mocks.dialog).toHaveBeenCalled())
const source = mocks.dialog.mock.lastCall?.[0].request.source
expect(source.transcriptPath).toBeUndefined()
expect(buildAgentSessionContinuationPrompt(source, 'focused')).toContain(
'A saved session transcript was unavailable, so use these recent chat messages:'
)
expect(source.capturedText).toContain('user: Keep the recent task context')
expect(buildAgentSessionContinuationPrompt(source, 'full')).toBeNull()
})
it('withdraws the source schedule after hand-off starts without waiting for cancellation', async () => {
const target: RuntimeClientTarget = { kind: 'environment', environmentId: 'chat-host' }
const resetsAt = 1_800_000_000_000
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(resetsAt - 60_000)
status.set('chat', { usageLimitContinue: { turnItemId: 'cut', resetsAt, state: 'scheduled' } })
mocks.follows = false
function ScheduledRow() {
const automatic = useNativeChatUsageLimitAutoContinue(target, 'chat', {
turnItemId: 'cut',
resetsAt,
turnEndedAt: resetsAt - 120_000
})
return row(target, true, 'codex', [], undefined, automatic)
}
const view = render(<ScheduledRow />)
fireEvent.click(screen.getByRole('button', { name: 'Hand Off to Another Agent' }))
fireEvent.click(await screen.findByText('Cancel hand-off'))
expect(
mocks.options.mock.calls.some((call) => call[1] === 'agentSession.cancelUsageLimitContinue')
).toBe(false)
fireEvent.click(screen.getByRole('button', { name: 'Hand Off to Another Agent' }))
await screen.findByText('Start hand-off')
let finish = () => {}
mocks.options.mockImplementation(
() =>
new Promise((resolve) => {
finish = () => resolve({})
})
)
fireEvent.click(screen.getByText('Start hand-off'))
expect(screen.queryByText('Cancel hand-off')).toBeNull()
expect(mocks.options).toHaveBeenLastCalledWith(target, 'agentSession.cancelUsageLimitContinue', {
sessionId: 'chat',
turnItemId: 'cut',
resetsAt
})
status = new Map([
['chat', { usageLimitContinue: { turnItemId: 'cut', resetsAt, state: 'cancelled' } }]
])
view.rerender(<ScheduledRow />)
expect(screen.getByText("Orca won't continue this chat automatically.")).toBeInTheDocument()
finish()
})