Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
400935a
fix(native-chat): a chat a crash, quit or restart cut short reads Int…
brennanb2025 Oct 5, 2026
cd82add
fix(native-chat): say a cut response was interrupted, muted, without …
brennanb2025 Oct 5, 2026
43cc46b
test(native-chat): the agent's own exit row stays the one red row a r…
brennanb2025 Oct 5, 2026
cdcea9e
Merge remote-tracking branch 'origin/main' into brennanb2025/crash-cu…
brennanb2025 Oct 5, 2026
e6c2097
fix(native-chat): a turn the agent's own exit cut short reads failed,…
brennanb2025 Oct 5, 2026
edb7327
test(native-chat): an exit row followed by a reopen row for the same …
brennanb2025 Oct 5, 2026
0b556d2
fix(native-chat): a chat's settled status stays on the card and tab u…
brennanb2025 Oct 5, 2026
7a42ad4
fix(native-chat): a collapsed turn that was cut short still shows why…
brennanb2025 Oct 5, 2026
66b9318
fix(native-chat): re-word only an older host's red reopen row
brennanb2025 Oct 5, 2026
73611f1
revert(notifications): keep the cut's notification wording as on main
brennanb2025 Oct 5, 2026
06981f0
test(native-chat): type the explanation test's row scopes
brennanb2025 Oct 5, 2026
8fa691b
style: format round-1 tests
brennanb2025 Oct 5, 2026
13aad78
fix(native-chat): a re-worded reopen or quit row keeps what its host …
brennanb2025 Oct 5, 2026
5a53171
fix(native-chat): a re-worded row drops only the failure fact its old…
brennanb2025 Oct 5, 2026
8d478f2
fix(native-chat): an old chat verdict yields to live work and a fresh…
brennanb2025 Oct 5, 2026
452f21c
fix(native-chat): a row naming why Orca stopped reads as the muted in…
brennanb2025 Oct 5, 2026
dee7764
fix(native-chat): the reopen row is stored red so older clients still…
brennanb2025 Oct 5, 2026
23bcd5c
test(native-chat): the older client's fold hides a muted reopen row, …
brennanb2025 Oct 5, 2026
6a1cfdc
Merge remote-tracking branch 'origin/main' into brennanb2025/crash-cu…
brennanb2025 Oct 5, 2026
1d72d47
fix(native-chat): a chat whose start failed keeps its Failed as any n…
brennanb2025 Oct 5, 2026
145ed2b
fix(native-chat): a departed agent's done does not take the card from…
brennanb2025 Oct 5, 2026
c4c8446
Merge origin/main up to #25706 into brennanb2025/crash-cut-reads-inte…
brennanb2025 Oct 5, 2026
ef3313b
Merge remote-tracking branch 'origin/main' into brennanb2025/crash-cu…
brennanb2025 Oct 6, 2026
4bf665a
Merge remote-tracking branch 'origin/main' into brennanb2025/crash-cu…
brennanb2025 Oct 6, 2026
7fec698
test(native-chat): drop the font-scale prop main removed from the mes…
brennanb2025 Oct 6, 2026
c1962a0
Merge remote-tracking branch 'origin/main' into brennanb2025/crash-cu…
brennanb2025 Oct 6, 2026
e7820df
fix(test): drop the duplicate resolveLaunchArgs key so main's typeche…
brennanb2025 Oct 6, 2026
ee43985
Merge remote-tracking branch 'origin/main' into brennanb2025/crash-cu…
brennanb2025 Oct 7, 2026
b918ebb
fix(native-chat): rank a chat's settled mark on the worktree card the…
brennanb2025 Oct 7, 2026
abe7e59
Merge remote-tracking branch 'origin/main' into brennanb2025/crash-cu…
brennanb2025 Oct 7, 2026
7eb335f
test(acp): read Grok's cut-turn transcript with the agent-neutral notice
brennanb2025 Oct 7, 2026
a56b6f1
Merge remote-tracking branch 'origin/main' into brennanb2025/crash-cu…
brennanb2025 Oct 7, 2026
09f9ea0
Merge origin/main into brennanb2025/crash-cut-reads-interrupted
brennanb2025 Oct 7, 2026
cdcc646
test(native-chat): a quit's stored cut row is red; the reader mutes it
brennanb2025 Oct 8, 2026
7fa999c
Merge origin/main into brennanb2025/crash-cut-reads-interrupted
brennanb2025 Oct 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions mobile/src/session/MobileNativeChatTurnStatus.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,15 +75,15 @@ describe('MobileNativeChatTurnStatus', () => {
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', () => {
// A crash cut reads interrupted, as a Stop does; the crash's notice row explains why.
it('heads a turn a crash cut off and a turn the user stopped alike as interrupted', () => {
const crashed = render({
startedAt: Date.now(),
workedSeconds: 12,
verdict: 'interruption',
onToggleExpanded: vi.fn()
})
expect(labels(crashed.root)).toEqual(['Worked for 12s'])
expect(labels(crashed.root)).toEqual(['Interrupted after 12s'])
act(() => crashed.unmount())
const stopped = render({
startedAt: Date.now(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ import {
type StructuredAgentSessionState
} from '../../../src/shared/structured-agent-session-reducer'
import { selectStructuredAgentTurnBars } from '../../../src/shared/structured-agent-session-turn-timing'
import { TUI_AGENT_DISPLAY_NAMES } from '../../../src/shared/tui-agent-display-names'
import {
buildMobileNativeChatTransientData,
foldMobileNativeChatMessages
Expand Down Expand Up @@ -55,9 +54,7 @@ function apply(
/** The rows the phone's transcript reads, as its session hook builds them: the journal plus the
* notice a cut turn gets, projected with no other rejected send drawn in place. */
function phoneTranscript(state: StructuredAgentSessionState) {
const items = withNativeChatCutTurnNotices(state.items, {
agentName: TUI_AGENT_DISPLAY_NAMES.codex
})
const items = withNativeChatCutTurnNotices(state.items)
return {
items,
messages: projectStructuredAgentSessionMessages(items, [], state.submissions, {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { agentJournalItemKey } from '../../../src/shared/agent-session-journal-item-key'
import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types'
import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-session-wire'
import type { RpcClient } from '../transport/rpc-client'
Expand Down Expand Up @@ -78,7 +79,8 @@ describe('useMobileStructuredAgentSession transcript', () => {
renderer = null
})

it('shows a cut turn no row explains with the one notice desktop shows, in that turn', async () => {
/** The phone's session hook over a journal snapshot, as the transcript reads it. */
async function mountWith(items: AgentJournalRenderItem[]) {
let listener: ((value: unknown) => void) | null = null
const client: Pick<RpcClient, 'sendRequest' | 'subscribe'> = {
sendRequest: vi.fn(async (): Promise<RpcResponse> => ({
Expand Down Expand Up @@ -112,21 +114,71 @@ describe('useMobileStructuredAgentSession transcript', () => {
renderer = create(createElement(Harness))
})
await vi.waitFor(() => expect(listener).toEqual(expect.any(Function)))
act(() => listener?.(snapshot(items)))
return rendered
}

act(() => listener?.(snapshot(ITEMS)))
it('shows a cut turn no row explains with the one notice desktop shows, in that turn', async () => {
const rendered = await mountWith(ITEMS)

await vi.waitFor(() =>
expect(rendered.hook?.session.messages.at(-1)).toMatchObject({
role: 'system',
blocks: [
{
text: 'Codex stopped while this response was in progress. You can continue in this conversation.',
tone: 'error'
text: 'This response was interrupted. You can continue in this conversation.',
presentation: 'response-interrupted',
tone: 'notice'
}
]
})
)
// The turn bars place rows by the same items, so the notice joins the cut turn.
expect(rendered.hook?.turnJournal.items.at(-1)).toMatchObject({ turnScope: SCOPE })
})

// The phone draws the text of a row whose presentation it does not word, and ignores tone, so the
// shared reader must hand it the interrupted words for an older host's red row and for a row
// naming why Orca stopped.
it.each([
["an older host's red reopen row", undefined],
['a row naming why Orca stopped', 'orca-stop'],
['the reopen row a newer host stores red', 'response-interrupted']
] as const)('shows %s in the interrupted words', async (_label, presentation) => {
const stopped =
'Codex stopped while this response was in progress. You can continue in this conversation.'
const rendered = await mountWith([
...ITEMS,
{
itemId: agentJournalItemKey({
provider: 'orca',
clientMessageId: 'stale-session:session-1:death-3-2000'
}),
revision: 0,
sequence: 4,
observedAt: 4,
turnScope: SCOPE,
body: {
kind: 'status',
text: stopped,
tone: 'error',
...(presentation ? { presentation } : {})
}
}
])

await vi.waitFor(() =>
expect(rendered.hook?.session.messages.at(-1)).toMatchObject({
role: 'system',
blocks: [
{
text: 'This response was interrupted. You can continue in this conversation.',
presentation: presentation ?? 'response-interrupted',
tone: 'notice'
}
]
})
)
expect(JSON.stringify(rendered.hook?.session.messages)).not.toContain(stopped)
})
})
9 changes: 1 addition & 8 deletions mobile/src/session/use-mobile-structured-agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { encodeNativeChatTranscriptIdentity } from '../../../src/shared/native-c
import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send'
import { projectStructuredAgentSessionMessages } from '../../../src/shared/structured-agent-session-message-projection'
import { withNativeChatCutTurnNotices } from '../../../src/shared/native-chat-cut-turn-notice'
import { TUI_AGENT_DISPLAY_NAMES } from '../../../src/shared/tui-agent-display-names'
import { isStructuredAgentSessionMainAgentWorking } from '../../../src/shared/structured-agent-session-main-agent-working'
import { isFinalAgentSessionReadRefusal } from '../../../src/shared/structured-agent-session-read-refusal'
import {
Expand Down Expand Up @@ -175,13 +174,7 @@ export function useMobileStructuredAgentSession(args: {

// What the transcript reads, as desktop does: the journal plus the one notice a cut turn with no
// row gets.
const transcriptItems = useMemo(
() =>
withNativeChatCutTurnNotices(state.items, {
agentName: TUI_AGENT_DISPLAY_NAMES[agent === 'codex' ? 'codex' : 'claude']
}),
[agent, state.items]
)
const transcriptItems = useMemo(() => withNativeChatCutTurnNotices(state.items), [state.items])
const messages = useMemo(
// Off: the phone hands a rejected message back to its composer, so a row would show it twice.
() =>
Expand Down
14 changes: 8 additions & 6 deletions mobile/src/worktree/agent-row-display.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,12 +63,13 @@ describe('agentDotState', () => {
)
})

it('reads a crash-cut turn as failed and an unproven end as unconfirmed', () => {
// A crash, quit or restart cut it short: the phone reads it interrupted, as desktop does.
it('reads a crash-cut turn as interrupted and an unproven end as unconfirmed', () => {
expect(agentDotState(row({ state: 'done', ...mainAgentDone('interruption') }), 0)).toBe(
'failed'
'interrupted'
)
expect(agentDisplayLabel(row({ state: 'done', ...mainAgentDone('interruption') }), 0)).toBe(
'Failed'
'Interrupted'
)
expect(agentDotState(row({ state: 'done', ...mainAgentDone('unconfirmed') }), 0)).toBe(
'unconfirmed'
Expand All @@ -88,10 +89,11 @@ describe('agentDotState', () => {
it('shows a main agent that failed while its subagents still run as failed', () => {
expect(agentDotState(row({ state: 'working', ...mainAgentDone('failure') }), 0)).toBe('failed')
expect(agentDotState(row({ state: 'waiting', ...mainAgentDone('failure') }), 0)).toBe('failed')
// Only a failure outranks live work; a success, a stop or a crash cut with live subagents
// reads working.
expect(agentDotState(row({ state: 'working', ...mainAgentDone('interruption') }), 0)).toBe(
'failed'
'working'
)
// Only a fault outranks live work; a success or a stop with live subagents reads working.
expect(agentDotState(row({ state: 'working', ...mainAgentDone('success') }), 0)).toBe('working')
expect(
agentDotState(
Expand Down Expand Up @@ -143,7 +145,7 @@ describe('agentDotState', () => {
expect(agentDotState(row({ state: 'done', updatedAt: 0 }), stale)).toBe('done')
expect(
agentDotState(row({ state: 'done', updatedAt: 0, ...mainAgentDone('interruption') }), stale)
).toBe('failed')
).toBe('interrupted')
})
})

Expand Down
7 changes: 3 additions & 4 deletions mobile/src/worktree/agent-row-display.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,18 +49,17 @@ export function agentRowVerdict(row: AgentRowVerdictSource): AgentTurnOutcome |
return row.state === 'done' && row.interrupted ? 'cancellation' : null
}

// A fault (a failure, or a turn cut short by something other than the user) reads failed and
// outranks every state; a user's Stop, a turn a newer request replaced, and an unproven end mark
// only a row that is itself done.
// A failure reads failed and outranks every state; a turn that ended early (a user's Stop, a newer
// request, a crash, quit or restart) and an unproven end mark only a row that is itself done.
export function agentRowVerdictMark(
row: AgentRowVerdictSource
): 'failed' | 'interrupted' | 'unconfirmed' | null {
switch (agentRowVerdict(row)) {
case 'failure':
case 'interruption':
return 'failed'
case 'cancellation':
case 'superseded':
case 'interruption':
return row.state === 'done' ? 'interrupted' : null
case 'unconfirmed':
return row.state === 'done' ? 'unconfirmed' : null
Expand Down
4 changes: 2 additions & 2 deletions src/main/acp/acp-structured-host-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ describe('reopening a Grok chat through the host', () => {
'completed'
)
// The transcript explains the cut the way a Claude or Codex chat's does.
const transcript = withNativeChatCutTurnNotices(rows, { agentName: 'Grok' })
const transcript = withNativeChatCutTurnNotices(rows)
expect(
transcript.some(
(row) =>
Expand Down Expand Up @@ -150,7 +150,7 @@ describe('a Grok crash whose exit is not proven yet', () => {
child.exit()
await rig.settle()
await host.flushStreamedEvents(SESSION)
const transcript = withNativeChatCutTurnNotices(await rows(), { agentName: 'Grok' })
const transcript = withNativeChatCutTurnNotices(await rows())
// The crash's own notice, as for any crash; not a Stop's.
expect(
transcript.flatMap((row) =>
Expand Down
2 changes: 1 addition & 1 deletion src/main/acp/acp-structured-host-stop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ describe('a Grok chat Stop', () => {
expect(framesOf(second, 'session/new')).toHaveLength(0)
expect(await messages()).toEqual(['hello', 'partial', 'again'])
// Only the Stop's own note: no exit or cut-reply notice for a Stop the person asked for.
const transcript = withNativeChatCutTurnNotices(await rows(), { agentName: 'Grok' })
const transcript = withNativeChatCutTurnNotices(await rows())
expect(
transcript.flatMap((row) => (row.body.kind === 'status' ? [row.body.text] : []))
).toEqual(['Cancellation requested.'])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ import {
} from './structured-agent-session-launch-env'
import { refuseAgentSessionMutation } from './structured-agent-session-mutation-admission'
import { settleStaleStructuredAgentSessionState } from './structured-agent-session-dead-generation-settlement'
import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation'
import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context'
import type {
StructuredAgentSessionProviderChild,
Expand Down Expand Up @@ -221,8 +220,7 @@ async function runAttachUnderAbort(
sessionId,
fence,
acquisitionGeneration,
deathEvidence: priorDeathEvidence,
failureTextContext: structuredAgentSessionFailureWordsContext(priorRecord)
deathEvidence: priorDeathEvidence
})
}
await bindAndDrain(eventSink, attached.journal, fence, (activity) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,17 +125,17 @@ async function settledTurn() {
const turn = items.map((item) => readAgentJournalTurn(item.body)).find(Boolean)
const [settled] = [...selectStructuredAgentSettledTurns(items).values()]
const label = settled && describeNativeChatTurnStatus({ elapsedSeconds: 0, ...settled }).key
// Every error row a reader's transcript shows, the cut turn's derived notice included.
const notices = withNativeChatCutTurnNotices(items, { agentName: 'Codex' }).flatMap((item) =>
item.body.kind === 'status' && item.body.tone === 'error' ? [item.body.text] : []
// Every row a reader's transcript shows about a stop, the cut turn's derived notice included.
const notices = withNativeChatCutTurnNotices(items).flatMap((item) =>
item.body.kind === 'status' && (item.body.tone === 'error' || item.body.tone === 'notice')
? [`${item.body.tone}: ${item.body.text}`]
: []
)
return { turn, settled, label, notices }
}

/** A turn nobody stopped reads like a finished one, with exactly one row saying it stopped. */
const ONE_NOTICE = [
'Codex stopped while this response was in progress. You can continue in this conversation.'
]
/** A turn nobody stopped reads interrupted, with exactly one muted row saying so, blaming no one. */
const ONE_NOTICE = ['notice: This response was interrupted. You can continue in this conversation.']

function lastSummary(statuses: AgentSessionStatusEvent[]) {
const last = statuses.at(-1)
Expand Down Expand Up @@ -166,8 +166,8 @@ describe('a turn cut short by closing its provider', () => {
const { turn, label, notices } = await settledTurn()
expect(turn).toMatchObject({ state: 'interrupted' })
expect(turn).not.toHaveProperty('outcome')
// News for the sidebar; the turn reads like a finished one beside its one notice.
expect(label).toBe('workedFor')
// Interrupted, as a Stop is, beside the one notice that says why.
expect(label).toBe('interruptedAfter')
expect(notices).toEqual(ONE_NOTICE)
})

Expand Down Expand Up @@ -261,7 +261,7 @@ describe('a turn cut short by closing its provider', () => {
session: { status: 'idle', turnOutcome: 'interruption' }
})
const { label, notices } = await settledTurn()
expect(label).toBe('workedFor')
expect(label).toBe('interruptedAfter')
expect(notices).toEqual(ONE_NOTICE)
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ import {
} from './structured-agent-session-attach'
import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
import { settleStaleStructuredAgentSessionState } from './structured-agent-session-dead-generation-settlement'
import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation'
import {
markStructuredQueueReopen,
structuredAgentSessionHostInstance
Expand Down Expand Up @@ -158,8 +157,7 @@ async function settleGoneGeneration(
sessionId: record.sessionId,
fence: record.lease.runtimeFence,
acquisitionGeneration: null,
deathEvidence: record.lease.deathEvidence ?? null,
failureTextContext: structuredAgentSessionFailureWordsContext(record)
deathEvidence: record.lease.deathEvidence ?? null
})
} catch (error) {
// Best effort: the next open or acquire re-derives it.
Expand Down
Loading
Loading