Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,8 @@ describe('what a structured refusal says on the phone', () => {

expect(result).toEqual({
status: 'failed',
message: "The Orca running this chat doesn't support this. Update Orca, then try again."
message:
'This needs a newer Orca on the computer running this chat. Update Orca there, then try again.'
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -146,10 +146,12 @@ describe('a malformed row', () => {
.snapshot()
.items.map((entry) => entry.body)
.find((entry) => entry.kind === 'status')
expect(disclosure).toMatchObject({ kind: 'status' })
expect(disclosure && 'text' in disclosure ? disclosure.text : '').toContain(
'1 journal line could not be read'
)
// Named for the client to word, with English for a client that can't.
expect(disclosure).toEqual({
kind: 'status',
text: "Part of this chat's history couldn't be loaded.",
presentation: 'history-repaired'
})
})
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,12 @@
// nothing but its anchor and this row is a repair that has not been
// reconstructed yet, not a timeline.

import { agentSessionHostStatusBody } from '../../../shared/agent-session-host-status-rows'
import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key'
import type { AgentJournalItemIdentity } from '../../../shared/agent-session-journal-types'
import type {
AgentJournalItemIdentity,
AgentJournalPlainStatusItem
} from '../../../shared/agent-session-journal-types'

/** One stable identity, so a reopen upserts the same row instead of adding one. */
export const JOURNAL_REPAIR_DISCLOSURE_IDENTITY: AgentJournalItemIdentity = {
Expand All @@ -19,14 +23,13 @@ export const JOURNAL_REPAIR_DISCLOSURE_ITEM_ID = agentJournalItemKey(

export type JournalRepairDisclosure = {
identity: AgentJournalItemIdentity
body: { kind: 'status'; text: string }
body: AgentJournalPlainStatusItem
}

/** Disclosed when a repair skipped a row it could not read. */
export function journalRepairDisclosure(input: { malformedRows: number }): JournalRepairDisclosure {
const lines = `${input.malformedRows} journal line${input.malformedRows === 1 ? '' : 's'}`
export function journalRepairDisclosure(): JournalRepairDisclosure {
return {
identity: JOURNAL_REPAIR_DISCLOSURE_IDENTITY,
body: { kind: 'status', text: `${lines} could not be read` }
body: agentSessionHostStatusBody('history-repaired')
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ export async function openJournalStoreState(input: {
input.setMalformedRows(loaded.malformedRows)
}
if (input.malformedRows() > 0 && !input.readOnly()) {
const disclosure = journalRepairDisclosure({ malformedRows: input.malformedRows() })
const disclosure = journalRepairDisclosure()
await input.appendItem(disclosure.identity, disclosure.body, input.highestFence())
}
await settleStaleSubagentRosters(input, loaded)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type {
} from '../../../shared/agent-session-journal-types'
import type Database from '../../sqlite/sync-database'
import { JOURNAL_DB_SCHEMA_VERSION } from './journal-database-schema'
import { journalRepairDisclosure } from './journal-repair-disclosure'
import { journalDatabasePath } from './journal-host-database'
import type { AgentSessionJournal } from './journal-store'
import {
Expand Down Expand Up @@ -109,6 +110,7 @@ afterEach(async () => {
await rm(root, { recursive: true, force: true })
})

const REPAIR_TEXT = journalRepairDisclosure().body.text
describe('axis 1: the database shape', () => {
// A newer build's database opens read-only: its chats read, and nothing here writes to it.
it('reads a database a newer build stamped, refuses every write, and writes nothing', async () => {
Expand Down Expand Up @@ -242,9 +244,7 @@ describe('axis 2: the row body shape', () => {
expect(items.some((entry) => entry.body.kind === 'message')).toBe(true)
// …and the skip is visible in the timeline instead of silently swallowed.
expect(
items.some(
(entry) => entry.body.kind === 'status' && entry.body.text.includes('could not be read')
)
items.some((entry) => entry.body.kind === 'status' && entry.body.text === REPAIR_TEXT)
).toBe(true)
})

Expand All @@ -264,9 +264,7 @@ describe('axis 2: the row body shape', () => {
expect(
reopened
.snapshot()
.items.filter(
(entry) => entry.body.kind === 'status' && entry.body.text.includes('could not be read')
)
.items.filter((entry) => entry.body.kind === 'status' && entry.body.text === REPAIR_TEXT)
).toHaveLength(1)
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
AgentJournalSubmission
} from '../../../shared/agent-session-journal-types'
import { REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES } from '../../../shared/remote-runtime-memory-limits'
import { agentSessionHostStatusBody } from '../../../shared/agent-session-host-status-rows'

export const AGENT_SESSION_HISTORY_MAX_PAGE_BYTES = REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES / 2

Expand Down Expand Up @@ -46,17 +47,11 @@ export function submissionBytesByItemId(
return bytes
}

export function oversizedHistoryItem(
item: AgentJournalRenderItem,
byteLength: number
): AgentJournalRenderItem {
export function oversizedHistoryItem(item: AgentJournalRenderItem): AgentJournalRenderItem {
return {
...item,
itemId: boundJournalKeyComponent(item.itemId),
body: {
kind: 'status',
text: `[Orca: item truncated — ${byteLength} bytes exceeds the history page budget]`
}
body: agentSessionHostStatusBody('history-item-too-large')
}
}

Expand All @@ -72,7 +67,7 @@ export function boundHistoryItemsByBytes(
for (const group of ordered) {
const bytes = group.reduce((sum, item) => sum + historyEntryBytes(item, submissionBytes), 0)
if (kept.length === 0 && bytes > maxBytes) {
kept.push(group.map((item) => oversizedHistoryItem(item, bytes)))
kept.push(group.map((item) => oversizedHistoryItem(item)))
break
}
if (total + bytes > maxBytes) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ function referenceBoundHistoryItemsByBytes(
for (const group of ordered) {
const bytes = group.reduce((sum, item) => sum + historyEntryBytes(item, submissionBytes), 0)
if (kept.length === 0 && bytes > maxBytes) {
kept.push(group.map((item) => oversizedHistoryItem(item, bytes)))
kept.push(group.map((item) => oversizedHistoryItem(item)))
break
}
if (total + bytes > maxBytes) {
Expand Down Expand Up @@ -153,7 +153,9 @@ it('matches eager byte bounding at every budget boundary in both directions', ()
).toEqual(expected)
if (
actual.items.some(
(entry) => entry.body.kind === 'status' && /truncated/.test(entry.body.text)
(entry) =>
entry.body.kind === 'status' &&
entry.body.presentation === 'history-item-too-large'
)
) {
truncatedCases += 1
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -319,9 +319,12 @@ describe('history page byte ceiling', () => {
readAgentSessionHistory(journal, { sessionId: 'session-1', direction: 'tail', limit: 40 })
)
expect(tail.items).toHaveLength(1)
const bodyOnPage = tail.items[0]?.body
expect(bodyOnPage?.kind).toBe('status')
expect(bodyOnPage?.kind === 'status' ? bodyOnPage.text : '').toContain('[Orca: item truncated')
// Named for the client to word, with English for a client that can't.
expect(tail.items[0]?.body).toEqual({
kind: 'status',
text: 'This part of the chat was too large to show.',
presentation: 'history-item-too-large'
})
})
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@ function readForward(
if (contentBytes > HISTORY_PAGE_CONTENT_BUDGET_BYTES) {
items = items.map((item) => {
const bytes = historyEntryBytes(item, submissionBytes)
return bytes > HISTORY_PAGE_CONTENT_BUDGET_BYTES ? oversizedHistoryItem(item, bytes) : item
return bytes > HISTORY_PAGE_CONTENT_BUDGET_BYTES ? oversizedHistoryItem(item) : item
})
contentBytes = pageContentBytes(items, projected.batch.removedItemIds)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,7 @@ it('holds the message with its reason while the exit stays unverifiable, and sen
{
kind: 'status',
tone: 'warning',
text: "Orca couldn't confirm Claude's previous process ended. Messages wait to be sent until Orca confirms it has ended.",
text: 'Claude from before may still be running. Your messages will send once it stops.',
failure: { kind: 'previousExitUnverifiable' }
}
])
Expand Down
7 changes: 5 additions & 2 deletions src/renderer/src/components/agent-child-row-text.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import type { AgentChildRowModel } from '../../../shared/agent-child-row-model'
import { formatAgentTypeLabel } from '../../../shared/agent-type-label'
import { agentStateLabel } from '@/components/AgentStateDot'
import { backgroundTaskStateReason } from '@/components/native-chat/background-task-roster'
import {
backgroundTaskStateReason,
backgroundTaskStateWord
} from '@/components/native-chat/background-task-roster'
import { translate } from '@/i18n/i18n'
import { agentNoUpdateLabel } from '@/lib/agent-row-decay-state'
import { formatAgentToolPreview } from '@/lib/agent-row-tool-preview'
Expand Down Expand Up @@ -41,7 +44,7 @@ export function agentChildRowDetailText(row: AgentChildRowModel, now: number): s
case 'role':
return formatAgentTypeLabel(detail.agentType)
case 'reason':
return backgroundTaskStateReason(detail.state) ?? ''
return backgroundTaskStateReason(detail.state) ?? backgroundTaskStateWord(detail.state)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,12 +106,32 @@ describe('NativeChatPickerMenu', () => {
/>
)

expect(screen.getAllByText('Could not load skills from this host')).toHaveLength(2)
expect(screen.getAllByText("Couldn't load skills")).toHaveLength(2)
expect(screen.queryByText('Loading skills...')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
expect(onRetry).toHaveBeenCalledOnce()
})

it('announces the same unavailable sentence it shows, with no Retry', () => {
render(
<NativeChatPickerMenu
autocomplete={autocomplete({
items: [],
skillStatus: 'error',
skillErrorKind: 'unavailable'
})}
activeIndex={0}
listboxId="picker"
onChoose={vi.fn()}
onRetry={vi.fn()}
/>
)

expect(screen.getAllByText("Skills aren't available in SSH chats")).toHaveLength(2)
expect(screen.queryByText("Couldn't load skills")).toBeNull()
expect(screen.queryByRole('button', { name: 'Retry' })).toBeNull()
})

it('uses command-only empty copy for a picker without skill support', () => {
render(
<NativeChatPickerMenu
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@ export const NativeChatPickerMenu = memo(function NativeChatPickerMenu({
const emptyText = noMatches ? getPickerEmptyText(autocomplete) : null
const collision = commands.find((item) => item.skillCollision)
const duplicate = skills.find((item) => item.sources.length > 1)
// One sentence for the line and the screen reader, so the two never disagree.
const skillErrorText =
autocomplete.skillErrorKind === 'unavailable'
? translate(
'components.native-chat.composer.skillsUnavailableHost',
"Skills aren't available in SSH chats"
)
: translate('components.native-chat.composer.skillsLoadFailed', "Couldn't load skills")

let optionIndex = 0
return (
Expand Down Expand Up @@ -71,17 +79,7 @@ export const NativeChatPickerMenu = memo(function NativeChatPickerMenu({
) : null}
{autocomplete.skillStatus === 'error' ? (
<PickerStatus>
<span className="min-w-0 flex-1">
{autocomplete.skillErrorKind === 'unavailable'
? translate(
'components.native-chat.composer.skillsUnavailableHost',
'Skills are unavailable for this host'
)
: translate(
'components.native-chat.composer.skillsLoadFailed',
'Could not load skills from this host'
)}
</span>
<span className="min-w-0 flex-1">{skillErrorText}</span>
{autocomplete.skillErrorKind !== 'unavailable' ? (
<button
type="button"
Expand Down Expand Up @@ -114,10 +112,7 @@ export const NativeChatPickerMenu = memo(function NativeChatPickerMenu({
{autocomplete.skillStatus === 'loading'
? translate('components.native-chat.composer.loadingSkills', 'Loading skills...')
: autocomplete.skillStatus === 'error'
? translate(
'components.native-chat.composer.skillsLoadFailed',
'Could not load skills from this host'
)
? skillErrorText
: emptyText
? emptyText
: autocomplete.skillsEnabled
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,10 @@ describe('NativeChatBackgroundTaskRun', () => {
expect(screen.getByText('Background workflow')).toBeInTheDocument()
})

it('reads a state this build has no word for as no contact, never as live', () => {
it('reads a state this build has no word for as no recent update, never as live', () => {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: models a row a newer build wrote, which the wire admits as an open string.
render(<NativeChatBackgroundTaskRun block={task({ state: 'teleported' as 'done' })} />)
expect(screen.getByText(/unverifiable/)).toBeInTheDocument()
expect(screen.getByText(/no recent update/)).toBeInTheDocument()
})
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export function NativeChatBackgroundTaskRun({
// not read as two different things on the two surfaces.
const label = resolveBackgroundTaskName({ id: block.taskId, kind, description: block.label })
// Every attention state states its reason on the row, the same word the strip
// uses; `unverifiable` ("no contact") must never be silently dropped.
// uses; `unverifiable` has none beyond its state word, which must never be dropped.
const reason = backgroundTaskStateReason(state)
// The sentence the provider itself wrote. It is the row's whole reason for
// existing when a task fails, and it is dropped from the prose above as the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,7 @@ describe('background-task row reasons', () => {
return screen.getAllByRole('listitem')
}

// `unverifiable` is the SSH verdict for "no contact"; a row that hides it reads
// `unverifiable` is the SSH verdict for lost contact; a row that hides it reads
// like a working child. `blocked` is the same class of loss.
it('names the reason on every attention state, not only on waiting', () => {
const rows = expandedRows([
Expand All @@ -291,7 +291,7 @@ describe('background-task row reasons', () => {
{ id: 'a4', kind: 'agent', description: 'busy child', state: 'working' }
])
expect(rows).toHaveLength(4)
expect(rows[0].textContent).toContain('ssh child · no contact')
expect(rows[0].textContent).toContain('ssh child · no recent update')
expect(rows[1].textContent).toContain('flaky child · failed')
expect(rows[2].textContent).toContain('approval child · needs approval')
// A running row has nothing to explain.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,11 @@ it('keeps pointing the terminal-backed chat back to its terminal when a read fai
).toBeInTheDocument()
})

it('tells the structured chat its read keeps retrying', () => {
render(<NativeChatEmptyState kind="error" retrying />)
expect(
screen.getByText('The transcript could not be read. Orca keeps trying to load it.')
).toBeInTheDocument()
// It retries on its own, which the pane does not report, and has no terminal to point back to.
it('says only that the structured chat did not load while its read retries', () => {
const { container } = render(<NativeChatEmptyState kind="error" retrying />)
expect(screen.getByText('Could not load conversation')).toBeInTheDocument()
expect(container.querySelectorAll('p')).toHaveLength(1)
})

it('shows the host message in place of the terminal-backed default', () => {
Expand All @@ -31,8 +31,8 @@ it('shows the host message in place of the terminal-backed default', () => {
})

// The structured chat passes words only from the notice table, never the host's.
it("says the structured chat's own words for the failure once, as the title, above its retrying line", () => {
render(
it("says the structured chat's own words for the failure once, as the title, and nothing more", () => {
const { container } = render(
<NativeChatEmptyState
kind="error"
retrying
Expand All @@ -43,9 +43,7 @@ it("says the structured chat's own words for the failure once, as the title, abo
'font-medium'
)
expect(screen.queryByText('Could not load conversation')).toBeNull()
expect(
screen.getByText('The transcript could not be read. Orca keeps trying to load it.')
).toBeInTheDocument()
expect(container.querySelectorAll('p')).toHaveLength(1)
})

it('says a failure no retry gets past in its one line, with nothing of trying again', () => {
Expand Down
Loading
Loading