Files
orca/mobile/src/session/mobile-native-chat-render-data.test.ts
T
Brennan Benson a0a7535b63 fix(native-chat): say each chat outcome once and drop internal-only surfaces (#24595)
* fix(native-chat): say each chat outcome once and drop internal-only surfaces

After a restart, resuming a chat reported the same outcome in up to four
places: a toast, the chat's own note, the status bar entry and the resume
dialog. Success also toasted, and a lost answer or unconfirmed dismissal
raised toasts of their own. The chat's note and the status bar entry (which
opens the dialog listing every affected chat) now carry it alone, and the
resume guidance no longer talks about which process "owns this session".

Provider events Orca does not recognise and that carry no words of their own
are logged instead of journaled as raw "codex · notification:foo" rows. Rows
with the provider's own sentence, failures, and records of replies Orca
already sent stay.

The approval card no longer shows the matched ask rule, its settings source
or a "Blocked path" line; it keeps the request, its detail and the reason.

* fix(native-chat): store wordless provider events, hide them only when drawn

Dropping text-less provider events on the host also dropped Codex's
turn/plan/updated row, which the task list (checklist and composer progress
panel) is built from. The host now journals these rows exactly as before,
and desktop and mobile chats leave out a row whose only text is the host's
"provider · kind" label. Failures, notices, rows with the provider's own
sentence and records of requests Orca answered still show. A hidden legacy
Codex collab-call row still counts as the agent acting for which subagent
section stays open.

Claude's local slash command output is read from `content` for that one
event kind only, so it shows as the row's text instead of disappearing.

* fix(native-chat): rows no client draws never fill the Codex cap or split a turn's work

- Codex per-turn cap: a wordless provider row (decided by the same shared rule the clients use)
  has its own silent storage bound, so it no longer crowds out rows that draw and never produces
  a "N more provider notifications not shown" line about rows nobody sees. Plan updates are
  exempt from the cap, so the task list keeps updating after a noisy turn.
- A Claude local command's row an older host stored with only its label (/usage output in the
  payload) stays drawn instead of disappearing.
- A settled turn whose only extra row draws nothing offers no expand arrow, and a wordless row no
  longer splits one tool run into two.

* fix(native-chat): a resume request that never reached the chats says so once, with Retry

When the resume request fails before the host reserved anything (the host is not ready, or its
recovery record cannot be written), the host has nothing to record, and the chats used to fall
silently back to "N chats to resume". The status bar and dialog now show them as failed, with the
existing guidance and Retry, until Retry, Dismiss, a host answer that no longer offers them, or a
relaunch. Comments that still cited the removed toast are corrected.

* fix(native-chat): keep mobile tool runs whole and a failed Dismiss's chat marked

- Mobile draws no task list, so it now drops wordless provider rows, plan updates included,
  before folding tool runs; a mid-run plan update no longer leaves two run headers back to back.
- A Dismiss the host never took keeps a lost resume request's chat shown as failed instead of
  turning it back into a plain offer. The lost request's real error now goes to the renderer log,
  since the row carries this side's own reason code.
- Comments corrected: the Codex cap no longer claims dropped wordless rows stay countable, and
  the resume comments cover a chat a lost request never reached.

* fix(native-chat): tell the user what their Resume click did, in one toast

Clicking Resume in the dialog (or Retry on a row) closes the dialog and acts on
chats that are mostly off-screen, and nothing said what happened. One toast now
summarises that click: "Resumed N chats and asked them to continue", "N chats
couldn't be resumed" with Show (opens the dialog), "Couldn't confirm N chats were
resumed", or the mixed case in the same toast. A request lost before it reached
the chats counts its chats as not resumed. An opted-in automatic resume at launch
raises no toast; dismissal and other bookkeeping stay silent.

* fix(native-chat): Show on the Resume toast re-reads before opening; fix toast translations

