Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
7d1103b
fix(native-chat): read a crash-cut reply like a finished turn, with o…
brennanb2025 Oct 3, 2026
b0b71e3
fix(native-chat): explain a turn a quit or eviction cut, once
brennanb2025 Oct 3, 2026
029b6e9
test(native-chat): narrow the turn scope and add the seen map in the …
brennanb2025 Oct 3, 2026
0d14b9a
Merge remote-tracking branch 'origin/main' into brennanb2025/crash-cu…
brennanb2025 Oct 3, 2026
829a14b
fix(native-chat): derive a cut turn's one notice on read instead of w…
brennanb2025 Oct 3, 2026
06da7cc
fix(sidebar): read a cut-short turn's red mark from its acknowledgeme…
brennanb2025 Oct 3, 2026
ba26cf3
refactor(attention): use the one acknowledgement rule where it was co…
brennanb2025 Oct 3, 2026
b35c704
test(mobile): type the cut-turn notice test's client and hook holder
brennanb2025 Oct 3, 2026
54ec8b0
test: pin an older host's exit row by its writer and the sidebar rows…
brennanb2025 Oct 3, 2026
a9f35d6
test(sidebar): re-read the card and the tab when only an acknowledgem…
brennanb2025 Oct 3, 2026
b8abab0
fix(native-chat): keep a cut turn's notice through a resume that carr…
brennanb2025 Oct 3, 2026
8a793cb
fix(sidebar): judge a cut as seen by the main agent's clock when a su…
brennanb2025 Oct 3, 2026
abc92b3
Merge remote-tracking branch 'origin/main' into brennanb2025/crash-cu…
brennanb2025 Oct 3, 2026
f9223ea
fix(native-chat): tie a conversation-wide exit row to a cut only with…
brennanb2025 Oct 3, 2026
3b758d2
fix(native-chat): keep an owner's proven death the explanation of its…
brennanb2025 Oct 3, 2026
9e6a38e
test(sidebar): give the activity-status store mock the acknowledgemen…
brennanb2025 Oct 3, 2026
52864b1
Merge remote-tracking branch 'origin/main' into brennanb2025/crash-cu…
brennanb2025 Oct 3, 2026
7d84a83
Merge remote-tracking branch 'origin/main' into brennanb2025/crash-cu…
brennanb2025 Oct 4, 2026
f1c2af2
refactor: move the seen-gated red mark out of this change
brennanb2025 Oct 4, 2026
8214707
test(native-chat): pin that a restart note after a continuation's tur…
brennanb2025 Oct 4, 2026
14e8526
Merge commit 'b99d28e32fd10b77352a7e0bba10b54bbd2e0f86' into brennanb…
brennanb2025 Oct 4, 2026
0fe0e18
Merge commit '97fa6aee746d5923962b3801b3c7274a61059e9a' into brennanb…
brennanb2025 Oct 4, 2026
62c1a35
test(native-chat): keep a cut turn's notice beside a later message dr…
brennanb2025 Oct 4, 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
5 changes: 3 additions & 2 deletions mobile/src/session/MobileNativeChatTurnStatus.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,14 +78,15 @@ describe('MobileNativeChatTurnStatus', () => {
expect(onToggleExpanded).toHaveBeenCalledOnce()
})

it('heads a turn a crash cut off as failed, and a turn the user stopped as interrupted', () => {
// 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(['Failed after 12s'])
expect(labels(crashed.root)).toEqual(['Worked for 12s'])
act(() => crashed.unmount())
const stopped = render({
startedAt: Date.now(),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
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'
import type { RpcResponse } from '../transport/types'
import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session'

vi.mock('@react-native-async-storage/async-storage', () => ({
default: { getItem: vi.fn(async () => null), setItem: vi.fn(), removeItem: vi.fn() }
}))

const SCOPE = { kind: 'turn' as const, turnItemId: 'turn-1' }
const ITEMS: AgentJournalRenderItem[] = [
{
itemId: 'user-1',
revision: 0,
sequence: 1,
observedAt: 1,
turnScope: { kind: 'thread' },
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'long job' }] }
},
{
itemId: 'turn-1',
revision: 1,
sequence: 2,
observedAt: 2,
turnScope: { kind: 'thread' },
body: {
kind: 'turn',
turnId: 'turn-1',
userItemId: 'user-1',
state: 'interrupted',
startedAt: 2,
completedAt: 9
}
},
{
itemId: 'reply-1',
revision: 0,
sequence: 3,
observedAt: 3,
turnScope: SCOPE,
body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'half' }] }
}
]

