Files
orca/mobile/src/session/MobileNativeChatTurnStatus.test.ts
T
Brennan Benson a154562a89 fix(native-chat): show a reply cut off by an Orca crash or quit like a finished turn, with one explanation (#25043)
* fix(native-chat): read a crash-cut reply like a finished turn, with one explanation

A reply that an Orca crash or restart cut off said it failed three times: the
turn bar read "Failed after N", the chat's notice row said Claude stopped, and
the sidebar dot stayed red after the chat was read.

The turn bar now reads "Worked for N" for a proven crash/restart cut, the
notice row stays the one explanation, and the sidebar card's dot and agent
rows read failed only until the user has visited the chat (the same
acknowledgement that un-bolds the row), then read done. A failure, a user's
Stop, a replaced turn and an unconfirmed end keep their labels. The stored
outcome is unchanged.

* fix(native-chat): explain a turn a quit or eviction cut, once

A turn cut off by quitting Orca, an idle eviction or a teardown recorded the
same outcome as a crash-cut turn but wrote no notice row, so with the turn bar
now reading "Worked for N" nothing in the chat said it stopped.

The host stop's settle now writes the existing providerExited notice for the
latest turn when it ends as news (no person's Stop decided it): in the same
write as the host's own turn end, or right after the adapter's. It is keyed by
the turn, and skipped when an error row already explains that turn, so a retry
or an earlier exit row never leaves two.

* test(native-chat): narrow the turn scope and add the seen map in the crash-cut tests

* fix(native-chat): derive a cut turn's one notice on read instead of writing it at stop time

A turn cut short when the agent stopped without anyone asking now reads
"Worked for N", so it needs a row saying it stopped. Writing that row only on
the quit and eviction paths missed journals written before this change, a quit
that died between its drain and its settle, and any future stop cause.

The transcript now derives it from the journal on desktop and phone alike: a
root turn that ended interrupted with no verdict and no row about the stop
gets one notice in the provider-exit row's words. A stored exit row, matched by
its exit fact or its writer's identity rather than its tone, stays the
explanation, and so does the restart continuation's own outcome note, so a
refused resume says it once. The host's stop path is back to what it was.

* fix(sidebar): read a cut-short turn's red mark from its acknowledgement on every surface

A turn cut short with nobody asking reads failed only until the user has seen
it. The previous revision passed an optional "seen" flag to each caller, so any
surface that did not pass it, the chat's own tab dot among them, stayed red
beside a sidebar that read Done.

The verdict's display now takes the acknowledgement as part of what it reads:
the entry's stateStartedAt beside the time the user last acknowledged it, both
required, judged by one shared rule. Each surface joins it once where it builds
its rows: the sidebar's agent rows (and so the notes send menu), the workspace
card summary, the terminal tab bar, Cmd-J recent rows, and Activity threads.
Failures, Stops, replaced turns and unproven ends keep their marks as before;
the phone, which has no acknowledgement record, keeps the unseen reading.

* refactor(attention): use the one acknowledgement rule where it was copied

Auto-acknowledgement, the Activity unread count, dashboard row buckets and
notification acknowledgement each spelled the same "acknowledged at or after
the current state began" comparison; they now call the shared rule.

* test(mobile): type the cut-turn notice test's client and hook holder

* test: pin an older host's exit row by its writer and the sidebar rows' acknowledgement join

* test(sidebar): re-read the card and the tab when only an acknowledgement changes

* fix(native-chat): keep a cut turn's notice through a resume that carries on

A resume after a quit writes its "asked this agent to continue" note after its
own message, so counting that note as the cut's explanation removed the notice
once the resume went on, and made it flash away under automatic resume. Only the
notes that say the chat was not carried on (refused, not connected, not
confirmed) stand in for the notice now.

A row about the whole conversation now explains a cut turn only when no other
turn lies between them, and a failed start's row, which is about a start, never
does. The host writers and the rule share the row identity prefixes, with the
contract that a new row explaining a stop carries the provider-exited fact or
one of them.

* fix(sidebar): judge a cut as seen by the main agent's clock when a subagent holds the row

A subagent can keep a row working after its main agent was cut, and the row's
clock then predates the cut, so a look at the working chat counted as having
seen the cut and no red mark showed. The mark now compares the acknowledgement
with the later of the row's and the main agent's clocks; auto-acknowledgement of
the chat on screen reads the same clock, and its stamp covers it, so looking at
the chat still clears the mark. Bold rows, dashboard buckets, notifications and
unread counts keep the row's clock.

* fix(native-chat): tie a conversation-wide exit row to a cut only with no message sent since

A send after a quit's cut whose new agent died before its turn opened leaves a
provider-exit row about the whole conversation. That row is about the send,
not the earlier cut, so the cut kept no explanation. An exit row now explains
a preceding cut only when no message was sent in between; the restart notes,
which follow the continuation's own message, still need only no turn between.

The notes that say a resume did not carry the chat on are now told apart from
the continued note by their tone ('error' or 'warning'), which every host that
wrote them has set, instead of by their words.

* fix(native-chat): keep an owner's proven death the explanation of its cut after a send

A chat read before the startup reconcile settles its cut turn unverifiable;
if the user then sends a message, the reconcile proves the old agent dead and
writes its row about the conversation after that message. The row names the
old owner's death, never the send, so a message since no longer detaches it
from the cut. Only a provider-exit row, which a later start can write, still
needs no message sent since.

* test(sidebar): give the activity-status store mock the acknowledgement map

The card summary now reads acknowledgedAgentsByPaneKey; this test's hand-built
store state lacked it, so every summary read threw.

* refactor: move the seen-gated red mark out of this change

This change now keeps only the cut turn's label and its one derived notice.
The red mark that clears once the user has seen a cut turn moves to its own
change, so each can land alone; until it lands, the sidebar, tab bar, Cmd-J,
Activity and the notes send menu read a cut turn as failed, as before.

* test(native-chat): pin that a restart note after a continuation's turn leaves the cut's notice

* test(native-chat): keep a cut turn's notice beside a later message drawn as not sent
2026-10-04 23:19:58 -07:00

146 lines
5.2 KiB
TypeScript

import { createElement } from 'react'
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('react-native', async () => {
const React = await import('react')
const Text = ({ children, ...props }: { children?: unknown }): unknown =>
React.createElement('Text', props, children)
return {
ActivityIndicator: (props: Record<string, unknown>) =>
React.createElement('ActivityIndicator', props),
Pressable: ({ children, ...props }: { children?: unknown }) =>
React.createElement('Pressable', props, children),
Text,
View: ({ children, ...props }: { children?: unknown }) =>
React.createElement('View', props, children),
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }
}
})
vi.mock('lucide-react-native', () => ({ ChevronRight: 'ChevronRight' }))
import {
MobileNativeChatTurnActivity,
MobileNativeChatTurnStatus
} from './MobileNativeChatTurnStatus'
const labels = (node: ReactTestInstance): string[] =>
node.findAllByType('Text' as never).map((text) => String(text.children.join('')))
const spinners = (node: ReactTestInstance): ReactTestInstance[] =>
node.findAllByType('ActivityIndicator' as never)
let renderer: ReactTestRenderer | null = null
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-09-04T00:00:00Z'))
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
vi.useRealTimers()
})
describe('MobileNativeChatTurnStatus', () => {
function render(props: {
startedAt: number | null
workedSeconds?: number | null
verdict?: 'interruption' | 'cancellation'
expanded?: boolean
onToggleExpanded?: () => void
}): ReactTestRenderer {
act(() => {
renderer = create(createElement(MobileNativeChatTurnStatus, props))
})
return renderer!
}
it('counts up from the first second while the turn runs, with no spinner', () => {
const tree = render({ startedAt: Date.now() })
expect(labels(tree.root)).toEqual(['Working for 0s'])
act(() => {
vi.advanceTimersByTime(12_000)
})
expect(labels(tree.root)).toEqual(['Working for 12s'])
expect(spinners(tree.root)).toHaveLength(0)
})
it('settles to a tappable "Worked for" row that toggles the turn', () => {
const onToggleExpanded = vi.fn()
const tree = render({ startedAt: Date.now(), workedSeconds: 184, onToggleExpanded })
expect(labels(tree.root)).toEqual(['Worked for 3m 4s'])
const button = tree.root.findByType('Pressable' as never)
expect(button.props.accessibilityLabel).toBe('Toggle turn details')
expect(button.props.accessibilityState).toEqual({ expanded: false })
act(() => button.props.onPress())
expect(onToggleExpanded).toHaveBeenCalledOnce()
})
// The crash's notice row explains the cut; the turn bar reads like any finished turn.
it('heads a turn a crash cut off as worked, and a turn the user stopped as interrupted', () => {
const crashed = render({
startedAt: Date.now(),
workedSeconds: 12,
verdict: 'interruption',
onToggleExpanded: vi.fn()
})
expect(labels(crashed.root)).toEqual(['Worked for 12s'])
act(() => crashed.unmount())
const stopped = render({
startedAt: Date.now(),
workedSeconds: 12,
verdict: 'cancellation',
onToggleExpanded: vi.fn()
})
expect(labels(stopped.root)).toEqual(['Interrupted after 12s'])
})
it('stays a plain row when the settled turn has nothing to disclose', () => {
const tree = render({ startedAt: Date.now(), workedSeconds: 5 })
expect(tree.root.findAllByType('Pressable' as never)).toHaveLength(0)
expect(labels(tree.root)).toEqual(['Worked for 5s'])
})
it('holds no interval once the turn has settled', () => {
render({ startedAt: Date.now(), workedSeconds: 5 })
expect(vi.getTimerCount()).toBe(0)
})
})
describe('MobileNativeChatTurnActivity', () => {
function render(props: { thinking: boolean; activityText?: string | null }): ReactTestRenderer {
act(() => {
renderer = create(createElement(MobileNativeChatTurnActivity, props))
})
return renderer!
}
it('reads "Thinking" beside one spinner while the turn reasons', () => {
const tree = render({ thinking: true })
expect(labels(tree.root)).toEqual(['Thinking'])
expect(spinners(tree.root)).toHaveLength(1)
})
// The bar owns the clock; the tail line never repeats it.
it('reads plain "Working…" when the turn is not reasoning, and holds no timer', () => {
const tree = render({ thinking: false })
expect(labels(tree.root)).toEqual(['Working…'])
expect(vi.getTimerCount()).toBe(0)
})
it('lets provider activity text beat both fallbacks', () => {
const tree = render({ thinking: true, activityText: 'Running pnpm test' })
expect(labels(tree.root)).toEqual(['Running pnpm test'])
expect(spinners(tree.root)).toHaveLength(1)
})
it('announces the live line to assistive tech', () => {
const tree = render({ thinking: true })
const row = tree.root.findByType('View' as never)
expect(row.props.accessibilityLiveRegion).toBe('polite')
expect(row.props.accessibilityLabel).toBe('Agent is responding')
})
})