Show could open nothing when the host retired the failure while the toast was up,
because the dialog draws nothing without rows and the request stayed latched. Show now
goes through the status bar entry's re-read-then-open path, moved into the store so
both use it. The French, Japanese, Korean and Chinese "Resumed N chats and asked them
to continue" strings are rewritten to match their sibling strings. Tests pin Show on
the toast of a lost resume request whose chats the re-read still lists.

* test(native-chat): click the Resume toast's Show through a typed helper

The tests reached sonner's action handler with Reflect.apply, which the anti-slop lint
rejects. One test-support helper now returns the last toast's Show as a typed click.

* fix(native-chat): name the path an approval needs when the request doesn't show it

The approval card dropped Claude's blocked path along with the ask rule, so a
request like `git push` that needs access to `.git/config` no longer said what
it touches. Bring the path back as "Needs access to: <path>", only when the
description, reason or request detail doesn't already show it (verbatim or
JSON-escaped). One shared rule serves desktop and mobile; the ask rule stays out.

* fix(native-chat): count a blocked path as shown only when the text names the whole path

'/srv/data' inside '/srv/data-old/x' hid the "Needs access to" line although
the path itself was never shown. A match now needs the next character to end
the path: end of text, whitespace, a quote or a separator. Also pin that the
desktop approval mapping passes the blocked path through and drops the ask rule.

* fix(native-chat): the automatic resume at launch gets the same one outcome toast as a click

An opted-in launch resume now calls the same announce as a clicked Resume, with the same copy:
how many resumed, how many couldn't (with Show), unconfirmed, or mixed, said once. Dismiss and
bookkeeping stay silent; a launch whose chats all moved on raises nothing.

* fix(native-chat): the resume success toast says "Resumed N chats"

Each chat's own note already says Orca asked it to continue, so the toast drops "and asked
it/them to continue" in every locale; the couldn't-resume and couldn't-confirm counts and Show
are unchanged.
2026-10-06 12:59:32 -07:00