function snapshot(items: AgentJournalRenderItem[]): AgentSessionSubscribeEvent {
return {
type: 'snapshot',
sessionId: 'session-1',
fence: 3,
page: {
sessionId: 'session-1',
epoch: 'epoch-1',
fence: 3,
direction: 'tail',
items,
removedItemIds: [],
submissions: [],
window: {
oldest: { epoch: 'epoch-1', sequence: 1 },
newest: { epoch: 'epoch-1', sequence: 3 },
nextCursor: { epoch: 'epoch-1', sequence: 4 }
},
liveCursor: { epoch: 'epoch-1', sequence: 3 },
hasOlder: false,
hasNewer: false
}
}
}

describe('useMobileStructuredAgentSession transcript', () => {
let renderer: ReactTestRenderer | null = null
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
})

it('shows a cut turn no row explains with the one notice desktop shows, in that turn', async () => {
let listener: ((value: unknown) => void) | null = null
const client: Pick<RpcClient, 'sendRequest' | 'subscribe'> = {
sendRequest: vi.fn(async (): Promise<RpcResponse> => ({
id: 'response-1',
ok: true,
result: {},
_meta: { runtimeId: 'runtime-1' }
})),
subscribe: vi.fn((_method: string, _params: unknown, onData: (value: unknown) => void) => {
listener = onData
return () => {}
})
}
const rendered: { hook: ReturnType<typeof useMobileStructuredAgentSession> | null } = {
hook: null
}
function Harness(): null {
rendered.hook = useMobileStructuredAgentSession({
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the hook calls only sendRequest and subscribe, both provided.
client: client as RpcClient,
sessionId: 'session-1',
enabled: true,
connected: true,
hostSupport: null,
agent: 'codex',
onSendError: vi.fn()
})
return null
}
act(() => {
renderer = create(createElement(Harness))
})
await vi.waitFor(() => expect(listener).toEqual(expect.any(Function)))

act(() => listener?.(snapshot(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'
}
]
})
)
// 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 })
})
})
20 changes: 17 additions & 3 deletions mobile/src/session/use-mobile-structured-agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { useCallback, useMemo, useRef } from 'react'
import { encodeNativeChatTranscriptIdentity } from '../../../src/shared/native-chat-transcript-retention'
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 {
activeStructuredAgentSessionTurnId,
Expand Down Expand Up @@ -157,16 +159,28 @@ export function useMobileStructuredAgentSession(args: {
onSendError
})

// 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 messages = useMemo(
// Off: the phone hands a rejected message back to its composer, so a row would show it twice.
() =>
projectStructuredAgentSessionMessages(state.items, [], state.submissions, {
projectStructuredAgentSessionMessages(transcriptItems, [], state.submissions, {
rejectedInPlace: false
}),
[state.items, state.submissions]
[transcriptItems, state.submissions]
)
const turnId = activeStructuredAgentSessionTurnId(state.items)
const turnTiming = useMobileStructuredAgentTurnTiming(state, turnId)
const turnTiming = useMobileStructuredAgentTurnTiming(
{ ...state, items: transcriptItems },
turnId
)
const activityText =
selectStructuredAgentTurnActivity(state.items, turnId, state.activity)?.text ?? null
const thinking = isStructuredAgentSessionThinking(state.items)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
import type { AgentSessionStatusEvent } from '../../../shared/agent-session-wire'
import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record'
import { describeNativeChatTurnStatus } from '../../../shared/native-chat-turn-status'
import { withNativeChatCutTurnNotices } from '../../../shared/native-chat-cut-turn-notice'
import { selectStructuredAgentSettledTurns } from '../../../shared/structured-agent-session-turn-timing'
import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule'
import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support'
Expand Down Expand Up @@ -121,9 +122,19 @@ async function settledTurn() {
const { items } = await host.journalSnapshot(SESSION)
const turn = items.map((item) => readAgentJournalTurn(item.body)).find(Boolean)
const [settled] = [...selectStructuredAgentSettledTurns(items).values()]
return { turn, settled }
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] : []
)
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.'
]