512 lines
18 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import {
buildMobileNativeChatTransientData,
foldMobileNativeChatMessages,
mobileNativeChatEmptyState
} from './mobile-native-chat-render-data'
function assistant(id: string, text: string): NativeChatMessage {
return {
id,
role: 'assistant',
blocks: [{ type: 'text', text }],
timestamp: 0,
source: 'transcript'
}
}
function user(id: string, text: string): NativeChatMessage {
return { id, role: 'user', blocks: [{ type: 'text', text }], timestamp: 0, source: 'transcript' }
}
describe('mobileNativeChatEmptyState', () => {
it('invites a first message naming the agent, matching desktop copy', () => {
// waiting-session (live agent, no transcript) and ready (loaded, empty) both
// resolve to the shared "empty" copy with the agent label substituted.
const waiting = mobileNativeChatEmptyState('waiting-session', 'claude')
expect(waiting).toEqual({
title: 'Start a chat with Claude',
subtitle: 'Ask Claude to inspect code, explain output, or make a change.'
})
expect(mobileNativeChatEmptyState('ready', 'codex')?.title).toBe('Start a chat with Codex')
})
it('invites a first message while the transcript file is still unwritten', () => {
// The spinner is already gone by then, so a bare list would read as broken.
expect(mobileNativeChatEmptyState('awaiting-transcript', 'claude')?.title).toBe(
'Start a chat with Claude'
)
})
it('falls back to "the agent" when the agent is unknown', () => {
expect(mobileNativeChatEmptyState('waiting-session', null)?.title).toBe(
'Start a chat with the agent'
)
})
it('prefers the provided error message over the default subtitle', () => {
expect(mobileNativeChatEmptyState('error', 'claude', 'boom')?.subtitle).toBe('boom')
expect(mobileNativeChatEmptyState('error', 'claude')?.subtitle).toBe(
'The transcript could not be read. Toggle back to the terminal to keep working.'
)
})
it('returns null for states that show no empty copy', () => {
expect(mobileNativeChatEmptyState('loading', 'claude')).toBeNull()
expect(mobileNativeChatEmptyState('idle', 'claude')).toBeNull()
})
})
/** Mirrors the view: fold the raw transcript, then assemble the list. */
function build(
messages: NativeChatMessage[],
streaming: string | null,
pending: Parameters<typeof buildMobileNativeChatTransientData>[0]['pending']
): NativeChatMessage[] {
return buildMobileNativeChatTransientData({
messages,
folded: foldMobileNativeChatMessages(messages),
streaming,
pending
}).data
}
describe('buildMobileNativeChatTransientData', () => {
it('appends pending optimistic messages at the tail as user turns', () => {
const data = build([assistant('a1', 'hello')], null, [{ id: 'p1', text: 'queued' }])
const last = data[data.length - 1]
expect(last.id).toBe('p1')
expect(last.role).toBe('user')
expect(last.blocks).toEqual([{ type: 'text', text: 'queued' }])
})
it('renders a pending send with images as text followed by image-ref thumbnails', () => {
const data = build([], null, [
{ id: 'p1', text: 'look', images: ['file:///a.jpg', 'file:///b.jpg'] }
])
const last = data[data.length - 1]
expect(last.role).toBe('user')
expect(last.blocks).toEqual([
{ type: 'text', text: 'look' },
{ type: 'image-ref', url: 'file:///a.jpg' },
{ type: 'image-ref', url: 'file:///b.jpg' }
])
})
it('renders an image-only pending send (no text) as just the thumbnail', () => {
const data = build([], null, [{ id: 'p1', text: '', images: ['file:///a.jpg'] }])
expect(data[data.length - 1].blocks).toEqual([{ type: 'image-ref', url: 'file:///a.jpg' }])
})
it('folds transcript image marker turns into image-ref blocks (desktop parity)', () => {
// Claude records an attached image as `[Image: source: /path]` plus a
// caption turn carrying `[Image #1]`; the fold must merge them into one
// user turn with an image-ref block instead of showing raw marker text.
const data = build(
[
user('u1', '[Image: source: /tmp/a.png]'),
user('u2', '[Image #1] look at this'),
assistant('a1', 'nice photo')
],
null,
[]
)
const merged = data.find((message) => message.role === 'user')
expect(merged?.blocks).toEqual([
{ type: 'image-ref', path: '/tmp/a.png' },
{ type: 'text', text: 'look at this' }
])
})
it('folds a trailing-marker image echo into one user bubble', () => {
const data = build(
[user('u1', '[Image: source: /tmp/a.png]'), user('u2', 'look at this[Image #1]')],
null,
[]
)
expect(data).toHaveLength(1)
expect(data[0]?.blocks).toEqual([
{ type: 'image-ref', path: '/tmp/a.png' },
{ type: 'text', text: 'look at this' }
])
})
it('leaves out an unrecognised provider event with no words, but keeps failures and sentences', () => {
const frameRow = (
id: string,
kind: string,
text: string,
tone?: string
): NativeChatMessage => ({
id,
role: 'system',
timestamp: 0,
source: 'transcript',
blocks: [
{
type: 'text',
text,
...(tone ? { tone } : {}),
providerFrame: {
provider: 'codex',
kind,
payload: { head: '{}', byteLength: 2, digest: 'digest', truncated: false }
}
}
]
})
const folded = foldMobileNativeChatMessages([
frameRow('wordless', 'notification:future/event', 'codex · notification:future/event'),
frameRow(
'failure',
'notification:future/failure',
'codex · notification:future/failure',
'error'
),
frameRow('sentence', 'notification:warning', 'Sandbox is degraded.'),
assistant('a1', 'done')
])
expect(folded.map((message) => message.id)).toEqual(['failure', 'sentence', 'a1'])
})
it('renders a lone image marker turn (no caption) as an image-ref block', () => {
const data = build([user('u1', '[Image: source: /tmp/a.png]')], null, [])
expect(data[0]?.blocks).toEqual([{ type: 'image-ref', path: '/tmp/a.png' }])
})
it('keeps the phone-local image visible when the transcript replaces its optimistic echo', () => {
const folded = foldMobileNativeChatMessages([
user('source', '[Image: source: /tmp/a.png]'),
user('prompt', '[Image #1] look at this')
])
const result = buildMobileNativeChatTransientData({
messages: folded,
folded,
streaming: null,
pending: [],
imagePreviewsByMessageId: { prompt: ['file:///phone-photo.jpg'] }
})
expect(result.data).toHaveLength(1)
expect(result.data[0]?.blocks).toEqual([
{ type: 'image-ref', path: '/tmp/a.png', url: 'file:///phone-photo.jpg' },
{ type: 'text', text: 'look at this' }
])
})
it('restores the local preview onto a marker-only transcript turn', () => {
const result = buildMobileNativeChatTransientData({
messages: [user('prompt', '[Image #1]')],
folded: foldMobileNativeChatMessages([user('prompt', '[Image #1]')]),
streaming: null,
pending: [],
imagePreviewsByMessageId: { prompt: ['file:///phone-photo.jpg'] }
})
expect(result.data[0]?.blocks).toEqual([{ type: 'image-ref', url: 'file:///phone-photo.jpg' }])
})
it('appends a synthetic bubble for gated streaming text, between transcript and pending', () => {
// Whether text streams at all is the gate's call
// (`mobile-native-chat-streaming-gate.test.ts`); this only places it.
const data = build([user('u1', 'hi')], 'thinking out loud', [{ id: 'p1', text: 'queued' }])
expect(data.map((message) => message.id)).toEqual(['u1', 'streaming', 'p1'])
expect(data[1].blocks).toEqual([{ type: 'text', text: 'thinking out loud' }])
})
it('omits the bubble when the gate withheld the streaming text', () => {
const data = build([assistant('a1', 'done answer')], null, [])
expect(data.some((message) => message.id === 'streaming')).toBe(false)
})
})
describe('foldMobileNativeChatMessages', () => {
function toolCall(id: string): NativeChatMessage {
return {
id,
role: 'assistant',
blocks: [{ type: 'tool-call', name: 'Bash', input: { command: 'command -v orca-ide' } }],
timestamp: 0,
source: 'transcript'
}
}
function toolResult(id: string, output: string): NativeChatMessage {
return {
id,
role: 'tool',
blocks: [{ type: 'tool-result', output }],
timestamp: 0,
source: 'transcript'
}
}
// The chat reads a 40-message tail, so the window regularly opens on a result
// whose `tool_use` is older than the window. It used to render as its own
// bubble of raw shell output with no owning call.
it('drops a leading tool result whose call is older than the read window', () => {
const folded = foldMobileNativeChatMessages([
toolResult('orphan', 'Exit code 1\norca-ide not found'),
assistant('a1', 'Falling back to the installed binary.')
])
expect(folded.map((message) => message.id)).toEqual(['a1'])
})
// Mobile draws no task list, so a hidden plan update must not split the run around it.
it('keeps one tool run across a Codex plan update it does not draw', () => {
const plan: NativeChatMessage = {
id: 'plan',
role: 'system',
timestamp: 0,
source: 'transcript',
blocks: [
{
type: 'text',
text: 'codex · notification:turn/plan/updated',
providerFrame: {
provider: 'codex',
kind: 'notification:turn/plan/updated',
payload: { head: '{}', byteLength: 2, digest: 'digest', truncated: false }
}
}
]
}
const folded = foldMobileNativeChatMessages([
assistant('a1', 'Working.'),
toolCall('c1'),
toolResult('r1', 'ok'),
plan,
toolCall('c2'),
toolResult('r2', 'ok')
])
expect(folded.map((message) => message.id)).toEqual(['a1'])
expect(folded[0]?.blocks.filter((block) => block.type === 'tool-call')).toHaveLength(2)
})
it('still folds a result whose call is inside the window', () => {
const folded = foldMobileNativeChatMessages([
assistant('a1', 'Checking which binary is on PATH.'),
toolCall('c1'),
toolResult('r1', 'orca-ide not found')
])
expect(folded).toHaveLength(1)
expect(folded[0]?.blocks.map((block) => block.type)).toEqual([
'text',
'tool-call',
'tool-result'
])
})
it('keeps a mixed Claude tool result with a harness sidecar paired', () => {
const folded = foldMobileNativeChatMessages([
toolCall('c1'),
{
id: 'mixed',
role: 'user',
blocks: [
{ type: 'tool-result', output: 'important output' },
{ type: 'text', text: '<system-reminder>continue</system-reminder>' }
],
timestamp: 0,
source: 'transcript'
}
])
expect(folded[0]?.blocks).toEqual([
{ type: 'tool-call', name: 'Bash', input: { command: 'command -v orca-ide' } },
{ type: 'tool-result', output: 'important output' }
])
})
})
// Claude consumes a mid-turn send through a `queued_command` attachment and writes
// no `type:"user"` record for it, so that echo has no row to match - ever. What
// made the conversation read as scrambled was not the unmatched echo itself but
// where it rendered: appended after every turn, so it re-read below each new one.
describe('buildMobileNativeChatTransientData anchoring', () => {
function row(id: string, role: 'user' | 'assistant', text: string): NativeChatMessage {
return { id, role, blocks: [{ type: 'text', text }], timestamp: 1, source: 'transcript' }
}
it('keeps an unmatched echo where it was sent instead of below later turns', () => {
const folded = [row('m1', 'user', 'earlier'), row('m2', 'assistant', 'on it')]
const { data } = buildMobileNativeChatTransientData({
messages: folded,
folded,
streaming: null,
pending: [{ id: 'p1', text: 'a mid-turn send', baselineTailMessageId: 'm2' }]
})
expect(data.map((m) => m.id)).toEqual(['m1', 'm2', 'p1'])
// The agent keeps working. The echo must NOT drift below the new turns.
const later = [
...folded,
row('m3', 'assistant', 'still working'),
row('m4', 'assistant', 'done')
]
const { data: after } = buildMobileNativeChatTransientData({
messages: later,
folded: later,
streaming: null,
pending: [{ id: 'p1', text: 'a mid-turn send', baselineTailMessageId: 'm2' }]
})
expect(after.map((m) => m.id)).toEqual(['m1', 'm2', 'p1', 'm3', 'm4'])
})
it('reproduces the reported replay: stale echoes stay in place, not stacked at the tail', () => {
const folded = [
row('m1', 'user', 'first question'),
row('m2', 'assistant', 'answering'),
row('m3', 'assistant', 'newest turn')
]
const { data } = buildMobileNativeChatTransientData({
messages: folded,
folded,
streaming: null,
pending: [
{ id: 'p1', text: 'sent against m1', baselineTailMessageId: 'm1' },
{ id: 'p2', text: 'sent against m2', baselineTailMessageId: 'm2' }
]
})
expect(data.map((m) => m.id)).toEqual(['m1', 'p1', 'm2', 'p2', 'm3'])
})
it('keeps send order among echoes sharing one anchor', () => {
const { data } = buildMobileNativeChatTransientData({
messages: [row('m1', 'assistant', 'ready')],
folded: [row('m1', 'assistant', 'ready')],
streaming: null,
pending: [
{ id: 'p1', text: 'first', baselineTailMessageId: 'm1' },
{ id: 'p2', text: 'second', baselineTailMessageId: 'm1' }
]
})
expect(data.map((m) => m.id)).toEqual(['m1', 'p1', 'p2'])
})
it('falls back to the tail when the send captured no baseline', () => {
const { data } = buildMobileNativeChatTransientData({
messages: [row('m1', 'assistant', 'ready')],
folded: [row('m1', 'assistant', 'ready')],
streaming: null,
pending: [{ id: 'p1', text: 'no baseline yet', baselineTailMessageId: null }]
})
expect(data.map((m) => m.id)).toEqual(['m1', 'p1'])
})
it('falls back to the tail when the captured row left the raw window', () => {
const { data } = buildMobileNativeChatTransientData({
messages: [row('m1', 'assistant', 'ready')],
folded: [row('m1', 'assistant', 'ready')],
streaming: null,
pending: [{ id: 'p1', text: 'anchored to a folded-away row', baselineTailMessageId: 'gone' }]
})
expect(data.map((m) => m.id)).toEqual(['m1', 'p1'])
})
it('keeps an echo before later turns when its folded baseline leads the raw window', () => {
const messages = [
row('noise', 'user', '<system-reminder>hidden boundary'),
row('a1', 'assistant', 'arrived later')
]
const folded = foldMobileNativeChatMessages(messages)
expect(folded.map((message) => message.id)).toEqual(['a1'])
const { data } = buildMobileNativeChatTransientData({
messages,
folded,
streaming: null,
pending: [
{ id: 'p1', text: 'sent after the hidden boundary', baselineTailMessageId: 'noise' }
]
})
expect(data.map((message) => message.id)).toEqual(['p1', 'a1'])
})
it('still puts the streaming bubble after the transcript', () => {
const { data } = buildMobileNativeChatTransientData({
messages: [row('m1', 'user', 'hi')],
folded: [row('m1', 'user', 'hi')],
streaming: 'thinking',
pending: [{ id: 'p1', text: 'echo', baselineTailMessageId: 'm1' }]
})
expect(data.map((m) => m.id)).toEqual(['m1', 'p1', 'streaming'])
})
it('anchors a send captured against a tool row to its folded assistant', () => {
const messages: NativeChatMessage[] = [
row('a1', 'assistant', 'working'),
{
id: 'tool',
role: 'assistant',
blocks: [{ type: 'tool-call', name: 'Bash', input: { command: 'pnpm test' } }],
timestamp: 2,
source: 'transcript'
},
row('a2', 'assistant', 'done')
]
const folded = foldMobileNativeChatMessages(messages)
expect(folded.map((message) => message.id)).toEqual(['a1', 'a2'])
const { data } = buildMobileNativeChatTransientData({
messages,
folded,
streaming: null,
pending: [{ id: 'p1', text: 'sent during the tool', baselineTailMessageId: 'tool' }]
})
expect(data.map((message) => message.id)).toEqual(['a1', 'p1', 'a2'])
})
it('anchors after the prompt that absorbed an earlier image-source row', () => {
const messages = [
row('a1', 'assistant', 'ready'),
user('source', '[Image: source: /tmp/earlier.png]'),
user('prompt', '[Image #1] earlier image'),
row('a2', 'assistant', 'done')
]
const folded = foldMobileNativeChatMessages(messages)
expect(folded.map((message) => message.id)).toEqual(['a1', 'prompt', 'a2'])
const { data } = buildMobileNativeChatTransientData({
messages,
folded,
streaming: null,
pending: [{ id: 'p1', text: 'sent after the image source', baselineTailMessageId: 'source' }]
})
expect(data.map((message) => message.id)).toEqual(['a1', 'prompt', 'p1', 'a2'])
})
})
describe("mobile shows the conversation, never a subagent's rows", () => {
it("keeps the spawn's one line and drops what the subagent said and did", () => {
const child = { agentId: 'task-1', producerKind: 'agent' as const }
const folded = foldMobileNativeChatMessages([
user('ask', 'review the PR'),
{
id: 'spawn',
role: 'system',
blocks: [{ type: 'text', text: 'Kicked off 1 subagent' }],
timestamp: 0,
source: 'transcript'
},
{ ...assistant('child-said', 'The PR is CLEAN.'), ...child },
{
id: 'child-grep',
role: 'assistant',
blocks: [{ type: 'tool-call', name: 'Grep', input: {} }],
timestamp: 0,
source: 'transcript',
...child
},
assistant('answer', 'Delegated; nothing to fix.')
])
expect(folded.map((message) => message.id)).toEqual(['ask', 'spawn', 'answer'])
expect(folded[2]?.blocks).toEqual([{ type: 'text', text: 'Delegated; nothing to fix.' }])
})
})