function lastSummary(statuses: AgentSessionStatusEvent[]) {
const last = statuses.at(-1)
return last?.type === 'status' ? last.session : null
Expand All @@ -136,11 +147,11 @@ describe('a turn cut short by closing its provider', () => {
await host.close(SESSION, 'user-close')

expect(lastSummary(statuses)).toMatchObject({ status: 'idle', turnOutcome: 'cancellation' })
const { turn, settled } = await settledTurn()
const { turn, label, notices } = await settledTurn()
expect(turn).toMatchObject({ state: 'interrupted', outcome: 'cancellation' })
expect(
settled && describeNativeChatTurnStatus({ elapsedSeconds: 0, ...settled })
).toMatchObject({ key: 'interruptedAfter' })
expect(label).toBe('interruptedAfter')
// The person's own close explains itself.
expect(notices).toEqual([])
})

it('leaves a close the user did not aim at this chat as news', async () => {
Expand All @@ -150,12 +161,12 @@ describe('a turn cut short by closing its provider', () => {
await host.close(SESSION, 'evict')

expect(lastSummary(statuses)).toMatchObject({ status: 'idle', turnOutcome: 'interruption' })
const { turn, settled } = await settledTurn()
const { turn, label, notices } = await settledTurn()
expect(turn).toMatchObject({ state: 'interrupted' })
expect(turn).not.toHaveProperty('outcome')
expect(
settled && describeNativeChatTurnStatus({ elapsedSeconds: 0, ...settled })
).toMatchObject({ key: 'failedAfter' })
// News for the sidebar; the turn reads like a finished one beside its one notice.
expect(label).toBe('workedFor')
expect(notices).toEqual(ONE_NOTICE)
})

it("records the user's close on a turn no adapter settled, through the host's fallback", async () => {
Expand Down Expand Up @@ -247,10 +258,9 @@ describe('a turn cut short by closing its provider', () => {
expect(statuses.findLast((event) => event.type === 'status')).toMatchObject({
session: { status: 'idle', turnOutcome: 'interruption' }
})
const { settled } = await settledTurn()
expect(
settled && describeNativeChatTurnStatus({ elapsedSeconds: 0, ...settled })
).toMatchObject({ key: 'failedAfter' })
const { label, notices } = await settledTurn()
expect(label).toBe('workedFor')
expect(notices).toEqual(ONE_NOTICE)
})

it("keeps the user's cancellation when a close aborts after the provider settled, then retries", async () => {
Expand Down Expand Up @@ -297,9 +307,10 @@ describe('a turn cut short by closing its provider', () => {
await host.collaboratorsForTests().lifetime.idleSweep.tick()

expect(closeCalls).toBe(1)
const { turn } = await settledTurn()
const { turn, notices } = await settledTurn()
expect(turn).toMatchObject({ state: 'interrupted' })
expect(turn?.outcome).toBe(verdict.outcome)
expect(notices).toEqual(cause === 'evict' ? ONE_NOTICE : [])
}
)

Expand All @@ -309,19 +320,21 @@ describe('a turn cut short by closing its provider', () => {

await host.flushAllStreamedEvents({ trigger: 'quit' })

const { turn } = await settledTurn()
const { turn, notices } = await settledTurn()
expect(turn).toMatchObject({ state: 'interrupted' })
expect(turn).not.toHaveProperty('outcome')
expect(notices).toEqual(ONE_NOTICE)
})

it('leaves a crash the provider saw before the user closed the chat as news', async () => {
exitObservedFirst = true
await runningTurn()
// The provider reports its own exit, which it saw first, as it closes: no verdict.
await host.close(SESSION, 'user-close')
const { turn } = await settledTurn()
const { turn, notices } = await settledTurn()
expect(turn).toMatchObject({ state: 'interrupted' })
expect(turn).not.toHaveProperty('outcome')
expect(notices).toEqual(ONE_NOTICE)
})

it.each(['user-close', 'evict'] as const)(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
} from './structured-agent-session-adapter'
import { resettleOpenStructuredAgentSessionConversation } from './structured-agent-session-conversation-open'
import { StructuredAgentSessionHost } from './structured-agent-session-host'
import { withNativeChatCutTurnNotices } from '../../../shared/native-chat-cut-turn-notice'
import { STRUCTURED_AGENT_SESSION_IDLE_MS } from './structured-agent-session-idle-sweep'
import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types'
import {
Expand Down Expand Up @@ -216,6 +217,34 @@ describe('a turn a crash cut short mid-tool', () => {
expect(completedStructuredAgentTurnSeconds(timing)).toBe(27)
})

// The turn bar reads like a finished turn, so this row is the one place the chat says why.
it('explains the cut once, with one notice row and a turn bar that does not repeat it', async () => {
openHost({ probeOwner: async () => ({ outcome: 'pid-absent' }) })

await host.restoreReadableSessions()

const { items } = await host.journalSnapshot(SESSION)
// As a reader's transcript shows it: the stored row is the explanation, so none is derived.
const statusRows = withNativeChatCutTurnNotices(items, { agentName: 'Claude' }).flatMap(
(item) => (item.body.kind === 'status' ? [item.body] : [])
)
expect(statusRows).toEqual([
expect.objectContaining({
text: 'Claude stopped while this response was in progress. You can continue in this conversation.',
failure: expect.objectContaining({ kind: 'providerExited' }),
tone: 'error'
})
])
const [timing] = selectStructuredAgentTurnTimings(items).values()
expect(
describeNativeChatTurnStatus({
elapsedSeconds: 0,
workedSeconds: completedStructuredAgentTurnSeconds(timing),
verdict: timing?.verdict
})
).toEqual({ key: 'workedFor', duration: '27s' })
})

it('ends at the pre-crash renewal when the child outlived Orca and recovery stopped it', async () => {
// The orphan is alive at relaunch, but its output went nowhere: none of that is work shown.
let alive = true
Expand Down Expand Up @@ -298,7 +327,7 @@ describe('a turn a read reached before the reconcile proved its owner dead', ()
unsubscribe()
})

it('reports the revision to the status feed as an interruption, which the chat folds as failed', async () => {
it('reports the revision to the status feed as an interruption, which the chat folds as worked', async () => {
const published: AgentSessionStatusSummary[] = []
openHost({
probeOwner: async () => ({ outcome: 'pid-absent' }),
Expand All @@ -314,7 +343,7 @@ describe('a turn a read reached before the reconcile proved its owner dead', ()
await host.reconcileRestartLeases()
await drainSession()

// The sidebar's red Failed, then the folded "Failed after 27s".
// The sidebar's red Failed until seen; the turn folds as "Worked for 27s" beside its notice row.
await vi.waitFor(() => expect(outcomes().at(-1)).toBe('interruption'))
const [timing] = selectStructuredAgentTurnTimings(
(await host.journalSnapshot(SESSION)).items
Expand All @@ -325,7 +354,7 @@ describe('a turn a read reached before the reconcile proved its owner dead', ()
workedSeconds: completedStructuredAgentTurnSeconds(timing),
verdict: timing?.verdict
})
).toEqual({ key: 'failedAfter', duration: '27s' })
).toEqual({ key: 'workedFor', duration: '27s' })
})

it('revises nothing twice, whoever re-runs the settle', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
type SubmissionRejectionFact
} from '../../../shared/agent-session-failure'
import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key'
import { STALE_SESSION_ROW_PREFIX } from '../../../shared/agent-session-stop-row-identity'
import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission'
import {
AGENT_JOURNAL_THREAD_SCOPE,
Expand Down Expand Up @@ -229,7 +230,7 @@ export async function settleStaleStructuredAgentSessionState(input: {
)
// Per attempt: a retry re-partitions only what is left, and a reused chunk id would skip it.
const generation = input.acquisitionGeneration ?? `seq-${journal.cursor().sequence}`
const settlementId = `stale-session:${input.sessionId}:${input.fence}:${generation}`
const settlementId = `${STALE_SESSION_ROW_PREFIX}${input.sessionId}:${input.fence}:${generation}`
const mutations: JournalLifecycleMutationInput[] = []
for (const item of items) {
const identity = parseAgentJournalItemKey(item.itemId)
Expand Down Expand Up @@ -261,7 +262,7 @@ export async function settleStaleStructuredAgentSessionState(input: {
// Named by the death it explains, so a retry after a partly written settle adds no second row.
identity: {
provider: 'orca',
clientMessageId: `stale-session:${input.sessionId}:death-${evidence.ownerFence ?? 'unowned'}-${evidence.observedAt}`
clientMessageId: `${STALE_SESSION_ROW_PREFIX}${input.sessionId}:death-${evidence.ownerFence ?? 'unowned'}-${evidence.observedAt}`
},
// The death evidence is Orca's log text, never a sentence for a person: the row says only
// that the provider stopped.
Expand Down
Loading
Loading