From 0c8a7f1f1b096ed3106846396212a239293c1ee9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 00:10:26 -0700 Subject: [PATCH 01/52] fix(native-chat): an older Orca keeps a chat with newer content read-only, and an unreadable annotation costs only itself A body or lifecycle mutation of a kind this build does not know, inside a row of a known kind, now latches the chat read-only with every row kept, as a newer row kind or version already does. It used to read as damage, and the open deleted the journal from that row on. Each optional field of a body schema now declares what an unparseable value means: - droppable (an annotation): removed from the row in memory; the chat stays writable; - must-understand (a prompt's questions, a plan approval's plan, a turn's lifecycle, a goal change): the row is unreadable and the chat latches read-only. Only a required field that fails is damage, repaired as before. A test holds every optional field reachable from a body schema to a policy; the known body kinds are read off the schema. Prompt options and questions no longer reject a key this build does not know, which deleted the journal from that row on. The context-usage drop is now one case of the droppable rule. --- .../journal-row-schema.test.ts | 13 +- .../journal-row-schema.ts | 187 +++++++++------ .../journal-unknown-content.test.ts | 219 ++++++++++++++++++ ...ent-session-journal-body-admission.test.ts | 163 +++++++++++++ .../agent-session-journal-body-admission.ts | 61 +++++ .../agent-session-journal-optional-facts.ts | 130 +++++++++++ src/shared/agent-session-journal-schemas.ts | 176 +++++++------- 7 files changed, 786 insertions(+), 163 deletions(-) create mode 100644 src/main/native-chat/agent-session-journal/journal-unknown-content.test.ts create mode 100644 src/shared/agent-session-journal-body-admission.test.ts create mode 100644 src/shared/agent-session-journal-body-admission.ts create mode 100644 src/shared/agent-session-journal-optional-facts.ts diff --git a/src/main/native-chat/agent-session-journal/journal-row-schema.test.ts b/src/main/native-chat/agent-session-journal/journal-row-schema.test.ts index 1d013255b215..966304e60a37 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-schema.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-schema.test.ts @@ -421,7 +421,7 @@ describe('producer linkage on the persisted row', () => { expect(parsed.ok && 'agentId' in parsed.row).toBe(false) }) - it('leaves a strict prompt shape able to parse, because linkage rides the row', () => { + it('parses a prompt shape with linkage on the row', () => { const question = { ...BASE, v: 3, @@ -440,10 +440,9 @@ describe('producer linkage on the persisted row', () => { expect(parsed.ok).toBe(true) }) - it('rejects the same row when linkage is put INSIDE the strict shape', () => { - // The positive control for the test above: proof the strictness it avoids is - // real, rather than the row parsing for some unrelated reason. This is why - // the bundle rides the row base and never a body. + it('reads the same row with linkage put INSIDE a prompt option, ignoring the key there', () => { + // Older builds read prompt options strictly and dropped the journal from such a row, which is + // why the bundle rides the row base and never a body. This build ignores and keeps the key. const smuggled = { ...BASE, v: 3, @@ -457,6 +456,8 @@ describe('producer linkage on the persisted row', () => { resolution } } - expect(parseJournalRow(JSON.stringify(smuggled)).ok).toBe(false) + const parsed = parseJournalRow(JSON.stringify(smuggled)) + expect(parsed.ok && parsed.row.kind === 'item' && parsed.row.body).toEqual(smuggled.body) + expect(parsed.ok && 'agentId' in parsed.row).toBe(false) }) }) diff --git a/src/main/native-chat/agent-session-journal/journal-row-schema.ts b/src/main/native-chat/agent-session-journal/journal-row-schema.ts index 9276b1a8c05f..5d148d3a0219 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-schema.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-schema.ts @@ -12,6 +12,13 @@ // rule delete the journal from an unknown kind, so a new kind either ships its // reader first and is written only once no supported build lacks that reader, // or is written at a bumped `v`. +// +// Inside a known row the same holds a level down. A body or lifecycle mutation of a kind this +// build does not know is UNREADABLE. An optional body field it cannot parse is dropped or makes +// the row UNREADABLE, as the field declares (agent-session-journal-schemas.ts). Only a required +// field that fails is damage, which the open repairs. Builds from before this rule delete the +// journal from all three, so the same reader-first-or-`v` rule applies to a new body kind, and an +// optional field never changes type: a new meaning is a new key. import type { AgentSessionFailureFact } from '../../../shared/agent-session-failure' import { @@ -24,15 +31,15 @@ import { type AgentSessionProviderHandle } from '../../../shared/agent-session-journal-types' import { - isAdmissibleAgentJournalItemBody, - isAdmissibleAgentJournalMessageBody -} from '../../../shared/agent-session-journal-schemas' -import { isAdmissibleAgentSessionContextUsage } from '../../../shared/agent-session-context-usage-schema' + readAgentJournalItemBody, + readAgentJournalMessageBody, + type AgentJournalContentVerdict +} from '../../../shared/agent-session-journal-body-admission' import type { StructuredAgentSessionStopCause } from '../agent-session-wire/structured-agent-session-stop-cause' -/** Producer linkage rides the row BASE rather than the body: the two nested - * prompt shapes are `.strict()`, so an unknown key on a body would make the - * whole row parse as malformed. It is also deliberately not a `v` bump — an +/** Producer linkage rides the row BASE rather than the body: older builds read + * the two nested prompt shapes strictly, so an unknown key there made the whole + * row parse as malformed. It is also deliberately not a `v` bump — an * unknown `v` makes a row unreadable and latches the host read-only, while an * unknown KEY is ignored below, so an older host reads a stamped row and * behaves exactly as it does today. */ @@ -229,19 +236,16 @@ export function parseJournalRow(line: string): JournalRowParse { } } } - dropUnusableContextUsage(upcast) - if (isJournalRow(upcast)) { + const content = journalRowContent(upcast) + if (isReadableJournalRow(upcast, content)) { return { ok: true, row: upcast } } - // A newer build's kind is placed by the envelope every row keeps; one without it is damage. - const { kind } = upcast - const unknownKind = typeof kind === 'string' && kind !== '' && !KNOWN_ROW_KINDS.has(kind) - return { ok: false, unreadable: unknownKind && hasJournalRowEnvelope(upcast) } + return { ok: false, unreadable: content === 'unreadable' } } /** Linkage ids this build cannot trust, removed from a row it still keeps. * - * Deliberately NOT part of `isJournalRow`: rejecting a row there drops it from + * Deliberately NOT part of `journalRowContent`: rejecting a row there drops it from * the timeline, so a validator tightened against one bad field becomes a * whole-store kill switch. Dropping the field degrades the row to the * session's own agent — what every row said before linkage existed — while @@ -278,28 +282,6 @@ function dropUnusableTurnScope(record: Record): void { } } -/** Context facts this build cannot read, removed from the turn row that carries - * them. Same reasoning as linkage: they are an annotation on the turn, and - * rejecting the row for them would truncate the journal from that row on. */ -function dropUnusableContextUsage(record: Record): void { - const bodies = [ - record.kind === 'item' ? record.body : undefined, - ...(record.kind === 'lifecycle-batch' && Array.isArray(record.mutations) - ? record.mutations.map((mutation) => (isPlainObject(mutation) ? mutation.body : undefined)) - : []) - ] - for (const body of bodies) { - if ( - isPlainObject(body) && - body.kind === 'turn' && - body.contextUsage !== undefined && - !isAdmissibleAgentSessionContextUsage(body.contextUsage) - ) { - delete body.contextUsage - } - } -} - /** Read-time upcast chain. Each step raises a row exactly one version. */ function upcastRow(record: Record, version: number): Record { let current = record @@ -322,46 +304,85 @@ function isPlainObject(value: unknown): value is Record { * Render BODIES are the exception and validate against the canonical deep * schema — their nested shapes are dereferenced unguarded all the way to the * rendered surface, so a JSON-valid corruption must fail here, not there. */ -function isJournalRow(record: Record): record is JournalRow { - const fieldCheck = typeof record.kind === 'string' ? KNOWN_ROW_KINDS.get(record.kind) : undefined - return fieldCheck !== undefined && hasJournalRowEnvelope(record) && fieldCheck(record) +function journalRowContent(record: Record): AgentJournalContentVerdict { + if (!hasJournalRowEnvelope(record)) { + return 'malformed' + } + const { kind } = record + const contentCheck = typeof kind === 'string' ? KNOWN_ROW_KINDS.get(kind) : undefined + if (contentCheck) { + return contentCheck(record) + } + // A newer build's kind is placed by the envelope every row keeps. + return typeof kind === 'string' && kind !== '' ? 'unreadable' : 'malformed' +} + +/** Narrows a record by the verdict `journalRowContent` gave that same record. */ +function isReadableJournalRow( + _record: Record, + content: AgentJournalContentVerdict +): _record is JournalRow { + return content === 'readable' +} + +/** Fields first: a body is read only once the fields that place it hold. */ +function contentWhen( + fields: boolean, + content: () => AgentJournalContentVerdict +): AgentJournalContentVerdict { + return fields ? content() : 'malformed' } /** Each kind's own fields, keyed by every kind the union holds: a kind without a check here fails * to compile, never reads as a newer build's kind. */ -const ROW_FIELD_CHECK_BY_KIND: Record< +const ROW_CONTENT_CHECK_BY_KIND: Record< JournalRow['kind'], - (record: Record) => boolean + (record: Record) => AgentJournalContentVerdict > = { - epoch: (record) => typeof record.reason === 'string' && isPlainObject(record.providerHandle), + epoch: (record) => + contentWhen( + typeof record.reason === 'string' && isPlainObject(record.providerHandle), + () => 'readable' + ), item: (record) => - typeof record.itemId === 'string' && - Number.isInteger(record.revision) && - isAdmissibleAgentJournalItemBody(record.body), - tombstone: (record) => typeof record.itemId === 'string' && Number.isInteger(record.revision), + contentWhen(typeof record.itemId === 'string' && Number.isInteger(record.revision), () => + readAgentJournalItemBody(record.body) + ), + tombstone: (record) => + contentWhen( + typeof record.itemId === 'string' && Number.isInteger(record.revision), + () => 'readable' + ), submission: (record) => - typeof record.clientMessageId === 'string' && - record.clientMessageId.length > 0 && - typeof record.payloadFingerprint === 'string' && - isPlainObject(record.providerHandle) && - isAdmissibleAgentJournalMessageBody(record.body), + contentWhen( + typeof record.clientMessageId === 'string' && + record.clientMessageId.length > 0 && + typeof record.payloadFingerprint === 'string' && + isPlainObject(record.providerHandle), + () => readAgentJournalMessageBody(record.body) + ), dispatch: (record) => - typeof record.clientMessageId === 'string' && - record.clientMessageId.length > 0 && - typeof record.state === 'string' && - record.state.length > 0 && - (record.providerItemId === null || typeof record.providerItemId === 'string') && - (record.reason === null || typeof record.reason === 'string'), + contentWhen( + typeof record.clientMessageId === 'string' && + record.clientMessageId.length > 0 && + typeof record.state === 'string' && + record.state.length > 0 && + (record.providerItemId === null || typeof record.providerItemId === 'string') && + (record.reason === null || typeof record.reason === 'string'), + () => 'readable' + ), 'lifecycle-batch': (record) => - typeof record.settlementId === 'string' && - record.settlementId.length > 0 && - Array.isArray(record.mutations) && - record.mutations.length > 0 && - record.mutations.length <= MAX_JOURNAL_LIFECYCLE_BATCH_MUTATIONS && - Buffer.byteLength(JSON.stringify(record), 'utf8') + 1 <= MAX_JOURNAL_LIFECYCLE_BATCH_BYTES && - record.mutations.every(isLifecycleMutation) + contentWhen( + typeof record.settlementId === 'string' && + record.settlementId.length > 0 && + Array.isArray(record.mutations) && + record.mutations.length > 0 && + record.mutations.length <= MAX_JOURNAL_LIFECYCLE_BATCH_MUTATIONS && + Buffer.byteLength(JSON.stringify(record), 'utf8') + 1 <= MAX_JOURNAL_LIFECYCLE_BATCH_BYTES, + () => lifecycleMutationsContent(record.mutations) + ) } -const KNOWN_ROW_KINDS = new Map(Object.entries(ROW_FIELD_CHECK_BY_KIND)) +const KNOWN_ROW_KINDS = new Map(Object.entries(ROW_CONTENT_CHECK_BY_KIND)) /** The fields every row keeps whatever its kind: its epoch, its place in it, its writer, its time. */ function hasJournalRowEnvelope(record: Record): boolean { @@ -375,18 +396,36 @@ function hasJournalRowEnvelope(record: Record): boolean { ) } -function isLifecycleMutation(value: unknown): value is JournalLifecycleMutation { +/** Each mutation kind's own fields, keyed like the row kinds and for the same reason. */ +const MUTATION_CONTENT_CHECK_BY_KIND: Record< + JournalLifecycleMutation['kind'], + (mutation: Record) => AgentJournalContentVerdict +> = { + item: (mutation) => + contentWhen(Number.isInteger(mutation.revision), () => readAgentJournalItemBody(mutation.body)), + tombstone: (mutation) => contentWhen(Number.isInteger(mutation.revision), () => 'readable') +} +const KNOWN_MUTATION_KINDS = new Map(Object.entries(MUTATION_CONTENT_CHECK_BY_KIND)) + +/** Damage anywhere is damage; otherwise a mutation a newer build wrote makes the batch unreadable. */ +function lifecycleMutationsContent(mutations: unknown): AgentJournalContentVerdict { + const verdicts = (Array.isArray(mutations) ? mutations : []).map(lifecycleMutationContent) + if (verdicts.includes('malformed')) { + return 'malformed' + } + return verdicts.includes('unreadable') ? 'unreadable' : 'readable' +} + +function lifecycleMutationContent(value: unknown): AgentJournalContentVerdict { if (!isPlainObject(value) || typeof value.itemId !== 'string') { - return false + return 'malformed' } - if (value.kind === 'tombstone') { - return Number.isInteger(value.revision) + const { kind } = value + const contentCheck = typeof kind === 'string' ? KNOWN_MUTATION_KINDS.get(kind) : undefined + if (contentCheck) { + return contentCheck(value) } - return ( - value.kind === 'item' && - Number.isInteger(value.revision) && - isAdmissibleAgentJournalItemBody(value.body) - ) + return typeof kind === 'string' && kind !== '' ? 'unreadable' : 'malformed' } /** Approximate on-disk cost of a row, used for the per-session size bound. */ diff --git a/src/main/native-chat/agent-session-journal/journal-unknown-content.test.ts b/src/main/native-chat/agent-session-journal/journal-unknown-content.test.ts new file mode 100644 index 000000000000..0dfd48076917 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-unknown-content.test.ts @@ -0,0 +1,219 @@ +// Inside a row of a known kind, what this build cannot read is never repaired away unless it is +// damage: a body or mutation of a newer kind, or a must-understand fact it cannot parse, latches +// the chat read-only with every row kept as it was; an annotation it cannot parse costs only that +// annotation. A required field that fails is still damage, repaired as before. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + AGENT_JOURNAL_THREAD_SCOPE, + AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + type AgentJournalItemIdentity, + type AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { + closeTestJournalHostDatabase, + createTrackedJournalOpener, + insertTestJournalRowJson, + liveTestJournalRows, + openTestJournalHostDatabase +} from './journal-host-database-test-support' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-newer-content', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } +} +const SCOPE = { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + +let root: string +const journals = createTrackedJournalOpener() + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-journal-newer-content-')) +}) + +afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +function item(ordinal: number): AgentJournalItemIdentity { + return { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal } +} + +function open() { + return journals.open({ identity: IDENTITY, stateDirectory: root }) +} + +function stored(): { seq: number; rowJson: string }[] { + return liveTestJournalRows(openTestJournalHostDatabase(root).db, IDENTITY.sessionId).map( + (row) => ({ seq: row.seq, rowJson: row.rowJson }) + ) +} + +/** An anchor, two items, then `rows` as a newer build (or damage) wrote them, then one more item + * of this build's: closed, so the next open replays all of it. */ +async function journalWith(rows: (epoch: string) => Record[]) { + const first = await open() + await first.appendItem(item(0), { kind: 'status', text: 'one' }, SCOPE) + await first.appendItem(item(1), { kind: 'status', text: 'two' }, SCOPE) + const epoch = first.epoch + let seq = first.cursor().sequence + await journals.closeAll() + const tail = itemRow(epoch, 'codex:thread-1:turn-1:9', { kind: 'status', text: 'after' }) + const { db } = openTestJournalHostDatabase(root) + for (const row of [...rows(epoch), tail]) { + seq += 1 + insertTestJournalRowJson(db, IDENTITY.sessionId, seq, JSON.stringify({ ...row, seq }), 5_000) + } + closeTestJournalHostDatabase(root) + return { written: stored() } +} + +function itemRow(epoch: string, itemId: string, body: Record) { + return { + v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + epoch, + fence: 1, + ts: 5_000, + kind: 'item', + itemId, + revision: 1, + body + } +} + +const NEWER_BODY = { kind: 'plan-card', steps: [{ text: 'by a newer build' }] } + +async function expectReadOnlyAndKept(written: { seq: number; rowJson: string }[]) { + const journal = await open() + expect(journal.isReadOnly).toBe(true) + expect(journal.repair).toEqual({ malformedRows: 0 }) + await expect( + journal.appendItem(item(5), { kind: 'status', text: 'refused' }, SCOPE) + ).rejects.toMatchObject({ code: 'journal_read_only' }) + await journals.closeAll() + expect(stored()).toEqual(written) + const reopened = await open() + expect(reopened.isReadOnly).toBe(true) + await journals.closeAll() + expect(stored()).toEqual(written) +} + +describe("a newer build's content inside a known row", () => { + it('latches read-only on an item body of a newer kind, with every row byte-identical', async () => { + const { written } = await journalWith((epoch) => [ + itemRow(epoch, 'codex:thread-1:turn-1:2', NEWER_BODY) + ]) + await expectReadOnlyAndKept(written) + }) + + it.each([ + ['of a newer kind', { kind: 'pin', itemId: 'codex:thread-1:turn-1:3', revision: 2 }], + [ + 'whose body is a newer kind', + { kind: 'item', itemId: 'codex:thread-1:turn-1:4', revision: 1, body: NEWER_BODY } + ] + ])('latches read-only on a lifecycle mutation %s', async (_name, mutation) => { + const { written } = await journalWith((epoch) => [ + { + v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + epoch, + fence: 1, + ts: 5_000, + kind: 'lifecycle-batch', + settlementId: 'settle-1', + mutations: [ + { + kind: 'item', + itemId: 'codex:thread-1:turn-1:3', + revision: 1, + body: { kind: 'status', text: 'ok' } + }, + mutation + ] + } + ]) + await expectReadOnlyAndKept(written) + }) + + it('latches read-only on a submission whose body is a newer kind', async () => { + const { written } = await journalWith((epoch) => [ + { + v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + epoch, + fence: 1, + ts: 5_000, + kind: 'submission', + clientMessageId: 'client-1', + payloadFingerprint: 'f', + providerHandle: IDENTITY.providerHandle, + body: { kind: 'voice-note', clip: 'by a newer build' } + } + ]) + await expectReadOnlyAndKept(written) + }) + + it('latches read-only on a must-understand fact it cannot parse', async () => { + const { written } = await journalWith((epoch) => [ + itemRow(epoch, 'codex:thread-1:turn-1:2', { + kind: 'status', + text: 'Turn started', + turnLifecycle: { turnId: 7, state: 'running' } + }) + ]) + await expectReadOnlyAndKept(written) + }) +}) + +describe('an annotation this build cannot parse', () => { + it('costs only the annotation: the chat stays writable, nothing is deleted or rewritten', async () => { + const { written } = await journalWith((epoch) => [ + itemRow(epoch, 'codex:thread-1:turn-1:2', { + kind: 'turn', + turnId: 'turn-1', + state: 'done', + durationMs: null, + outcome: 3 + }) + ]) + const journal = await open() + expect(journal.isReadOnly).toBe(false) + expect(journal.repair).toEqual({ malformedRows: 0 }) + const items = journal.snapshot().items + expect(items.map((entry) => entry.itemId)).toEqual([ + 'codex:thread-1:turn-1:0', + 'codex:thread-1:turn-1:1', + 'codex:thread-1:turn-1:2', + 'codex:thread-1:turn-1:9' + ]) + expect(items[2]?.body).toEqual({ kind: 'turn', turnId: 'turn-1', state: 'done' }) + await journals.closeAll() + expect(stored()).toEqual(written) + }) +}) + +describe('a required field that fails', () => { + it('is still damage: the open deletes from that row and discloses the repair', async () => { + const { written } = await journalWith((epoch) => [ + itemRow(epoch, 'codex:thread-1:turn-1:2', { + kind: 'diff', + path: 'a.ts', + patch: { head: 'x' } + }) + ]) + const journal = await open() + expect(journal.isReadOnly).toBe(false) + expect(journal.repair).toEqual({ malformedRows: 1 }) + await journals.closeAll() + const after = stored() + expect(after.slice(0, 3)).toEqual(written.slice(0, 3)) + expect(after.some((row) => row.rowJson.includes('"diff"'))).toBe(false) + expect(after.some((row) => row.rowJson.includes('"after"'))).toBe(false) + }) +}) diff --git a/src/shared/agent-session-journal-body-admission.test.ts b/src/shared/agent-session-journal-body-admission.test.ts new file mode 100644 index 000000000000..51afb7c23030 --- /dev/null +++ b/src/shared/agent-session-journal-body-admission.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from 'vitest' +import { + readAgentJournalItemBody, + readAgentJournalMessageBody +} from './agent-session-journal-body-admission' +import { unclassifiedOptionalFacts } from './agent-session-journal-optional-facts' +import { + AgentJournalItemBodySchema, + AgentJournalMessageBodySchema +} from './agent-session-journal-schemas' + +const RESOLUTION = { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } +const QUESTION = { + kind: 'question', + question: 'Which lane?', + options: [{ id: 'o-1', label: 'First' }], + questions: [ + { + id: 'q-1', + question: 'Which lane?', + multiSelect: false, + options: [{ id: 'o-1', label: 'First' }], + freeTextQuestionId: 'q-1-other' + } + ], + resolution: RESOLUTION +} +const TURN_STATUS = { + kind: 'status', + text: 'Turn started', + turnLifecycle: { turnId: 't-1', state: 'running', startedAt: 5 } +} + +/** Reads a copy, so each case starts from the same body; returns the verdict and what is left. */ +function read(body: Record) { + const copy = structuredClone(body) + return { verdict: readAgentJournalItemBody(copy), body: copy } +} + +it('gives every optional field a body can hold a policy', () => { + expect(unclassifiedOptionalFacts(AgentJournalItemBodySchema)).toEqual([]) + expect(unclassifiedOptionalFacts(AgentJournalMessageBodySchema)).toEqual([]) +}) + +describe('a body of a kind this build does not know', () => { + it('is unreadable and left as it was', () => { + const body = { kind: 'plan-card', steps: [{ text: 'by a newer build' }] } + expect(read(body)).toEqual({ verdict: 'unreadable', body }) + }) + + it.each([ + ['no kind', { text: 'x' }], + ['an empty kind', { kind: '' }], + ['a kind that is not a string', { kind: 7 }] + ])('is damage with %s', (_name, body) => { + expect(read(body).verdict).toBe('malformed') + }) + + it.each([null, 'status', [{ kind: 'status' }]])('is damage when the body is %j', (body) => { + expect(readAgentJournalItemBody(body)).toBe('malformed') + }) +}) + +describe('an optional fact this build cannot parse', () => { + it.each([ + ['a negative turn duration', { kind: 'turn', turnId: 't', state: 'done', durationMs: -1 }], + ['a null turn start', { kind: 'turn', turnId: 't', state: 'done', startedAt: null }], + ['a failure fact with no kind', { kind: 'status', text: 'Failed', failure: { kind: '' } }], + [ + 'context facts of a later shape', + { kind: 'turn', turnId: 't', state: 'done', contextUsage: 1 } + ], + [ + 'a tool call id that is only spaces', + { kind: 'tool-call', name: 'Read', state: 'done', callId: ' ' } + ] + ])('drops %s and keeps the row', (_name, body) => { + const { verdict, body: left } = read(body) + expect(verdict).toBe('readable') + const dropped = Object.keys(body).filter((key) => !(key in left)) + expect(dropped).toHaveLength(1) + expect(left).toEqual( + Object.fromEntries(Object.entries(body).filter(([key]) => key !== dropped[0])) + ) + }) + + it('drops a broken annotation nested in a block, an option and a turn lifecycle, and keeps them', () => { + const message = { + kind: 'message', + role: 'assistant', + blocks: [{ type: 'text', text: 'hi', tone: 5 }] + } + expect(read(message)).toEqual({ + verdict: 'readable', + body: { ...message, blocks: [{ type: 'text', text: 'hi' }] } + }) + const question = { ...QUESTION, options: [{ id: 'o-1', label: 'First', description: 3 }] } + expect(read(question)).toEqual({ + verdict: 'readable', + body: { ...question, options: [{ id: 'o-1', label: 'First' }] } + }) + const status = { + ...TURN_STATUS, + turnLifecycle: { ...TURN_STATUS.turnLifecycle, startedAt: 'x' } + } + expect(read(status)).toEqual({ + verdict: 'readable', + body: { ...status, turnLifecycle: { turnId: 't-1', state: 'running' } } + }) + }) + + it.each([ + ['a question list that is not a list', { ...QUESTION, questions: null }], + ['a question with no free-text id string', { ...QUESTION, freeTextQuestionId: 4 }], + ['a turn lifecycle with no turn', { ...TURN_STATUS, turnLifecycle: { state: 'running' } }], + ['a goal change with no goal', { kind: 'status', text: 'Goal', threadGoal: { state: 'set' } }], + [ + 'a plan approval with no plan', + { + kind: 'approval', + title: 'Approve the plan?', + detail: null, + options: [{ id: 'yes', label: 'Yes' }], + resolution: RESOLUTION, + subject: { kind: 'plan' } + } + ] + ])('makes the row unreadable, not damaged, for must-understand %s', (_name, body) => { + expect(read(body)).toEqual({ verdict: 'unreadable', body }) + }) +}) + +describe('a field this build does not know', () => { + it('is ignored and kept, in a prompt option too', () => { + const body = { ...QUESTION, options: [{ id: 'o-1', label: 'First', shortcut: 'f' }], next: 1 } + expect(read(body)).toEqual({ verdict: 'readable', body }) + }) +}) + +describe('a required field that fails', () => { + it.each([ + ['question options that are not a list', { ...QUESTION, options: null }], + ['a question without its resolution', { ...QUESTION, resolution: undefined }], + ['a diff whose patch is broken', { kind: 'diff', path: 'a.ts', patch: { head: 'x' } }], + ['a turn with no state', { kind: 'turn', turnId: 't' }], + [ + 'a known block missing its text', + { kind: 'message', role: 'user', blocks: [{ type: 'text' }] } + ] + ])('is damage: %s', (_name, body) => { + expect(read(body).verdict).toBe('malformed') + }) +}) + +describe("a submission's body", () => { + it('reads a message, a newer kind as unreadable, and another known kind as damage', () => { + expect(readAgentJournalMessageBody({ kind: 'message', role: 'user', blocks: [] })).toBe( + 'readable' + ) + expect(readAgentJournalMessageBody({ kind: 'voice-note', clip: 'x' })).toBe('unreadable') + expect(readAgentJournalMessageBody({ kind: 'status', text: 'x' })).toBe('malformed') + }) +}) diff --git a/src/shared/agent-session-journal-body-admission.ts b/src/shared/agent-session-journal-body-admission.ts new file mode 100644 index 000000000000..6ba6cbbf0ca0 --- /dev/null +++ b/src/shared/agent-session-journal-body-admission.ts @@ -0,0 +1,61 @@ +// Reading a persisted item body, by the per-field rule in agent-session-journal-schemas.ts: a body +// is readable (perhaps less an annotation it could not parse), unreadable (a newer build's), or +// damage. + +import { z } from 'zod' +import { + dropUnreadableOptionalFacts, + type AgentJournalOptionalFactPolicy +} from './agent-session-journal-optional-facts' +import { + AgentJournalItemBodySchema, + AgentJournalMessageBodySchema +} from './agent-session-journal-schemas' + +/** `unreadable` is never damage: the caller keeps the row and stops writing, as for a newer `v`. */ +export type AgentJournalContentVerdict = 'readable' | 'unreadable' | 'malformed' + +/** Every body kind this build knows, read off the schema so the two can never disagree. */ +const KNOWN_BODY_KINDS: ReadonlySet = new Set( + AgentJournalItemBodySchema.options.flatMap((option) => [...option.shape.kind.values]) +) + +const DROPPABLE: ReadonlySet = new Set(['droppable']) +const ANY_OPTIONAL: ReadonlySet = new Set([ + 'droppable', + 'must-understand' +]) + +/** Reads an item body in place: an unparseable droppable fact is removed from it. */ +export function readAgentJournalItemBody(body: unknown): AgentJournalContentVerdict { + return readBody(AgentJournalItemBodySchema, body) +} + +/** The same for a submission's body, which only a user message may be. */ +export function readAgentJournalMessageBody(body: unknown): AgentJournalContentVerdict { + return readBody(AgentJournalMessageBodySchema, body) +} + +function readBody(schema: z.core.$ZodType, body: unknown): AgentJournalContentVerdict { + if (typeof body !== 'object' || body === null || Array.isArray(body) || !('kind' in body)) { + return 'malformed' + } + const { kind } = body + if (typeof kind !== 'string' || kind === '') { + return 'malformed' + } + if (!KNOWN_BODY_KINDS.has(kind)) { + return 'unreadable' + } + if (z.safeParse(schema, body).success) { + return 'readable' + } + dropUnreadableOptionalFacts(schema, body, DROPPABLE) + if (z.safeParse(schema, body).success) { + return 'readable' + } + // Without its must-understand facts it would read: only they failed, so it is a newer build's. + const probe = structuredClone(body) + dropUnreadableOptionalFacts(schema, probe, ANY_OPTIONAL) + return z.safeParse(schema, probe).success ? 'unreadable' : 'malformed' +} diff --git a/src/shared/agent-session-journal-optional-facts.ts b/src/shared/agent-session-journal-optional-facts.ts new file mode 100644 index 000000000000..f589d206af6e --- /dev/null +++ b/src/shared/agent-session-journal-optional-facts.ts @@ -0,0 +1,130 @@ +// What a persisted body does with an optional fact this build cannot parse. Every optional field +// of a journal body schema states it where the field is declared: +// +// - droppable: an annotation (a time, a display detail, a typed copy of what the row also says in +// words). An unparseable one is removed from the row in memory, and the row reads as one written +// before the field existed, which every reader already handles because older rows lack it. +// - must-understand: a fact a reader acts on (a prompt's questions, a turn's lifecycle). An +// unparseable one makes the row unreadable: the chat latches read-only, as for a newer schema +// version, and nothing is deleted. +// +// A required field that fails is damage, repaired as before. A key this build does not know is +// ignored and kept. `unclassifiedOptionalFacts` lets a test hold every optional field reachable +// from a body schema to one of the two policies. + +import { z } from 'zod' + +export type AgentJournalOptionalFactPolicy = 'droppable' | 'must-understand' + +const POLICY_BY_FIELD = new WeakMap() + +function classifiedFact( + schema: T, + policy: AgentJournalOptionalFactPolicy +): z.ZodOptional { + const field = schema.optional() + POLICY_BY_FIELD.set(field, policy) + return field +} + +/** An optional annotation: unparseable, it is dropped and the row keeps the rest. */ +export function droppableFact(schema: T): z.ZodOptional { + return classifiedFact(schema, 'droppable') +} + +/** An optional fact a reader acts on: unparseable, it makes the row unreadable. */ +export function mustUnderstandFact(schema: T): z.ZodOptional { + return classifiedFact(schema, 'must-understand') +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** The union members `value` takes: a discriminated union's by its discriminator. A plain union's + * object members cannot be told apart, so only its discriminated members are walked. */ +function unionMembersFor(schema: z.ZodUnion, value: unknown): readonly z.core.$ZodType[] { + if (schema instanceof z.ZodDiscriminatedUnion) { + const discriminator = schema.def.discriminator + if (!isPlainObject(value)) { + return [] + } + return schema.options.filter( + (option) => + option instanceof z.ZodObject && + z.safeParse(option.shape[discriminator], value[discriminator]).success + ) + } + return schema.options.filter((option) => option instanceof z.ZodDiscriminatedUnion) +} + +/** + * Removes, in place, every optional fact under `schema` whose policy is in `policies` and whose + * value fails its own schema. A droppable fact is judged whole; required fields and must-understand + * facts are walked into first, so a broken annotation inside one goes and the fact stays. + */ +export function dropUnreadableOptionalFacts( + schema: z.core.$ZodType, + value: unknown, + policies: ReadonlySet +): void { + if (schema instanceof z.ZodOptional || schema instanceof z.ZodNullable) { + dropUnreadableOptionalFacts(schema.unwrap(), value, policies) + return + } + if (schema instanceof z.ZodArray) { + for (const element of Array.isArray(value) ? value : []) { + dropUnreadableOptionalFacts(schema.element, element, policies) + } + return + } + if (schema instanceof z.ZodUnion) { + for (const member of unionMembersFor(schema, value)) { + dropUnreadableOptionalFacts(member, value, policies) + } + return + } + if (!(schema instanceof z.ZodObject) || !isPlainObject(value)) { + return + } + for (const [key, field] of Object.entries(schema.shape)) { + if (value[key] === undefined) { + continue + } + const policy = POLICY_BY_FIELD.get(field) + if (policy !== 'droppable') { + dropUnreadableOptionalFacts(field, value[key], policies) + } + if (policy !== undefined && policies.has(policy) && !z.safeParse(field, value[key]).success) { + delete value[key] + } + } +} + +/** Paths of optional fields under `schema` that state no policy. Droppable facts are judged whole, + * so the fields inside one need none. */ +export function unclassifiedOptionalFacts(schema: z.core.$ZodType, path = ''): string[] { + if (schema instanceof z.ZodOptional || schema instanceof z.ZodNullable) { + return unclassifiedOptionalFacts(schema.unwrap(), path) + } + if (schema instanceof z.ZodArray) { + return unclassifiedOptionalFacts(schema.element, `${path}[]`) + } + if (schema instanceof z.ZodUnion) { + return schema.options.flatMap((option, index) => + unclassifiedOptionalFacts(option, `${path}|${index}`) + ) + } + if (!(schema instanceof z.ZodObject)) { + return [] + } + return Object.entries(schema.shape).flatMap(([key, field]) => { + const policy = POLICY_BY_FIELD.get(field) + const fieldPath = path ? `${path}.${key}` : key + if (policy === 'droppable') { + return [] + } + const inner = unclassifiedOptionalFacts(field, fieldPath) + return field instanceof z.ZodOptional && policy === undefined ? [fieldPath, ...inner] : inner + }) +} diff --git a/src/shared/agent-session-journal-schemas.ts b/src/shared/agent-session-journal-schemas.ts index 035bb7d1d01c..8f1c22cbcf00 100644 --- a/src/shared/agent-session-journal-schemas.ts +++ b/src/shared/agent-session-journal-schemas.ts @@ -12,9 +12,16 @@ // fields (roles, dispatch/tool states) stay type-checked, never enum-checked, // and unknown object keys pass — a same-version row written by a slightly // newer build must not be misread as malformed (see journal-row-schema.ts). +// +// What a persisted body this build cannot fully read means is decided per field, here: a body +// `kind` it does not know is a newer build's, so the chat reads read-only; every optional field +// is declared droppable or must-understand (agent-session-journal-optional-facts.ts); a required +// field that fails is damage. A new optional field picks its policy; changing what a field holds +// is a new key, never a new type under the old one. import { z } from 'zod' import { AgentSessionContextUsageSchema } from './agent-session-context-usage-schema' +import { droppableFact, mustUnderstandFact } from './agent-session-journal-optional-facts' import type { AgentJournalItemBody, AgentJournalMessageItem, @@ -37,10 +44,10 @@ const ProviderFrame = z.object({ }) const ToolMetadata = { - mcpIdentity: z.object({ server: z.string(), tool: z.string() }).optional(), - exitCode: z.number().int().optional(), - durationMs: z.number().nonnegative().optional(), - webSearchResults: z.array(z.object({ title: z.string(), url: z.string() })).optional() + mcpIdentity: droppableFact(z.object({ server: z.string(), tool: z.string() })), + exitCode: droppableFact(z.number().int()), + durationMs: droppableFact(z.number().nonnegative()), + webSearchResults: droppableFact(z.array(z.object({ title: z.string(), url: z.string() }))) } const KNOWN_BLOCK_TYPES = new Set([ @@ -63,9 +70,9 @@ const SubagentEntry = z.object({ id: z.string(), label: z.string(), state: z.string().min(1), - tokens: z.number().optional(), - startedAt: z.number().optional(), - settledAt: z.number().optional() + tokens: droppableFact(z.number()), + startedAt: droppableFact(z.number()), + settledAt: droppableFact(z.number()) }) /** Renderers select blocks by `type` equality and skip what they cannot draw, @@ -76,29 +83,29 @@ const Block = z.union([ z.object({ type: z.literal('text'), text: z.string(), - presentation: z.string().optional(), - tone: z.string().optional(), - providerFrame: ProviderFrame.optional() + presentation: droppableFact(z.string()), + tone: droppableFact(z.string()), + providerFrame: droppableFact(ProviderFrame) }), // `input: undefined` loses its key under JSON.stringify, so a persisted // canonical tool call may lack it entirely. z.object({ type: z.literal('tool-call'), name: z.string(), - input: z.unknown().optional(), - callId: ProviderCallId.optional(), + input: droppableFact(z.unknown()), + callId: droppableFact(ProviderCallId), ...ToolMetadata }), z.object({ type: z.literal('tool-result'), output: z.string(), - isError: z.boolean().optional() + isError: droppableFact(z.boolean()) }), z.object({ type: z.literal('image-ref'), - path: z.string().optional(), - url: z.string().optional(), - alt: z.string().optional() + path: droppableFact(z.string()), + url: droppableFact(z.string()), + alt: droppableFact(z.string()) }), z.object({ type: z.literal('subagent-group'), @@ -114,49 +121,46 @@ const Block = z.union([ kind: z.string().min(1), label: z.string(), state: z.string().min(1), - parentToolUseId: z.string().optional(), - summary: z.string().optional(), - error: z.string().optional(), - outputFile: z.string().optional(), - tokens: z.number().optional(), - startedAt: z.number().optional(), - settledAt: z.number().optional() + parentToolUseId: droppableFact(z.string()), + summary: droppableFact(z.string()), + error: droppableFact(z.string()), + outputFile: droppableFact(z.string()), + tokens: droppableFact(z.number()), + startedAt: droppableFact(z.number()), + settledAt: droppableFact(z.number()) }) ]), z.object({ type: z.string() }).refine((block) => !KNOWN_BLOCK_TYPES.has(block.type)) ]) -const PromptOption = z - .object({ - id: z.string(), - label: z.string(), - description: z.string().optional() - }) - .strict() +const PromptOption = z.object({ + id: z.string(), + label: z.string(), + description: droppableFact(z.string()) +}) -const Question = z - .object({ - id: z.string(), - question: z.string(), - header: z.string().optional(), - multiSelect: z.boolean(), - options: z.array(PromptOption), - freeTextQuestionId: z.string().optional() - }) - .strict() +const Question = z.object({ + id: z.string(), + question: z.string(), + header: droppableFact(z.string()), + multiSelect: z.boolean(), + options: z.array(PromptOption), + // Where an answer's free text goes: dropped, it would go under no question. + freeTextQuestionId: mustUnderstandFact(z.string()) +}) const Resolution = z.object({ state: z.string().min(1), selectedOptionId: z.string().nullable(), - answers: z - .array( + answers: droppableFact( + z.array( z.object({ questionId: z.string(), optionIds: z.array(z.string()), other: z.string().optional() }) ) - .optional(), + ), resolvedBy: z.string().nullable(), resolvedAt: z.number().nullable() }) @@ -170,16 +174,16 @@ const ApprovalMatchedAskRule = z.object({ const ApprovalSubject = z.object({ kind: z.literal('plan'), text: z.string().min(1), - filePath: z.string().optional() + filePath: droppableFact(z.string()) }) -const MessageBody = z.object({ +export const AgentJournalMessageBodySchema = z.object({ kind: z.literal('message'), role: z.string().min(1), blocks: z.array(Block), // Open like roles: a send mode a newer build writes must not turn the row malformed. - sentAs: z.string().min(1).optional(), - command: z.object({ name: z.string().min(1) }).optional() + sentAs: droppableFact(z.string().min(1)), + command: droppableFact(z.object({ name: z.string().min(1) })) }) const ThreadGoal = z.object({ @@ -210,27 +214,28 @@ const FailureFact = z.object({ }) export const AgentJournalItemBodySchema = z.discriminatedUnion('kind', [ - MessageBody, + AgentJournalMessageBodySchema, z.object({ kind: z.literal('tool-call'), ...ToolMetadata, name: z.string(), // See the tool-call block: the key itself is lost when `input` is undefined. - input: z.unknown().optional(), - callId: ProviderCallId.optional(), + input: droppableFact(z.unknown()), + callId: droppableFact(ProviderCallId), state: z.string().min(1), - output: BoundedPayload.optional() + output: droppableFact(BoundedPayload) }), z.object({ kind: z.literal('diff'), path: z.string(), patch: BoundedPayload }), z.object({ kind: z.literal('approval'), title: z.string(), - displayName: z.string().optional(), - description: z.string().optional(), - decisionReason: z.string().optional(), - blockedPath: z.string().optional(), - matchedAskRule: ApprovalMatchedAskRule.optional(), - subject: ApprovalSubject.optional(), + displayName: droppableFact(z.string()), + description: droppableFact(z.string()), + decisionReason: droppableFact(z.string()), + blockedPath: droppableFact(z.string()), + matchedAskRule: droppableFact(ApprovalMatchedAskRule), + // The plan being approved: approving without it would approve what the person never saw. + subject: mustUnderstandFact(ApprovalSubject), detail: z.string().nullable(), options: z.array(PromptOption), resolution: Resolution @@ -239,30 +244,33 @@ export const AgentJournalItemBodySchema = z.discriminatedUnion('kind', [ kind: z.literal('question'), question: z.string(), options: z.array(PromptOption), - questions: z.array(Question).optional(), - freeTextQuestionId: z.string().optional(), + // What an answer is shaped by: dropped, an answer would go to questions nobody was asked. + questions: mustUnderstandFact(z.array(Question)), + freeTextQuestionId: mustUnderstandFact(z.string()), resolution: Resolution }), z.object({ kind: z.literal('status'), text: z.string(), - presentation: z.string().optional(), - tone: z.string().optional(), - turnLifecycle: z - .object({ + presentation: droppableFact(z.string()), + tone: droppableFact(z.string()), + // A turn's start or end and a goal's change: the host settles turns and answers goal edits + // from them, so a fold without one would write from a wrong state. + turnLifecycle: mustUnderstandFact( + z.object({ turnId: z.string(), state: z.string().min(1), - outcome: z.string().min(1).optional(), - userItemId: z.string().min(1).optional(), - startedAt: z.number().finite().positive().optional(), - requestedAt: z.number().finite().positive().optional(), - completedAt: z.number().finite().positive().optional(), - durationMs: z.number().finite().nonnegative().optional() + outcome: droppableFact(z.string().min(1)), + userItemId: droppableFact(z.string().min(1)), + startedAt: droppableFact(z.number().finite().positive()), + requestedAt: droppableFact(z.number().finite().positive()), + completedAt: droppableFact(z.number().finite().positive()), + durationMs: droppableFact(z.number().finite().nonnegative()) }) - .optional(), - providerFrame: ProviderFrame.optional(), - threadGoal: ThreadGoalState.optional(), - failure: FailureFact.optional() + ), + providerFrame: droppableFact(ProviderFrame), + threadGoal: mustUnderstandFact(ThreadGoalState), + failure: droppableFact(FailureFact) }), z.object({ kind: z.literal('turn'), @@ -271,14 +279,14 @@ export const AgentJournalItemBodySchema = z.discriminatedUnion('kind', [ // Open like `state`: a verdict a newer build writes must not turn the row // malformed. `readAgentJournalTurnOutcome` is where an unplaceable one // becomes unknown rather than an arm a caller would act on. - outcome: z.string().min(1).optional(), - userItemId: z.string().min(1).optional(), - startedAt: z.number().finite().positive().optional(), - requestedAt: z.number().finite().positive().optional(), - completedAt: z.number().finite().positive().optional(), - durationMs: z.number().finite().nonnegative().optional(), - contextUsage: AgentSessionContextUsageSchema.optional(), - providerTurnId: z.string().min(1).optional() + outcome: droppableFact(z.string().min(1)), + userItemId: droppableFact(z.string().min(1)), + startedAt: droppableFact(z.number().finite().positive()), + requestedAt: droppableFact(z.number().finite().positive()), + completedAt: droppableFact(z.number().finite().positive()), + durationMs: droppableFact(z.number().finite().nonnegative()), + contextUsage: droppableFact(AgentSessionContextUsageSchema), + providerTurnId: droppableFact(z.string().min(1)) }) ]) @@ -345,7 +353,7 @@ export function isAdmissibleAgentJournalItemBody(value: unknown): value is Agent export function isAdmissibleAgentJournalMessageBody( value: unknown ): value is AgentJournalMessageItem { - return MessageBody.safeParse(value).success + return AgentJournalMessageBodySchema.safeParse(value).success } export function isAdmissibleAgentJournalRenderItem( @@ -366,7 +374,9 @@ export function isAdmissibleAgentJournalSubmission( type Admits = T export type CanonicalJournalTypesAreAdmissible = [ Admits ? true : false>, - Admits ? true : false>, + Admits< + AgentJournalMessageItem extends z.input ? true : false + >, Admits< AgentJournalRenderItem extends z.input ? true : false >, From 4a636dbce998b08f903cc44df6a35d4f17eb3967 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 00:18:11 -0700 Subject: [PATCH 02/52] feat(native-chat): a chat an older Orca keeps read-only says so before a send is refused A chat a newer Orca wrote opens read-only, and until now nothing said so: the person found out when a send failed with "Chats were saved by a newer Orca". The host now names the reason on every whole history page it serves (`page.readOnly: 'written-by-newer-orca'`: hydration, history and catch-up resets), the shared client reducer keeps it from the latest whole page, and the desktop status strip and the phone's composer area say "This chat was saved by a newer Orca, so it's read-only here. Update Orca to continue this chat." Every read-only latch the host has is a newer Orca's (its database, a row version, a row kind, a body kind or must-understand fact), so the reason is derived from the journal's latch, not stored. The field is optional: older clients ignore it (cross-version test against the newest release's reducer), and older hosts never send it, so a new client against one says nothing, as today. A reason this client does not know shows nothing rather than words that may be wrong. The window-merge helpers move out of the client reducer into their own module. --- .../MobileNativeChatComposerNotices.tsx | 46 ++++ .../src/session/MobileNativeChatOverlay.tsx | 1 + mobile/src/session/MobileNativeChatView.tsx | 18 +- ...bile-native-chat-read-only-notice.test.tsx | 120 ++++++++++ .../session/mobile-native-chat-view-styles.ts | 10 - .../mobile-structured-read-only-notice.ts | 13 ++ .../session/use-mobile-native-chat-session.ts | 2 + .../use-mobile-structured-agent-session.ts | 4 +- .../agent-session-history-page.ts | 17 +- .../agent-session-read-only-page.test.ts | 154 +++++++++++++ ...tructuredSession.read-only-notice.test.tsx | 61 +++++ ...tiveChatStructuredSession.test-harness.tsx | 4 + .../NativeChatStructuredSession.tsx | 1 + .../NativeChatStructuredSessionStatus.tsx | 16 ++ .../agent-session-write-notice-text.ts | 10 + .../use-structured-agent-session.ts | 2 + .../src/i18n/en-runtime-required.json | 2 + src/renderer/src/i18n/locales/en.json | 2 + src/renderer/src/i18n/locales/es.json | 2 + src/renderer/src/i18n/locales/fr.json | 2 + src/renderer/src/i18n/locales/ja.json | 2 + src/renderer/src/i18n/locales/ko.json | 2 + src/renderer/src/i18n/locales/zh.json | 2 + src/shared/agent-session-read-only.ts | 18 ++ src/shared/agent-session-wire.ts | 5 + src/shared/agent-session-write-notice-copy.ts | 2 + ...ed-agent-session-reducer.read-only.test.ts | 74 +++++++ .../structured-agent-session-reducer.ts | 74 +------ .../structured-agent-session-window-merge.ts | 69 ++++++ ...gent-session-read-only-notice.unit.test.ts | 209 ++++++++++++++++++ 30 files changed, 858 insertions(+), 86 deletions(-) create mode 100644 mobile/src/session/MobileNativeChatComposerNotices.tsx create mode 100644 mobile/src/session/mobile-native-chat-read-only-notice.test.tsx create mode 100644 mobile/src/session/mobile-structured-read-only-notice.ts create mode 100644 src/main/native-chat/agent-session-wire/agent-session-read-only-page.test.ts create mode 100644 src/renderer/src/components/native-chat/NativeChatStructuredSession.read-only-notice.test.tsx create mode 100644 src/shared/agent-session-read-only.ts create mode 100644 src/shared/structured-agent-session-reducer.read-only.test.ts create mode 100644 src/shared/structured-agent-session-window-merge.ts create mode 100644 tests/e2e/cross-version-wire/agent-session-read-only-notice.unit.test.ts diff --git a/mobile/src/session/MobileNativeChatComposerNotices.tsx b/mobile/src/session/MobileNativeChatComposerNotices.tsx new file mode 100644 index 000000000000..5626c7407755 --- /dev/null +++ b/mobile/src/session/MobileNativeChatComposerNotices.tsx @@ -0,0 +1,46 @@ +import { StyleSheet, Text, View } from 'react-native' +import { colors, spacing, typography } from '../theme/mobile-theme' + +/** The lines above the composer: why the host keeps this chat read-only, said before any send, + * and the latest send failure. */ +export function MobileNativeChatComposerNotices({ + readOnlyNotice, + sendErrorMessage +}: { + readOnlyNotice: string | null + sendErrorMessage: string | null | undefined +}): React.JSX.Element { + return ( + <> + {readOnlyNotice ? ( + + {readOnlyNotice} + + ) : null} + {sendErrorMessage ? ( + // This banner is the only channel for a send failure — announce it. + + {sendErrorMessage} + + ) : null} + + ) +} + +const styles = StyleSheet.create({ + notice: { + alignItems: 'center', + paddingHorizontal: spacing.md, + paddingBottom: spacing.xs + }, + readOnlyText: { + color: colors.textMuted, + fontSize: typography.metaSize, + textAlign: 'center' + }, + sendErrorText: { + color: colors.statusRed, + fontSize: typography.metaSize, + fontWeight: '600' + } +}) diff --git a/mobile/src/session/MobileNativeChatOverlay.tsx b/mobile/src/session/MobileNativeChatOverlay.tsx index 3650528d4660..2037b940c5aa 100644 --- a/mobile/src/session/MobileNativeChatOverlay.tsx +++ b/mobile/src/session/MobileNativeChatOverlay.tsx @@ -125,6 +125,7 @@ export function MobileNativeChatOverlay({ inputLockReason={inputLockReason} sendErrorMessage={sendErrorMessage} onClearSendError={onClearSendError} + readOnlyNotice={session.readOnlyNotice ?? null} filePaths={controller.nativeChatFilePaths} onNeedFiles={controller.loadNativeChatFiles} sessionOptions={controller.nativeChatSessionOptions} diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index 469abba53880..77386edefe4b 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -35,6 +35,7 @@ import { MobileAgentWorkingIndicator } from './MobileAgentWorkingIndicator' import type { PendingNativeChatImage } from './mobile-native-chat-image-attachment' import { MobileNativeChatComposer } from './MobileNativeChatComposer' import { MobileNativeChatPromptCard } from './MobileNativeChatPromptCard' +import { MobileNativeChatComposerNotices } from './MobileNativeChatComposerNotices' import { NO_QUEUED_SLOT, type MobileQueuedSlotProps } from './use-mobile-native-chat-queued-slot' import type { MobileChatPermission } from './mobile-native-chat-permission' import type { MobileChatQuestion } from './mobile-native-chat-question' @@ -109,6 +110,8 @@ type Props = MobileQueuedSlotProps & { sendErrorMessage?: string | null /** Clears `sendErrorMessage` once a later send is accepted. */ onClearSendError?: () => void + /** Why the host keeps this chat read-only, shown above the composer before any send. */ + readOnlyNotice?: string | null filePaths?: string[] onNeedFiles?: (query: string) => void /** Model/session-option pickers for the composer action row (desktop parity). */ @@ -178,6 +181,7 @@ export function MobileNativeChatView({ inputLockReason, sendErrorMessage, onClearSendError, + readOnlyNotice = null, filePaths, onNeedFiles, sessionOptions, @@ -417,16 +421,10 @@ export function MobileNativeChatView({ ) : null} - {sendErrorMessage ? ( - // This banner is the only channel for a send failure — announce it. - - {sendErrorMessage} - - ) : null} + ({ + getItem: vi.fn(async () => null), + setItem: vi.fn(async () => {}), + removeItem: vi.fn(async () => {}) +})) +vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage })) +vi.mock('react-native', async () => { + const React = await import('react') + return { + Text: ({ children, ...props }: { children?: ReactNode }) => + React.createElement('Text', props, children), + View: ({ children, ...props }: { children?: ReactNode }) => + React.createElement('View', props, children), + StyleSheet: { create: (styles: unknown) => styles } + } +}) + +import { MobileNativeChatComposerNotices } from './MobileNativeChatComposerNotices' +import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session' + +const NOTICE = + "This chat was saved by a newer Orca, so it's read-only here. Update Orca to continue this chat." + +function snapshot(readOnly?: 'written-by-newer-orca'): AgentSessionSubscribeEvent { + const page: AgentSessionHistoryPage = { + sessionId: 'session-1', + epoch: 'epoch-1', + fence: 3, + direction: 'tail', + items: [], + removedItemIds: [], + submissions: [], + window: { oldest: null, newest: null, nextCursor: { epoch: 'epoch-1', sequence: 0 } }, + liveCursor: { epoch: 'epoch-1', sequence: 0 }, + hasOlder: false, + hasNewer: false, + ...(readOnly ? { readOnly } : {}) + } + return { type: 'snapshot', sessionId: 'session-1', fence: 3, page } +} + +let renderer: ReactTestRenderer | null = null +let hook: ReturnType | null = null +let listener: ((value: unknown) => void) | null = null + +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the hook calls only sendRequest and subscribe on the client. +const client = { + sendRequest: vi.fn(async () => ({ ok: true, result: {}, _meta: { runtimeId: 'runtime-1' } })), + subscribe: vi.fn((_method: string, _params: unknown, onData: (value: unknown) => void) => { + listener = onData + return () => {} + }) +} as unknown as RpcClient + +function Harness(): null { + hook = useMobileStructuredAgentSession({ + client, + sessionId: 'session-1', + sourceIdentity: 'host-a\0workspace-a', + enabled: true, + connected: true, + hostSupport: null, + agent: 'codex', + onSendError: () => {} + }) + return null +} + +beforeEach(() => { + listener = null +}) + +afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + hook = null +}) + +it("words the host's read-only reason on the phone, and drops it when the host takes writes again", async () => { + act(() => { + renderer = create(createElement(Harness)) + }) + await vi.waitFor(() => expect(listener).not.toBeNull()) + act(() => listener?.(snapshot('written-by-newer-orca'))) + expect(hook?.session.readOnlyNotice).toBe(NOTICE) + act(() => listener?.(snapshot())) + expect(hook?.session.readOnlyNotice).toBeNull() +}) + +it('shows the notice above the composer as plain text, apart from a send failure', () => { + const rendered: { view?: ReactTestRenderer } = {} + act(() => { + rendered.view = create( + createElement(MobileNativeChatComposerNotices, { + readOnlyNotice: NOTICE, + sendErrorMessage: 'Your message was not sent.' + }) + ) + }) + const lines = (rendered.view?.root.findAll((node) => String(node.type) === 'View') ?? []).map( + (node) => [ + node.props.accessibilityRole, + node.findAll((child) => String(child.type) === 'Text')[0]?.props.children + ] + ) + expect(lines).toEqual([ + ['text', NOTICE], + ['alert', 'Your message was not sent.'] + ]) + act(() => rendered.view?.unmount()) +}) diff --git a/mobile/src/session/mobile-native-chat-view-styles.ts b/mobile/src/session/mobile-native-chat-view-styles.ts index 160477cfad80..a3e0d7093e46 100644 --- a/mobile/src/session/mobile-native-chat-view-styles.ts +++ b/mobile/src/session/mobile-native-chat-view-styles.ts @@ -29,16 +29,6 @@ export const styles = StyleSheet.create({ fontSize: typography.metaSize, fontWeight: '700' }, - sendError: { - alignItems: 'center', - paddingHorizontal: spacing.md, - paddingBottom: spacing.xs - }, - sendErrorText: { - color: colors.statusRed, - fontSize: typography.metaSize, - fontWeight: '600' - }, chromeToggle: { flexDirection: 'row', alignItems: 'center', diff --git a/mobile/src/session/mobile-structured-read-only-notice.ts b/mobile/src/session/mobile-structured-read-only-notice.ts new file mode 100644 index 000000000000..09a50b85cc8c --- /dev/null +++ b/mobile/src/session/mobile-structured-read-only-notice.ts @@ -0,0 +1,13 @@ +import { + agentSessionReadOnlyNoticeParts, + type AgentSessionReadOnlyReason +} from '../../../src/shared/agent-session-read-only' +import { agentSessionWriteNoticeEnglish } from '../../../src/shared/agent-session-refusal-notice' + +/** The phone's words for why the host keeps a chat read-only: desktop's sentences, in English. */ +export function mobileReadOnlyNotice( + reason: AgentSessionReadOnlyReason | undefined +): string | null { + const parts = agentSessionReadOnlyNoticeParts(reason) + return parts ? agentSessionWriteNoticeEnglish(parts) : null +} diff --git a/mobile/src/session/use-mobile-native-chat-session.ts b/mobile/src/session/use-mobile-native-chat-session.ts index 1f8bdae2d481..66564cf00252 100644 --- a/mobile/src/session/use-mobile-native-chat-session.ts +++ b/mobile/src/session/use-mobile-native-chat-session.ts @@ -41,6 +41,8 @@ export type MobileNativeChatSession = { loadingEarlier: boolean /** Grow the window to page in older history. */ loadEarlier: () => void + /** Why the host keeps this chat read-only, in words; absent on a chat that takes writes. */ + readOnlyNotice?: string | null } // Small first page for a fast first paint; grows by a page as the user scrolls. diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index 653d7fbaaa1a..c3078dd84737 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -30,6 +30,7 @@ import { requestMobileStructuredAgentSessionCancel } from './mobile-structured-agent-session-cancel' import { useMobileStructuredAgentMutate } from './use-mobile-structured-agent-mutation' +import { mobileReadOnlyNotice } from './mobile-structured-read-only-notice' import { useMobileStructuredSendWithOutcome, type StructuredMobileSendAttachment @@ -223,7 +224,8 @@ export function useMobileStructuredAgentSession(args: { error: state.error, hasMore: state.hasOlder, loadingEarlier: loadingOlder, - loadEarlier + loadEarlier, + readOnlyNotice: mobileReadOnlyNotice(state.readOnly) }, isWorking: isStructuredAgentSessionMainAgentWorking(turnId, state.submissions, state.fence), turnId, diff --git a/src/main/native-chat/agent-session-wire/agent-session-history-page.ts b/src/main/native-chat/agent-session-wire/agent-session-history-page.ts index 1fd376e2aae5..913bb4dae9cd 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-history-page.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-history-page.ts @@ -79,6 +79,21 @@ export function readAgentSessionHistory( * snapshot for the whole run so each page costs its own rows, not the timeline. */ snapshot: AgentJournalSnapshot = journal.snapshot(), scope: AgentSessionHistoryScope = 'every-agent' +): AgentSessionHistoryResult { + const result = readHistory(journal, request, snapshot, scope) + return { ...result, page: { ...result.page, ...readOnlyField(journal) } } +} + +/** Every read-only latch a journal has is a newer Orca's, so that is the reason it states. */ +function readOnlyField(journal: AgentSessionJournal): Pick { + return journal.isReadOnly ? { readOnly: 'written-by-newer-orca' } : {} +} + +function readHistory( + journal: AgentSessionJournal, + request: AgentSessionHistoryRequest, + snapshot: AgentJournalSnapshot, + scope: AgentSessionHistoryScope ): AgentSessionHistoryResult { if (journal.isReadOnly) { return historyReset(snapshot, 'schema_unreadable') @@ -145,7 +160,7 @@ export function readAgentSessionHydrationPage( journal: AgentSessionJournal, fence?: number ): AgentSessionHistoryPage { - return buildHydrationPage(journal.snapshot(), fence) + return { ...buildHydrationPage(journal.snapshot(), fence), ...readOnlyField(journal) } } function buildHydrationPage( diff --git a/src/main/native-chat/agent-session-wire/agent-session-read-only-page.test.ts b/src/main/native-chat/agent-session-wire/agent-session-read-only-page.test.ts new file mode 100644 index 000000000000..ead663be80bf --- /dev/null +++ b/src/main/native-chat/agent-session-wire/agent-session-read-only-page.test.ts @@ -0,0 +1,154 @@ +// The host says why it keeps a chat read-only on every whole page it serves, so a client can say +// it before a send is refused: a hydration page, a history page, and a catch-up's reset alike. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + AGENT_JOURNAL_THREAD_SCOPE, + AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + type AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { + closeTestJournalHostDatabase, + createTrackedJournalOpener, + insertTestJournalRowJson, + openTestJournalHostDatabase +} from '../agent-session-journal/journal-host-database-test-support' +import { agentSessionReadOnlyNoticeParts } from '../../../shared/agent-session-read-only' +import { agentSessionWriteNoticeEnglish } from '../../../shared/agent-session-refusal-notice' +import { + EMPTY_STRUCTURED_AGENT_SESSION, + reduceStructuredAgentSession +} from '../../../shared/structured-agent-session-reducer' +import { JOURNAL_DB_SCHEMA_VERSION } from '../agent-session-journal/journal-database-schema' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { + readAgentSessionHistory, + readAgentSessionHydrationPage +} from './agent-session-history-page' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-read-only-page', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } +} + +let root: string +const journals = createTrackedJournalOpener() + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-read-only-page-')) +}) + +afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +function open(): Promise { + return journals.open({ identity: IDENTITY, stateDirectory: root }) +} + +/** A chat of one item, reopened after a newer build appended `row` at the next sequence. */ +async function reopenedAfter(row: (epoch: string) => Record) { + const journal = await open() + await journal.appendItem( + { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 }, + { kind: 'status', text: 'before' }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + const seq = journal.cursor().sequence + 1 + const epoch = journal.epoch + await journals.closeAll() + const json = JSON.stringify({ ...row(epoch), epoch, seq, fence: 1, ts: 5_000 }) + insertTestJournalRowJson( + openTestJournalHostDatabase(root).db, + IDENTITY.sessionId, + seq, + json, + 5_000 + ) + closeTestJournalHostDatabase(root) + return open() +} + +/** Every whole page this host serves for the chat, as a client would ask for it. */ +function servedPages(journal: AgentSessionJournal) { + const tail = readAgentSessionHistory(journal, { + sessionId: IDENTITY.sessionId, + direction: 'tail' + }) + const after = readAgentSessionHistory(journal, { + sessionId: IDENTITY.sessionId, + direction: 'after', + cursor: { epoch: journal.epoch, sequence: 0 } + }) + return [readAgentSessionHydrationPage(journal, 1), tail.page, after.page] +} + +/** What a client attaching to the chat would say, desktop and phone alike: the hydration page as a + * snapshot through the shared client reducer, in the phone's English. */ +function attachedClientNotice(journal: AgentSessionJournal): string | null { + const state = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, { + type: 'event', + event: { + type: 'snapshot', + sessionId: IDENTITY.sessionId, + page: readAgentSessionHydrationPage(journal, 1), + fence: 1 + } + }) + const parts = agentSessionReadOnlyNoticeParts(state.readOnly) + return parts ? agentSessionWriteNoticeEnglish(parts) : null +} + +const NOTICE = + "This chat was saved by a newer Orca, so it's read-only here. Update Orca to continue this chat." + +const ITEM = { v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, kind: 'item', itemId: 'x', revision: 1 } + +describe('a chat the host keeps read-only', () => { + it.each([ + ['a newer row version', () => ({ ...ITEM, v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION + 1 })], + ['a newer row kind', () => ({ v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, kind: 'future-mark' })], + ['a newer body kind', () => ({ ...ITEM, body: { kind: 'plan-card', steps: [] } })] + ])('names a newer Orca on every page, for %s', async (_cause, row) => { + const journal = await reopenedAfter(row) + expect(journal.isReadOnly).toBe(true) + for (const page of servedPages(journal)) { + expect(page.readOnly).toBe('written-by-newer-orca') + } + expect(attachedClientNotice(journal)).toBe(NOTICE) + }) + + it('names a newer Orca on every page when a newer build stamped the whole database', async () => { + const journal = await reopenedAfter(() => ({ + ...ITEM, + body: { kind: 'status', text: 'later' } + })) + expect(journal.isReadOnly).toBe(false) + await journals.closeAll() + const opened = openTestJournalHostDatabase(root) + opened.db.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION + 1}`) + closeTestJournalHostDatabase(root) + const newer = await open() + expect(newer.isReadOnly).toBe(true) + for (const page of servedPages(newer)) { + expect(page.readOnly).toBe('written-by-newer-orca') + } + expect(attachedClientNotice(newer)).toBe(NOTICE) + }) +}) + +it('says nothing on a chat that takes writes', async () => { + const journal = await reopenedAfter(() => ({ ...ITEM, body: { kind: 'status', text: 'later' } })) + expect(journal.isReadOnly).toBe(false) + for (const page of servedPages(journal)) { + expect(page).not.toHaveProperty('readOnly') + } + expect(attachedClientNotice(journal)).toBeNull() +}) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.read-only-notice.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.read-only-notice.test.tsx new file mode 100644 index 000000000000..e62f7bd5243b --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.read-only-notice.test.tsx @@ -0,0 +1,61 @@ +// @vitest-environment happy-dom + +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' + +const { mocks, moduleFactories, resetStructuredSessionMocks } = await vi.hoisted(async () => + (await import('./NativeChatStructuredSession.test-harness')).createStructuredSessionMocks() +) + +vi.mock('@/runtime/structured-agent-session-client', () => + moduleFactories.structuredAgentSessionClient() +) +vi.mock('./use-structured-agent-session', () => moduleFactories.useStructuredAgentSession()) +vi.mock('./use-native-chat-font-scale', () => moduleFactories.useNativeChatFontScale()) +vi.mock('./use-native-chat-file-link-context', () => moduleFactories.useNativeChatFileLinkContext()) +vi.mock('./use-native-chat-file-link-click', () => moduleFactories.useNativeChatFileLinkClick()) +vi.mock('./NativeChatMessageList', () => moduleFactories.nativeChatMessageList()) +vi.mock('./NativeChatComposer', () => moduleFactories.nativeChatComposer()) +vi.mock('./NativeChatEmptyState', () => moduleFactories.nativeChatEmptyState()) +vi.mock('./NativeChatApprovalCard', () => moduleFactories.nativeChatApprovalCard()) +vi.mock('./NativeChatQuestionCard', () => moduleFactories.nativeChatQuestionCard()) + +import { NativeChatStructuredSession } from './NativeChatStructuredSession' + +afterEach(() => { + cleanup() + localStorage.clear() + resetStructuredSessionMocks() +}) + +const NOTICE = + "This chat was saved by a newer Orca, so it's read-only here. Update Orca to continue this chat." + +function renderSession() { + return render( + + ) +} + +it('says up front that a newer Orca saved the chat and that updating continues it', () => { + mocks.readOnly = 'written-by-newer-orca' + renderSession() + expect(screen.getByText(NOTICE).getAttribute('role')).toBe('status') +}) + +it('says nothing on a writable chat, or for a reason this client cannot word', () => { + renderSession() + expect(screen.queryByText(/saved by a newer Orca/)).toBeNull() + cleanup() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: stands in for an arm a newer host could publish, which this build's type cannot name. + mocks.readOnly = 'a-later-reason' as 'written-by-newer-orca' + renderSession() + expect(screen.queryByText(/saved by a newer Orca/)).toBeNull() +}) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx index 6e39ec0129de..b6c8db405d0f 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx @@ -5,6 +5,7 @@ import type { QueuedMessageCard } from './structured-agent-session-queued-cards' import type { AgentSessionBackgroundTask } from '../../../../shared/agent-session-wire' import type { AgentSessionWriteRefusal } from '../../../../shared/agent-session-write-failure' import type { AgentSessionRefusalReference } from '../../../../shared/agent-session-wire-refusals' +import type { AgentSessionReadOnlyReason } from '../../../../shared/agent-session-read-only' import type { NativeChatApprovalCardProps } from './NativeChatApprovalCard' import type { NativeChatDeliveryNotice } from './NativeChatMessageRow' import type { NativeChatQuestionCardProps } from './NativeChatQuestionCard' @@ -80,6 +81,7 @@ export function createStructuredSessionMocks() { mode: 'static' as 'static' | 'outbox', status: 'ready' as 'idle' | 'loading' | 'ready' | 'error', readRefusal: absent(), + readOnly: absent(), messages: null as null | unknown[], messageListProps: initialMessageListProps, composerProps: nullable<{ @@ -168,6 +170,7 @@ export function createStructuredSessionMocks() { status: mocks.status, error: outbox.error, readRefusal: mocks.readRefusal, + readOnly: mocks.readOnly, hasOlder: mocks.hasOlder, loadingOlder: mocks.loadingOlder, olderHistoryGeneration: mocks.olderHistoryGeneration, @@ -304,6 +307,7 @@ export function createStructuredSessionMocks() { mocks.mode = 'static' mocks.status = 'ready' mocks.readRefusal = undefined + mocks.readOnly = undefined mocks.messages = null mocks.messageListProps = null mocks.composerProps = null diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 6b79c8a7fc89..df9eaefda8a6 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -325,6 +325,7 @@ export function NativeChatStructuredSession( viewState.kind === 'error' || !readFailure?.named ? controller.error : readFailure.text } reconnecting={viewState.kind !== 'error' && readFailure !== null && !readFailure.named} + readOnly={controller.readOnly} composerError={composerError} isVisible={props.isVisible} backgroundTasks={controller.backgroundTasks} diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSessionStatus.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSessionStatus.tsx index c6f8316444f2..78e31349abfd 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSessionStatus.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSessionStatus.tsx @@ -4,6 +4,11 @@ import { useDelayedStatus } from '@/hooks/use-delayed-status' import { NativeChatBackgroundTasksStatus } from './NativeChatBackgroundTasksStatus' import type { StructuredSessionBackgroundTasksView } from './structured-session-background-tasks-view' import { useStructuredSessionChildRowContext } from './use-structured-session-child-row-context' +import { + agentSessionReadOnlyNoticeParts, + type AgentSessionReadOnlyReason +} from '../../../../shared/agent-session-read-only' +import { agentSessionWriteNoticeText } from './agent-session-write-notice-text' type StoppingBackgroundTasks = { sessionId: string @@ -28,6 +33,8 @@ export function NativeChatStructuredSessionStatus(props: { error: string | null /** A read the pane is reconnecting on its own: said plainly, not as an error. */ reconnecting?: boolean + /** Why the host keeps this chat read-only: said up front, before a send is refused. */ + readOnly?: AgentSessionReadOnlyReason composerError: string | null isVisible: boolean backgroundTasks: StructuredSessionBackgroundTasksView @@ -42,6 +49,7 @@ export function NativeChatStructuredSessionStatus(props: { SLOW_STARTUP_NOTICE_DELAY_MS ) const childRowContext = useStructuredSessionChildRowContext(props.paneKey) + const readOnlyParts = agentSessionReadOnlyNoticeParts(props.readOnly) const onStop = (taskId?: string) => { const sessionId = props.sessionId @@ -84,6 +92,14 @@ export function NativeChatStructuredSessionStatus(props: { )}

) : null} + {readOnlyParts ? ( +

+ {agentSessionWriteNoticeText(readOnlyParts)} +

+ ) : null} {props.reconnecting && !props.error ? (

{translate('components.native-chat.state.reconnecting', 'Reconnecting to this chat…')} diff --git a/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts b/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts index e584429ddfbf..e088e4d432c5 100644 --- a/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts +++ b/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts @@ -54,6 +54,16 @@ const SENTENCES: Record string> = { 'components.native-chat.writeNotice.updateOrcaToKeepUsing', COPY.updateOrcaToKeepUsing ), + chatReadOnlyNewerOrca: () => + translate( + 'components.native-chat.writeNotice.chatReadOnlyNewerOrca', + COPY.chatReadOnlyNewerOrca + ), + updateOrcaToContinueChat: () => + translate( + 'components.native-chat.writeNotice.updateOrcaToContinueChat', + COPY.updateOrcaToContinueChat + ), unsupported: () => translate('components.native-chat.writeNotice.unsupported', COPY.unsupported), unreachable: () => translate('components.native-chat.writeNotice.unreachable', COPY.unreachable), recordFailed: () => diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index 96ad805560d9..99be79f82a2d 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -206,6 +206,8 @@ export function useStructuredAgentSession(args: { error: outboxController.error, /** The refusal the failed read met, while `status` is `error`. */ readRefusal: transportEnabled ? state.readRefusal : undefined, + /** Why the host keeps this chat read-only; absent while it takes writes. */ + readOnly: transportEnabled ? state.readOnly : undefined, hasOlder: transportEnabled && state.hasOlder, railOutline: transportEnabled ? railOutline : null, loadingOlder: transportEnabled && loadingOlder, diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 72118c9ab68d..18b514c3eb82 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2838,6 +2838,7 @@ "backgroundTasksRunning": "Background tasks are still running.", "capacity": "Orca has received too many requests in the last day.", "chatNotFound": "The Orca running this chat has no record of it.", + "chatReadOnlyNewerOrca": "This chat was saved by a newer Orca, so it's read-only here.", "clearUnfinished": "The last /clear didn't finish.", "commandRunning": "A /compact or /clear is still running.", "conversationCleared": "This conversation has been cleared.", @@ -2876,6 +2877,7 @@ "turnActive": "The agent is still responding.", "unreachable": "Orca couldn't reach the agent.", "unsupported": "The Orca running this chat doesn't support this. Update Orca, then try again.", + "updateOrcaToContinueChat": "Update Orca to continue this chat.", "updateOrcaToKeepUsing": "Update Orca to keep using them.", "waitForBackgroundTasks": "Wait for the background tasks to finish.", "waitForCommand": "Wait for the /compact or /clear to finish.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 48f6eec3cb18..6a72ab3aa9b5 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17572,6 +17572,8 @@ "historyUnavailable": "Orca couldn't open this chat's history right now.", "savedByNewerOrca": "Chats were saved by a newer Orca.", "updateOrcaToKeepUsing": "Update Orca to keep using them.", + "chatReadOnlyNewerOrca": "This chat was saved by a newer Orca, so it's read-only here.", + "updateOrcaToContinueChat": "Update Orca to continue this chat.", "unsupported": "The Orca running this chat doesn't support this. Update Orca, then try again.", "unreachable": "Orca couldn't reach the agent.", "recordFailed": "Orca couldn't record it in this chat's history.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 84dffe050fdf..9b2940574360 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -15015,6 +15015,8 @@ "historyUnavailable": "Orca no pudo abrir el historial de este chat en este momento.", "savedByNewerOrca": "Los chats se guardaron con una versión más reciente de Orca.", "updateOrcaToKeepUsing": "Actualice Orca para seguir usándolos.", + "chatReadOnlyNewerOrca": "Este chat se guardó con una versión más reciente de Orca, así que aquí es de solo lectura.", + "updateOrcaToContinueChat": "Actualice Orca para continuar este chat.", "unsupported": "La versión de Orca que ejecuta este chat no lo admite. Actualice Orca y vuelva a intentarlo.", "unreachable": "Orca no pudo comunicarse con el agente.", "recordFailed": "Orca no pudo registrarlo en el historial de este chat.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index d8086183f219..16ddeb7527c1 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17481,6 +17481,8 @@ "historyUnavailable": "Orca n'a pas pu ouvrir l'historique de ce chat pour le moment.", "savedByNewerOrca": "Les chats ont été enregistrés par une version plus récente d'Orca.", "updateOrcaToKeepUsing": "Mettez à jour Orca pour continuer à les utiliser.", + "chatReadOnlyNewerOrca": "Ce chat a été enregistré par une version plus récente d'Orca ; il est donc en lecture seule ici.", + "updateOrcaToContinueChat": "Mettez à jour Orca pour poursuivre ce chat.", "unsupported": "L'instance d'Orca qui exécute ce chat ne le prend pas en charge. Mettez Orca à jour, puis réessayez.", "unreachable": "Orca n'a pas pu joindre l'agent.", "recordFailed": "Orca n'a pas pu l'enregistrer dans l'historique de ce chat.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 0997b8b616bb..93299d8d8238 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17417,6 +17417,8 @@ "historyUnavailable": "Orca は現在このチャットの履歴を開けませんでした。", "savedByNewerOrca": "チャットは新しいバージョンの Orca で保存されました。", "updateOrcaToKeepUsing": "引き続き使用するには、Orca を更新してください。", + "chatReadOnlyNewerOrca": "このチャットは新しいバージョンの Orca で保存されたため、ここでは読み取り専用です。", + "updateOrcaToContinueChat": "このチャットを続けるには、Orca を更新してください。", "unsupported": "このチャットを実行している Orca はこの操作に対応していません。Orca を更新してから、もう一度お試しください。", "unreachable": "Orca は Agent に接続できませんでした。", "recordFailed": "Orca はこのチャットの履歴に記録できませんでした。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 29149cd5a652..316e5a639098 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17417,6 +17417,8 @@ "historyUnavailable": "Orca가 지금 이 채팅의 기록을 열지 못했습니다.", "savedByNewerOrca": "채팅이 최신 버전의 Orca에서 저장되었습니다.", "updateOrcaToKeepUsing": "계속 사용하려면 Orca를 업데이트하세요.", + "chatReadOnlyNewerOrca": "이 채팅은 최신 버전의 Orca에서 저장되어 여기서는 읽기 전용입니다.", + "updateOrcaToContinueChat": "이 채팅을 계속하려면 Orca를 업데이트하세요.", "unsupported": "이 채팅을 실행 중인 Orca가 이 기능을 지원하지 않습니다. Orca를 업데이트한 후 다시 시도하세요.", "unreachable": "Orca가 에이전트에 연결하지 못했습니다.", "recordFailed": "Orca가 이 채팅의 기록에 저장하지 못했습니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index d907237dea88..9fd68ee72a19 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17382,6 +17382,8 @@ "historyUnavailable": "Orca 目前无法打开此聊天的历史记录。", "savedByNewerOrca": "这些聊天由更新版本的 Orca 保存。", "updateOrcaToKeepUsing": "请更新 Orca 以继续使用这些聊天。", + "chatReadOnlyNewerOrca": "此聊天由更新版本的 Orca 保存,因此在此处为只读。", + "updateOrcaToContinueChat": "请更新 Orca 以继续此聊天。", "unsupported": "运行此聊天的 Orca 不支持此操作。请更新 Orca 后重试。", "unreachable": "Orca 无法连接到智能体。", "recordFailed": "Orca 无法将其记录到此聊天的历史记录中。", diff --git a/src/shared/agent-session-read-only.ts b/src/shared/agent-session-read-only.ts new file mode 100644 index 000000000000..3500a19f7781 --- /dev/null +++ b/src/shared/agent-session-read-only.ts @@ -0,0 +1,18 @@ +// Why a host keeps a chat read-only, as it publishes it beside the chat's history, and the words a +// chat surface shows for it. The host owns the fact; a surface only says it. + +import type { AgentSessionWriteNoticeSentence } from './agent-session-write-notice-copy' + +/** Every latch a host has is a newer Orca's: its database, row version, row kind or content. */ +export const AGENT_SESSION_READ_ONLY_REASONS = ['written-by-newer-orca'] as const +export type AgentSessionReadOnlyReason = (typeof AGENT_SESSION_READ_ONLY_REASONS)[number] + +/** The notice for a reason this client knows; none for one a newer host names, which this client + * cannot word truthfully. */ +export function agentSessionReadOnlyNoticeParts( + reason: AgentSessionReadOnlyReason | undefined +): AgentSessionWriteNoticeSentence[] | null { + return reason === 'written-by-newer-orca' + ? ['chatReadOnlyNewerOrca', 'updateOrcaToContinueChat'] + : null +} diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts index 3100087faa11..97acd528bffa 100644 --- a/src/shared/agent-session-wire.ts +++ b/src/shared/agent-session-wire.ts @@ -38,6 +38,7 @@ import { type AgentSessionRecord } from './agent-session-record' import type { AgentProviderSessionMetadata } from './agent-session-resume' +import type { AgentSessionReadOnlyReason } from './agent-session-read-only' import type { NativeChatSubagentEntry } from './native-chat-types' import type { StructuredAgentSessionProjectedStatus } from './structured-agent-session-projection' @@ -133,6 +134,10 @@ export type AgentSessionHistoryPage = { /** Names the subagents with rows on the page whose roster row is older than it; bounded. * Absent from older hosts, and when every such roster row is on the page. */ subagentRoster?: AgentSessionSubagentRosterEntry[] + /** Present while the host keeps this chat read-only, and why: it serves the history and refuses + * every write. Absent on a writable chat and from older hosts, which say nothing until a write + * is refused. */ + readOnly?: AgentSessionReadOnlyReason } export type AgentSessionHistoryResult = diff --git a/src/shared/agent-session-write-notice-copy.ts b/src/shared/agent-session-write-notice-copy.ts index e5e6d011311b..15bb611b65fe 100644 --- a/src/shared/agent-session-write-notice-copy.ts +++ b/src/shared/agent-session-write-notice-copy.ts @@ -33,6 +33,8 @@ export const AGENT_SESSION_WRITE_NOTICE_COPY = { historyUnavailable: "Orca couldn't open this chat's history right now.", savedByNewerOrca: 'Chats were saved by a newer Orca.', updateOrcaToKeepUsing: 'Update Orca to keep using them.', + chatReadOnlyNewerOrca: "This chat was saved by a newer Orca, so it's read-only here.", + updateOrcaToContinueChat: 'Update Orca to continue this chat.', unsupported: "The Orca running this chat doesn't support this. Update Orca, then try again.", unreachable: "Orca couldn't reach the agent.", recordFailed: "Orca couldn't record it in this chat's history.", diff --git a/src/shared/structured-agent-session-reducer.read-only.test.ts b/src/shared/structured-agent-session-reducer.read-only.test.ts new file mode 100644 index 000000000000..cb388f73577d --- /dev/null +++ b/src/shared/structured-agent-session-reducer.read-only.test.ts @@ -0,0 +1,74 @@ +import { expect, it } from 'vitest' +import type { AgentSessionHistoryPage, AgentSessionSubscribeEvent } from './agent-session-wire' +import { + EMPTY_STRUCTURED_AGENT_SESSION, + reduceStructuredAgentSession, + type StructuredAgentSessionState +} from './structured-agent-session-reducer' + +const CURSOR = { epoch: 'epoch-a', sequence: 2 } + +function page(readOnly?: 'written-by-newer-orca'): AgentSessionHistoryPage { + return { + sessionId: 'session-a', + epoch: 'epoch-a', + direction: 'tail', + items: [], + removedItemIds: [], + submissions: [], + window: { oldest: null, newest: null, nextCursor: CURSOR }, + liveCursor: CURSOR, + hasOlder: false, + hasNewer: false, + ...(readOnly ? { readOnly } : {}) + } +} + +function apply(state: StructuredAgentSessionState, event: AgentSessionSubscribeEvent) { + return reduceStructuredAgentSession(state, { type: 'event', event }, 1) +} + +it("keeps the host's read-only reason from a whole page until a later whole page drops it", () => { + const snapshot = apply(EMPTY_STRUCTURED_AGENT_SESSION, { + type: 'snapshot', + sessionId: 'session-a', + page: page('written-by-newer-orca'), + fence: 1 + }) + expect(snapshot.readOnly).toBe('written-by-newer-orca') + + const batch = apply(snapshot, { + type: 'batch', + sessionId: 'session-a', + batch: { + cursor: { epoch: 'epoch-a', sequence: 3 }, + items: [], + removedItemIds: [], + submissions: [] + } + }) + expect(batch.readOnly).toBe('written-by-newer-orca') + + const reset = apply(batch, { + type: 'reset', + sessionId: 'session-a', + reset: 'schema_unreadable', + page: page('written-by-newer-orca'), + fence: 1 + }) + expect(reset.readOnly).toBe('written-by-newer-orca') + + // An updated host restarts and hydrates the client again, writable. + const writable = apply(reset, { + type: 'snapshot', + sessionId: 'session-a', + page: page(), + fence: 2 + }) + expect(writable).not.toHaveProperty('readOnly') + const history = reduceStructuredAgentSession(writable, { + type: 'history-page', + page: page('written-by-newer-orca') + }) + expect(history.readOnly).toBe('written-by-newer-orca') +}) diff --git a/src/shared/structured-agent-session-reducer.ts b/src/shared/structured-agent-session-reducer.ts index dde31b09418c..a69486c2e094 100644 --- a/src/shared/structured-agent-session-reducer.ts +++ b/src/shared/structured-agent-session-reducer.ts @@ -13,9 +13,9 @@ import type { AgentSessionTurnActivity } from './agent-session-wire' import type { AgentSessionRefusalReference } from './agent-session-wire-refusals' +import type { AgentSessionReadOnlyReason } from './agent-session-read-only' import { backgroundTaskStatesEqual } from './agent-session-background-task-state-equality' import { admitAgentSessionBackgroundTaskState } from './agent-session-background-task-state-admission' -import { agentJournalSubmissionKey } from './agent-session-journal-item-key' import { MAX_RETAINED_ITEMS, MAX_RETAINED_OWN_ITEMS, @@ -23,6 +23,11 @@ import { trimRetainedItems } from './structured-agent-session-item-retention' import { compareAgentJournalItems } from './agent-session-journal-position' +import { + liveItemsWithinWindow, + mergeItems, + mergeSubmissions +} from './structured-agent-session-window-merge' import { readAgentJournalTurn } from './agent-session-turn-record' import { foldStructuredAgentSubagentRoster, @@ -70,6 +75,9 @@ export type StructuredAgentSessionState = { /** Bumped per live batch that leaves a turn row's newest revision outside the window * (dropped or trimmed), so a whole-journal answer derived from turn rows is asked for again. */ unloadedTurnRevisions?: number + /** Why the host keeps this chat read-only, from its latest whole page; a host stays read-only + * until it restarts, which sends a new one. */ + readOnly?: AgentSessionReadOnlyReason } export type StructuredAgentSessionAction = @@ -79,8 +87,6 @@ export type StructuredAgentSessionAction = | { type: 'history-page'; page: AgentSessionHistoryPage } | { type: 'older-page'; requestedCursor: AgentJournalCursor; page: AgentSessionHistoryPage } -const MAX_RETAINED_SUBMISSIONS = 256 - export const EMPTY_STRUCTURED_AGENT_SESSION: StructuredAgentSessionState = { epoch: null, cursor: null, @@ -134,6 +140,7 @@ function replacePage( status: 'ready', subagentRoster: foldStructuredAgentSubagentRosterPage(undefined, page), activity: activity ?? null, + ...(page.readOnly !== undefined ? { readOnly: page.readOnly } : {}), ...(backgroundTasks !== undefined ? { backgroundTasks } : page.backgroundTasks !== undefined @@ -142,67 +149,6 @@ function replacePage( } } -function mergeItems( - current: readonly AgentJournalRenderItem[], - incoming: readonly AgentJournalRenderItem[], - removedIds: readonly string[] -): AgentJournalRenderItem[] { - const removed = new Set(removedIds) - const byId = new Map( - current.filter((item) => !removed.has(item.itemId)).map((item) => [item.itemId, item]) - ) - for (const item of incoming) { - const prior = byId.get(item.itemId) - if (!prior || item.revision >= prior.revision) { - byId.set(item.itemId, item) - } - } - return [...byId.values()].sort(compareAgentJournalItems) -} - -/** - * Live rows the loaded window can take. The window is a contiguous suffix of the - * journal, and its oldest row is the load-older anchor. A revision of a row older - * than the window keeps that row's original sequence, so admitting it would move - * the anchor below the window and paging `before` it would skip every row between. - * The journal keeps the revision; the page reader serves it once the window - * reaches the row. With nothing older on the host the window is the whole journal - * and a row below the head (a revived tombstone) leaves no hole, so it is admitted. - */ -function liveItemsWithinWindow( - state: StructuredAgentSessionState, - incoming: readonly AgentJournalRenderItem[] -): readonly AgentJournalRenderItem[] { - const head = state.items[0] - if (!head || !state.hasOlder) { - return incoming - } - return incoming.filter((item) => item.sequence >= head.sequence) -} - -function mergeSubmissions( - current: readonly AgentJournalSubmission[], - incoming: readonly AgentJournalSubmission[], - items: readonly AgentJournalRenderItem[] -): AgentJournalSubmission[] { - const byId = new Map(current.map((submission) => [submission.clientMessageId, submission])) - for (const submission of incoming) { - byId.set(submission.clientMessageId, submission) - } - const sorted = [...byId.values()].sort((left, right) => left.submittedAt - right.submittedAt) - const itemIds = new Set( - items - .filter((item) => item.body.kind === 'message' && item.body.role === 'user') - .map((item) => item.itemId) - ) - // Loaded user messages need their provider alias for durable turn attribution. - return sorted.filter( - (submission, index) => - index >= sorted.length - MAX_RETAINED_SUBMISSIONS || - itemIds.has(agentJournalSubmissionKey(submission.clientMessageId)) - ) -} - /** `receivedAt` is the client clock at apply time; callers pass it so the reducer stays pure. */ export function reduceStructuredAgentSession( state: StructuredAgentSessionState, diff --git a/src/shared/structured-agent-session-window-merge.ts b/src/shared/structured-agent-session-window-merge.ts new file mode 100644 index 000000000000..8c8770c1377e --- /dev/null +++ b/src/shared/structured-agent-session-window-merge.ts @@ -0,0 +1,69 @@ +// How a page or live batch merges into the items and submissions a client retains. + +import type { AgentJournalRenderItem, AgentJournalSubmission } from './agent-session-journal-types' +import { agentJournalSubmissionKey } from './agent-session-journal-item-key' +import { compareAgentJournalItems } from './agent-session-journal-position' +import type { StructuredAgentSessionState } from './structured-agent-session-reducer' + +const MAX_RETAINED_SUBMISSIONS = 256 + +export function mergeItems( + current: readonly AgentJournalRenderItem[], + incoming: readonly AgentJournalRenderItem[], + removedIds: readonly string[] +): AgentJournalRenderItem[] { + const removed = new Set(removedIds) + const byId = new Map( + current.filter((item) => !removed.has(item.itemId)).map((item) => [item.itemId, item]) + ) + for (const item of incoming) { + const prior = byId.get(item.itemId) + if (!prior || item.revision >= prior.revision) { + byId.set(item.itemId, item) + } + } + return [...byId.values()].sort(compareAgentJournalItems) +} + +/** + * Live rows the loaded window can take. The window is a contiguous suffix of the + * journal, and its oldest row is the load-older anchor. A revision of a row older + * than the window keeps that row's original sequence, so admitting it would move + * the anchor below the window and paging `before` it would skip every row between. + * The journal keeps the revision; the page reader serves it once the window + * reaches the row. With nothing older on the host the window is the whole journal + * and a row below the head (a revived tombstone) leaves no hole, so it is admitted. + */ +export function liveItemsWithinWindow( + state: StructuredAgentSessionState, + incoming: readonly AgentJournalRenderItem[] +): readonly AgentJournalRenderItem[] { + const head = state.items[0] + if (!head || !state.hasOlder) { + return incoming + } + return incoming.filter((item) => item.sequence >= head.sequence) +} + +export function mergeSubmissions( + current: readonly AgentJournalSubmission[], + incoming: readonly AgentJournalSubmission[], + items: readonly AgentJournalRenderItem[] +): AgentJournalSubmission[] { + const byId = new Map(current.map((submission) => [submission.clientMessageId, submission])) + for (const submission of incoming) { + byId.set(submission.clientMessageId, submission) + } + const sorted = [...byId.values()].sort((left, right) => left.submittedAt - right.submittedAt) + const itemIds = new Set( + items + .filter((item) => item.body.kind === 'message' && item.body.role === 'user') + .map((item) => item.itemId) + ) + // Loaded user messages need their provider alias for durable turn attribution. + return sorted.filter( + (submission, index) => + index >= sorted.length - MAX_RETAINED_SUBMISSIONS || + itemIds.has(agentJournalSubmissionKey(submission.clientMessageId)) + ) +} diff --git a/tests/e2e/cross-version-wire/agent-session-read-only-notice.unit.test.ts b/tests/e2e/cross-version-wire/agent-session-read-only-notice.unit.test.ts new file mode 100644 index 000000000000..9aa28b377bc5 --- /dev/null +++ b/tests/e2e/cross-version-wire/agent-session-read-only-notice.unit.test.ts @@ -0,0 +1,209 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { + AGENT_JOURNAL_THREAD_SCOPE, + AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + type AgentSessionJournalIdentity +} from '../../../src/shared/agent-session-journal-types' +import type { + AgentSessionHistoryPage, + AgentSessionSubscribeEvent +} from '../../../src/shared/agent-session-wire' +import { agentSessionReadOnlyNoticeParts } from '../../../src/shared/agent-session-read-only' +import { + EMPTY_STRUCTURED_AGENT_SESSION, + reduceStructuredAgentSession +} from '../../../src/shared/structured-agent-session-reducer' +import { + closeTestJournalHostDatabase, + createTrackedJournalOpener, + insertTestJournalRowJson, + liveTestJournalRows, + openTestJournalHostDatabase +} from '../../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' +import { readAgentSessionHydrationPage } from '../../../src/main/native-chat/agent-session-wire/agent-session-history-page' +import { + importReleaseCheckoutModule, + materializeReleaseCheckout, + resolveBaselineReleaseRef +} from './release-checkout' + +/** + * A host that keeps a chat read-only now says why on every whole page (`page.readOnly`), and this + * build keeps a body of a kind it does not know read-only instead of deleting from it. Both meet + * builds that predate them: an older client is sent the new field (Rule 1), and an older host + * opens a journal holding a newer body kind. + */ +const SUITE_TIMEOUT_MS = 180_000 +// A main build that shares this one's host database and schema version, so a downgrade to it opens +// the journal writable. No release tag has that database yet; move to the first one that does. +const WRITABLE_BASELINE_REF = '3727100cc9dbcea6201f8a3e506676a3c4b53b18' +const JOURNAL = 'src/main/native-chat/agent-session-journal' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-read-only-notice', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } +} + +type Reduce = (state: unknown, action: unknown, receivedAt?: number) => unknown + +let directory: string +const journals = createTrackedJournalOpener() + +beforeAll(() => { + directory = mkdtempSync(join(tmpdir(), 'orca-read-only-notice-xv-')) +}) + +afterAll(async () => { + await journals.closeAll() + rmSync(directory, { recursive: true, force: true }) +}) + +/** A function the checked-out build exports, typed as the caller calls it. */ +function releaseExport(module: Record, name: string): T { + const value = module[name] + if (value === undefined) { + throw new Error(`the checked-out build exports no ${name}`) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: an export the checked-out build defines; each caller names the shape it uses, and a changed one fails the test. + return value as T +} + +/** This build's journal of one item, then a body of a kind this build does not know: closed. */ +async function journalWithNewerBody(): Promise<{ rows: string[]; newer: string }> { + const journal = await journals.open({ identity: IDENTITY, stateDirectory: directory }) + await journal.appendItem( + { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 }, + { kind: 'status', text: 'before' }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + const seq = journal.cursor().sequence + 1 + const newer = JSON.stringify({ + v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + kind: 'item', + epoch: journal.epoch, + seq, + fence: 1, + ts: 2_000, + itemId: 'codex:thread-1:turn-1:1', + revision: 1, + body: { kind: 'plan-card', steps: [{ text: 'by a newer build' }] } + }) + await journals.closeAll() + insertTestJournalRowJson( + openTestJournalHostDatabase(directory).db, + IDENTITY.sessionId, + seq, + newer, + 2_000 + ) + return { rows: storedRows(), newer } +} + +function storedRows(): string[] { + const rows = liveTestJournalRows(openTestJournalHostDatabase(directory).db, IDENTITY.sessionId) + closeTestJournalHostDatabase(directory) + return rows.map((row) => row.rowJson) +} + +function frames(page: AgentSessionHistoryPage): AgentSessionSubscribeEvent[] { + return [ + { type: 'snapshot', sessionId: IDENTITY.sessionId, page, fence: 1 }, + { type: 'reset', sessionId: IDENTITY.sessionId, reset: 'schema_unreadable', page, fence: 1 } + ] +} + +describe('a chat a newer Orca saved, across versions', () => { + it( + 'old client against new host: the read-only page reduces exactly as the same page without the field', + async () => { + await journalWithNewerBody() + const journal = await journals.open({ identity: IDENTITY, stateDirectory: directory }) + expect(journal.isReadOnly).toBe(true) + const page = readAgentSessionHydrationPage(journal, 1) + // The payload under test carries the field, or this compares nothing. + expect(page.readOnly).toBe('written-by-newer-orca') + const { readOnly: _field, ...withoutField } = page + await journals.closeAll() + + const checkout = await materializeReleaseCheckout(resolveBaselineReleaseRef()) + const reducer = await importReleaseCheckoutModule( + checkout, + 'src/shared/structured-agent-session-reducer.ts' + ) + const reduce = releaseExport(reducer, 'reduceStructuredAgentSession') + const empty = releaseExport(reducer, 'EMPTY_STRUCTURED_AGENT_SESSION') + for (const [index, frame] of frames(page).entries()) { + const reference = frames(withoutField)[index] + expect(reduce(empty, { type: 'event', event: frame }, 1), frame.type).toEqual( + reduce(empty, { type: 'event', event: reference }, 1) + ) + } + }, + SUITE_TIMEOUT_MS + ) + + it('new client against old host: a page with no field says nothing', () => { + const page: AgentSessionHistoryPage = { + sessionId: IDENTITY.sessionId, + epoch: 'epoch-1', + direction: 'tail', + items: [], + removedItemIds: [], + submissions: [], + window: { oldest: null, newest: null, nextCursor: { epoch: 'epoch-1', sequence: 0 } }, + hasOlder: false, + hasNewer: false + } + for (const frame of frames(page)) { + const state = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, { + type: 'event', + event: frame + }) + expect(agentSessionReadOnlyNoticeParts(state.readOnly), frame.type).toBeNull() + } + }) + + // Why a new body kind ships its reader first, or rides a bumped `v`: this build keeps one and goes + // read-only, but a build from before deletes the journal from it. Move the pinned build to the + // first release with this rule, and the older build keeps the row too. + it( + "this build keeps a newer build's body kind and goes read-only; a build before it deletes it", + async () => { + rmSync(directory, { recursive: true, force: true }) + directory = mkdtempSync(join(tmpdir(), 'orca-read-only-notice-xv-')) + const { rows, newer } = await journalWithNewerBody() + const reopened = await journals.open({ identity: IDENTITY, stateDirectory: directory }) + expect(reopened.isReadOnly).toBe(true) + await journals.closeAll() + expect(storedRows()).toEqual(rows) + + const checkout = await materializeReleaseCheckout(WRITABLE_BASELINE_REF) + const support = await importReleaseCheckoutModule( + checkout, + `${JOURNAL}/journal-host-database-test-support.ts` + ) + const older = releaseExport< + () => { + open: (options: { + identity: AgentSessionJournalIdentity + stateDirectory: string + }) => Promise + closeAll: () => Promise + } + >(support, 'createTrackedJournalOpener')() + try { + await older.open({ identity: IDENTITY, stateDirectory: directory }) + } finally { + await older.closeAll() + } + expect(storedRows()).not.toContain(newer) + }, + SUITE_TIMEOUT_MS + ) +}) From e40377cbbf6f25b4f06ab87c01780c535bc36f5f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 00:50:42 -0700 Subject: [PATCH 03/52] fix(native-chat): only a newer build's value goes read-only; damage repairs as before An older Orca keeps a chat read-only only on evidence a newer build wrote it: a value outside a closed set this build knows (a body kind, a nested discriminant or literal, a mutation kind), read off zod's issues. That evidence wins over damage beside it. Anything else that fails (a wrong type, a missing field, a bad optional value) is damage, repaired as before, so damage no longer freezes a chat behind an "update Orca" it cannot fix. Drops the per-field droppable/must-understand policy. A lifecycle mutation's kind is decided before its item id, so a newer kind without one is kept. The context-usage drop is unchanged from main. --- .../journal-row-schema.ts | 164 +++++--------- .../journal-row-unusable-annotations.ts | 83 +++++++ .../journal-unknown-content.test.ts | 162 ++++++++------ ...ent-session-journal-body-admission.test.ts | 207 ++++++++---------- .../agent-session-journal-body-admission.ts | 85 ++++--- .../agent-session-journal-optional-facts.ts | 130 ----------- src/shared/agent-session-journal-schemas.ts | 157 +++++++------ 7 files changed, 446 insertions(+), 542 deletions(-) create mode 100644 src/main/native-chat/agent-session-journal/journal-row-unusable-annotations.ts delete mode 100644 src/shared/agent-session-journal-optional-facts.ts diff --git a/src/main/native-chat/agent-session-journal/journal-row-schema.ts b/src/main/native-chat/agent-session-journal/journal-row-schema.ts index 5d148d3a0219..59fe2ed18021 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-schema.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-schema.ts @@ -13,12 +13,11 @@ // reader first and is written only once no supported build lacks that reader, // or is written at a bumped `v`. // -// Inside a known row the same holds a level down. A body or lifecycle mutation of a kind this -// build does not know is UNREADABLE. An optional body field it cannot parse is dropped or makes -// the row UNREADABLE, as the field declares (agent-session-journal-schemas.ts). Only a required -// field that fails is damage, which the open repairs. Builds from before this rule delete the -// journal from all three, so the same reader-first-or-`v` rule applies to a new body kind, and an -// optional field never changes type: a new meaning is a new key. +// Inside a known row the same holds a level down. A lifecycle mutation of a kind this build does +// not know, or a body holding a value outside a closed set it knows (a body kind, a nested +// discriminant, a literal), is UNREADABLE, whatever else in the row fails. Any other failure is +// damage, which the open repairs. Builds from before this rule delete the journal from such a +// row, so the same reader-first-or-`v` rule applies to a new value in a closed set. import type { AgentSessionFailureFact } from '../../../shared/agent-session-failure' import { @@ -36,6 +35,7 @@ import { type AgentJournalContentVerdict } from '../../../shared/agent-session-journal-body-admission' import type { StructuredAgentSessionStopCause } from '../agent-session-wire/structured-agent-session-stop-cause' +import { dropUnusableRowAnnotations } from './journal-row-unusable-annotations' /** Producer linkage rides the row BASE rather than the body: older builds read * the two nested prompt shapes strictly, so an unknown key there made the whole @@ -226,60 +226,11 @@ export function parseJournalRow(line: string): JournalRowParse { return { ok: false, unreadable: true } } const upcast = upcastRow(record, version) - dropUnusableProducerLinkage(upcast) - dropUnusableTurnScope(upcast) - if (upcast.kind === 'lifecycle-batch' && Array.isArray(upcast.mutations)) { - for (const mutation of upcast.mutations) { - if (isPlainObject(mutation)) { - dropUnusableProducerLinkage(mutation) - dropUnusableTurnScope(mutation) - } - } - } - const content = journalRowContent(upcast) - if (isReadableJournalRow(upcast, content)) { + dropUnusableRowAnnotations(upcast) + if (isJournalRow(upcast)) { return { ok: true, row: upcast } } - return { ok: false, unreadable: content === 'unreadable' } -} - -/** Linkage ids this build cannot trust, removed from a row it still keeps. - * - * Deliberately NOT part of `journalRowContent`: rejecting a row there drops it from - * the timeline, so a validator tightened against one bad field becomes a - * whole-store kill switch. Dropping the field degrades the row to the - * session's own agent — what every row said before linkage existed — while - * keeping the content, which is always the safer direction. An `agentId` that - * survives is a real one: the reader scopes on PRESENCE, so `''` or a - * non-string left in place would hide the row from its own author for good. */ -function dropUnusableProducerLinkage(record: Record): void { - for (const field of ['agentId', 'parentAgentId', 'providerParentRef', 'producerKind']) { - const value = record[field] - if (value !== undefined && (typeof value !== 'string' || value.length === 0)) { - delete record[field] - } - } - if (record.attempt !== undefined && !Number.isInteger(record.attempt)) { - delete record.attempt - } -} - -/** A scope this build cannot place, removed like unusable linkage: the row then reads as one - * written before scopes existed, and the reducer derives its scope. */ -function dropUnusableTurnScope(record: Record): void { - const scope = record.turnScope - if ( - scope !== undefined && - !( - isPlainObject(scope) && - (scope.kind === 'thread' || - (scope.kind === 'turn' && - typeof scope.turnItemId === 'string' && - scope.turnItemId.length > 0)) - ) - ) { - delete record.turnScope - } + return { ok: false, unreadable: journalRowContent(upcast) === 'unreadable' } } /** Read-time upcast chain. Each step raises a row exactly one version. */ @@ -317,20 +268,20 @@ function journalRowContent(record: Record): AgentJournalContent return typeof kind === 'string' && kind !== '' ? 'unreadable' : 'malformed' } -/** Narrows a record by the verdict `journalRowContent` gave that same record. */ -function isReadableJournalRow( - _record: Record, - content: AgentJournalContentVerdict -): _record is JournalRow { - return content === 'readable' +function isJournalRow(record: Record): record is JournalRow { + return journalRowContent(record) === 'readable' +} + +/** A newer build's content anywhere wins over damage beside it: never delete what it wrote. */ +function combinedContent(...verdicts: AgentJournalContentVerdict[]): AgentJournalContentVerdict { + if (verdicts.includes('unreadable')) { + return 'unreadable' + } + return verdicts.includes('malformed') ? 'malformed' : 'readable' } -/** Fields first: a body is read only once the fields that place it hold. */ -function contentWhen( - fields: boolean, - content: () => AgentJournalContentVerdict -): AgentJournalContentVerdict { - return fields ? content() : 'malformed' +function fieldsContent(hold: boolean): AgentJournalContentVerdict { + return hold ? 'readable' : 'malformed' } /** Each kind's own fields, keyed by every kind the union holds: a kind without a check here fails @@ -340,47 +291,46 @@ const ROW_CONTENT_CHECK_BY_KIND: Record< (record: Record) => AgentJournalContentVerdict > = { epoch: (record) => - contentWhen( - typeof record.reason === 'string' && isPlainObject(record.providerHandle), - () => 'readable' - ), + fieldsContent(typeof record.reason === 'string' && isPlainObject(record.providerHandle)), item: (record) => - contentWhen(typeof record.itemId === 'string' && Number.isInteger(record.revision), () => + combinedContent( + fieldsContent(typeof record.itemId === 'string' && Number.isInteger(record.revision)), readAgentJournalItemBody(record.body) ), tombstone: (record) => - contentWhen( - typeof record.itemId === 'string' && Number.isInteger(record.revision), - () => 'readable' - ), + fieldsContent(typeof record.itemId === 'string' && Number.isInteger(record.revision)), submission: (record) => - contentWhen( - typeof record.clientMessageId === 'string' && - record.clientMessageId.length > 0 && - typeof record.payloadFingerprint === 'string' && - isPlainObject(record.providerHandle), - () => readAgentJournalMessageBody(record.body) + combinedContent( + fieldsContent( + typeof record.clientMessageId === 'string' && + record.clientMessageId.length > 0 && + typeof record.payloadFingerprint === 'string' && + isPlainObject(record.providerHandle) + ), + readAgentJournalMessageBody(record.body) ), dispatch: (record) => - contentWhen( + fieldsContent( typeof record.clientMessageId === 'string' && record.clientMessageId.length > 0 && typeof record.state === 'string' && record.state.length > 0 && (record.providerItemId === null || typeof record.providerItemId === 'string') && - (record.reason === null || typeof record.reason === 'string'), - () => 'readable' + (record.reason === null || typeof record.reason === 'string') ), - 'lifecycle-batch': (record) => - contentWhen( - typeof record.settlementId === 'string' && - record.settlementId.length > 0 && - Array.isArray(record.mutations) && - record.mutations.length > 0 && - record.mutations.length <= MAX_JOURNAL_LIFECYCLE_BATCH_MUTATIONS && - Buffer.byteLength(JSON.stringify(record), 'utf8') + 1 <= MAX_JOURNAL_LIFECYCLE_BATCH_BYTES, - () => lifecycleMutationsContent(record.mutations) + 'lifecycle-batch': (record) => { + const mutations = Array.isArray(record.mutations) ? record.mutations : [] + return combinedContent( + fieldsContent( + typeof record.settlementId === 'string' && + record.settlementId.length > 0 && + mutations.length > 0 && + mutations.length <= MAX_JOURNAL_LIFECYCLE_BATCH_MUTATIONS && + Buffer.byteLength(JSON.stringify(record), 'utf8') + 1 <= MAX_JOURNAL_LIFECYCLE_BATCH_BYTES + ), + ...mutations.map(lifecycleMutationContent) ) + } } const KNOWN_ROW_KINDS = new Map(Object.entries(ROW_CONTENT_CHECK_BY_KIND)) @@ -402,22 +352,18 @@ const MUTATION_CONTENT_CHECK_BY_KIND: Record< (mutation: Record) => AgentJournalContentVerdict > = { item: (mutation) => - contentWhen(Number.isInteger(mutation.revision), () => readAgentJournalItemBody(mutation.body)), - tombstone: (mutation) => contentWhen(Number.isInteger(mutation.revision), () => 'readable') + combinedContent( + fieldsContent(typeof mutation.itemId === 'string' && Number.isInteger(mutation.revision)), + readAgentJournalItemBody(mutation.body) + ), + tombstone: (mutation) => + fieldsContent(typeof mutation.itemId === 'string' && Number.isInteger(mutation.revision)) } const KNOWN_MUTATION_KINDS = new Map(Object.entries(MUTATION_CONTENT_CHECK_BY_KIND)) -/** Damage anywhere is damage; otherwise a mutation a newer build wrote makes the batch unreadable. */ -function lifecycleMutationsContent(mutations: unknown): AgentJournalContentVerdict { - const verdicts = (Array.isArray(mutations) ? mutations : []).map(lifecycleMutationContent) - if (verdicts.includes('malformed')) { - return 'malformed' - } - return verdicts.includes('unreadable') ? 'unreadable' : 'readable' -} - +/** The kind first: a newer build's kind needs none of the fields this build's kinds have. */ function lifecycleMutationContent(value: unknown): AgentJournalContentVerdict { - if (!isPlainObject(value) || typeof value.itemId !== 'string') { + if (!isPlainObject(value)) { return 'malformed' } const { kind } = value diff --git a/src/main/native-chat/agent-session-journal/journal-row-unusable-annotations.ts b/src/main/native-chat/agent-session-journal/journal-row-unusable-annotations.ts new file mode 100644 index 000000000000..6c8811eb4a5b --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-row-unusable-annotations.ts @@ -0,0 +1,83 @@ +// Annotations on a persisted row this build cannot use, removed from a row it still keeps, before +// the row's content is checked (journal-row-schema.ts). + +import { isAdmissibleAgentSessionContextUsage } from '../../../shared/agent-session-context-usage-schema' + +export function dropUnusableRowAnnotations(record: Record): void { + dropUnusableProducerLinkage(record) + dropUnusableTurnScope(record) + if (record.kind === 'lifecycle-batch' && Array.isArray(record.mutations)) { + for (const mutation of record.mutations) { + if (isPlainObject(mutation)) { + dropUnusableProducerLinkage(mutation) + dropUnusableTurnScope(mutation) + } + } + } + dropUnusableContextUsage(record) +} + +/** Linkage ids this build cannot trust, removed from a row it still keeps. + * + * Deliberately NOT part of `isJournalRow`: rejecting a row there drops it from + * the timeline, so a validator tightened against one bad field becomes a + * whole-store kill switch. Dropping the field degrades the row to the + * session's own agent — what every row said before linkage existed — while + * keeping the content, which is always the safer direction. An `agentId` that + * survives is a real one: the reader scopes on PRESENCE, so `''` or a + * non-string left in place would hide the row from its own author for good. */ +function dropUnusableProducerLinkage(record: Record): void { + for (const field of ['agentId', 'parentAgentId', 'providerParentRef', 'producerKind']) { + const value = record[field] + if (value !== undefined && (typeof value !== 'string' || value.length === 0)) { + delete record[field] + } + } + if (record.attempt !== undefined && !Number.isInteger(record.attempt)) { + delete record.attempt + } +} + +/** A scope this build cannot place, removed like unusable linkage: the row then reads as one + * written before scopes existed, and the reducer derives its scope. */ +function dropUnusableTurnScope(record: Record): void { + const scope = record.turnScope + if ( + scope !== undefined && + !( + isPlainObject(scope) && + (scope.kind === 'thread' || + (scope.kind === 'turn' && + typeof scope.turnItemId === 'string' && + scope.turnItemId.length > 0)) + ) + ) { + delete record.turnScope + } +} + +/** Context facts this build cannot read, removed from the turn row that carries + * them. Same reasoning as linkage: they are an annotation on the turn, and + * rejecting the row for them would truncate the journal from that row on. */ +function dropUnusableContextUsage(record: Record): void { + const bodies = [ + record.kind === 'item' ? record.body : undefined, + ...(record.kind === 'lifecycle-batch' && Array.isArray(record.mutations) + ? record.mutations.map((mutation) => (isPlainObject(mutation) ? mutation.body : undefined)) + : []) + ] + for (const body of bodies) { + if ( + isPlainObject(body) && + body.kind === 'turn' && + body.contextUsage !== undefined && + !isAdmissibleAgentSessionContextUsage(body.contextUsage) + ) { + delete body.contextUsage + } + } +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/src/main/native-chat/agent-session-journal/journal-unknown-content.test.ts b/src/main/native-chat/agent-session-journal/journal-unknown-content.test.ts index 0dfd48076917..49906af2d5d7 100644 --- a/src/main/native-chat/agent-session-journal/journal-unknown-content.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-unknown-content.test.ts @@ -1,7 +1,7 @@ -// Inside a row of a known kind, what this build cannot read is never repaired away unless it is -// damage: a body or mutation of a newer kind, or a must-understand fact it cannot parse, latches -// the chat read-only with every row kept as it was; an annotation it cannot parse costs only that -// annotation. A required field that fails is still damage, repaired as before. +// Inside a row of a known kind, what a newer build wrote is never repaired away: a mutation of a +// newer kind, or a body holding a value outside a closed set this build knows, latches the chat +// read-only with every row kept as it was, even beside damage. Anything else that fails is damage, +// repaired as before. import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -88,7 +88,27 @@ function itemRow(epoch: string, itemId: string, body: Record) { } } +function batchRow(epoch: string, mutations: Record[]) { + return { + v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + epoch, + fence: 1, + ts: 5_000, + kind: 'lifecycle-batch', + settlementId: 'settle-1', + mutations + } +} + const NEWER_BODY = { kind: 'plan-card', steps: [{ text: 'by a newer build' }] } +const PLAN_APPROVAL = { + kind: 'approval', + title: 'Approve the plan?', + detail: null, + options: [{ id: 'yes', label: 'Yes' }], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null }, + subject: { kind: 'plan', text: 'Step one' } +} async function expectReadOnlyAndKept(written: { seq: number; rowJson: string }[]) { const journal = await open() @@ -121,23 +141,15 @@ describe("a newer build's content inside a known row", () => { ] ])('latches read-only on a lifecycle mutation %s', async (_name, mutation) => { const { written } = await journalWith((epoch) => [ - { - v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, - epoch, - fence: 1, - ts: 5_000, - kind: 'lifecycle-batch', - settlementId: 'settle-1', - mutations: [ - { - kind: 'item', - itemId: 'codex:thread-1:turn-1:3', - revision: 1, - body: { kind: 'status', text: 'ok' } - }, - mutation - ] - } + batchRow(epoch, [ + { + kind: 'item', + itemId: 'codex:thread-1:turn-1:3', + revision: 1, + body: { kind: 'status', text: 'ok' } + }, + mutation + ]) ]) await expectReadOnlyAndKept(written) }) @@ -159,61 +171,77 @@ describe("a newer build's content inside a known row", () => { await expectReadOnlyAndKept(written) }) - it('latches read-only on a must-understand fact it cannot parse', async () => { + it('latches read-only on a newer value of a nested closed set', async () => { const { written } = await journalWith((epoch) => [ - itemRow(epoch, 'codex:thread-1:turn-1:2', { - kind: 'status', - text: 'Turn started', - turnLifecycle: { turnId: 7, state: 'running' } - }) + itemRow(epoch, 'codex:thread-1:turn-1:2', { ...PLAN_APPROVAL, subject: { kind: 'diff' } }) ]) await expectReadOnlyAndKept(written) }) -}) -describe('an annotation this build cannot parse', () => { - it('costs only the annotation: the chat stays writable, nothing is deleted or rewritten', async () => { + it('latches read-only on a newer mutation kind that carries no item id', async () => { const { written } = await journalWith((epoch) => [ - itemRow(epoch, 'codex:thread-1:turn-1:2', { - kind: 'turn', - turnId: 'turn-1', - state: 'done', - durationMs: null, - outcome: 3 - }) + batchRow(epoch, [{ kind: 'turn-settle', turnId: 'turn-1', outcome: 'done' }]) ]) - const journal = await open() - expect(journal.isReadOnly).toBe(false) - expect(journal.repair).toEqual({ malformedRows: 0 }) - const items = journal.snapshot().items - expect(items.map((entry) => entry.itemId)).toEqual([ - 'codex:thread-1:turn-1:0', - 'codex:thread-1:turn-1:1', - 'codex:thread-1:turn-1:2', - 'codex:thread-1:turn-1:9' - ]) - expect(items[2]?.body).toEqual({ kind: 'turn', turnId: 'turn-1', state: 'done' }) - await journals.closeAll() - expect(stored()).toEqual(written) + await expectReadOnlyAndKept(written) }) -}) -describe('a required field that fails', () => { - it('is still damage: the open deletes from that row and discloses the repair', async () => { - const { written } = await journalWith((epoch) => [ - itemRow(epoch, 'codex:thread-1:turn-1:2', { - kind: 'diff', - path: 'a.ts', - patch: { head: 'x' } + it.each([ + [ + 'a body', + (epoch: string) => + itemRow(epoch, 'codex:thread-1:turn-1:2', { + ...PLAN_APPROVAL, + title: 5, + subject: { kind: 'diff' } + }) + ], + [ + 'a row', + (epoch: string) => ({ + ...itemRow(epoch, 'codex:thread-1:turn-1:2', NEWER_BODY), + revision: 'x' }) - ]) - const journal = await open() - expect(journal.isReadOnly).toBe(false) - expect(journal.repair).toEqual({ malformedRows: 1 }) - await journals.closeAll() - const after = stored() - expect(after.slice(0, 3)).toEqual(written.slice(0, 3)) - expect(after.some((row) => row.rowJson.includes('"diff"'))).toBe(false) - expect(after.some((row) => row.rowJson.includes('"after"'))).toBe(false) + ], + [ + 'a batch', + (epoch: string) => + batchRow(epoch, [ + { kind: 'item', itemId: 'codex:thread-1:turn-1:3', revision: 1, body: { kind: 'diff' } }, + { kind: 'pin' } + ]) + ] + ])('latches read-only when damage sits beside newer content in %s', async (_where, row) => { + const { written } = await journalWith((epoch) => [row(epoch)]) + await expectReadOnlyAndKept(written) }) }) + +describe('damage', () => { + it.each([ + ['a broken required field', { kind: 'diff', path: 'a.ts', patch: { head: 'x' } }], + [ + 'a broken optional value', + { kind: 'turn', turnId: 'turn-1', state: 'done', durationMs: null } + ], + [ + 'a turn lifecycle whose turn is a number', + { kind: 'status', text: 'Turn started', turnLifecycle: { turnId: 7, state: 'running' } } + ], + ['an empty plan', { ...PLAN_APPROVAL, subject: { kind: 'plan', text: '' } }] + ])( + 'is repaired as before, from %s: the open deletes from that row and says so', + async (_name, body) => { + const { written } = await journalWith((epoch) => [ + itemRow(epoch, 'codex:thread-1:turn-1:2', body) + ]) + const journal = await open() + expect(journal.isReadOnly).toBe(false) + expect(journal.repair).toEqual({ malformedRows: 1 }) + await journals.closeAll() + const after = stored() + expect(after.slice(0, 3)).toEqual(written.slice(0, 3)) + expect(after.some((row) => row.rowJson.includes('codex:thread-1:turn-1:2'))).toBe(false) + expect(after.some((row) => row.rowJson.includes('"after"'))).toBe(false) + } + ) +}) diff --git a/src/shared/agent-session-journal-body-admission.test.ts b/src/shared/agent-session-journal-body-admission.test.ts index 51afb7c23030..67d8696f2b44 100644 --- a/src/shared/agent-session-journal-body-admission.test.ts +++ b/src/shared/agent-session-journal-body-admission.test.ts @@ -3,152 +3,136 @@ import { readAgentJournalItemBody, readAgentJournalMessageBody } from './agent-session-journal-body-admission' -import { unclassifiedOptionalFacts } from './agent-session-journal-optional-facts' -import { - AgentJournalItemBodySchema, - AgentJournalMessageBodySchema -} from './agent-session-journal-schemas' const RESOLUTION = { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } +const QUESTION_ENTRY = { + id: 'q-1', + question: 'Which lane?', + multiSelect: false, + options: [{ id: 'o-1', label: 'First' }], + freeTextQuestionId: 'q-1-other' +} const QUESTION = { kind: 'question', question: 'Which lane?', options: [{ id: 'o-1', label: 'First' }], - questions: [ - { - id: 'q-1', - question: 'Which lane?', - multiSelect: false, - options: [{ id: 'o-1', label: 'First' }], - freeTextQuestionId: 'q-1-other' - } - ], + questions: [QUESTION_ENTRY], resolution: RESOLUTION } +const PLAN_APPROVAL = { + kind: 'approval', + title: 'Approve the plan?', + detail: null, + options: [{ id: 'yes', label: 'Yes' }], + resolution: RESOLUTION, + subject: { kind: 'plan', text: 'Step one' } +} const TURN_STATUS = { kind: 'status', text: 'Turn started', turnLifecycle: { turnId: 't-1', state: 'running', startedAt: 5 } } - -/** Reads a copy, so each case starts from the same body; returns the verdict and what is left. */ -function read(body: Record) { - const copy = structuredClone(body) - return { verdict: readAgentJournalItemBody(copy), body: copy } +const GOAL = { + objective: 'Ship it', + status: 'active', + tokenBudget: null, + tokensUsed: 1, + timeUsedSeconds: 1, + createdAt: 1, + updatedAt: 1 } -it('gives every optional field a body can hold a policy', () => { - expect(unclassifiedOptionalFacts(AgentJournalItemBodySchema)).toEqual([]) - expect(unclassifiedOptionalFacts(AgentJournalMessageBodySchema)).toEqual([]) -}) - -describe('a body of a kind this build does not know', () => { - it('is unreadable and left as it was', () => { - const body = { kind: 'plan-card', steps: [{ text: 'by a newer build' }] } - expect(read(body)).toEqual({ verdict: 'unreadable', body }) - }) - +describe('a body this build reads', () => { it.each([ - ['no kind', { text: 'x' }], - ['an empty kind', { kind: '' }], - ['a kind that is not a string', { kind: 7 }] - ])('is damage with %s', (_name, body) => { - expect(read(body).verdict).toBe('malformed') - }) - - it.each([null, 'status', [{ kind: 'status' }]])('is damage when the body is %j', (body) => { - expect(readAgentJournalItemBody(body)).toBe('malformed') + ['a question', QUESTION], + ['a plan approval', PLAN_APPROVAL], + ['a turn lifecycle', TURN_STATUS], + ['a goal change', { kind: 'status', text: 'Goal', threadGoal: { state: 'set', goal: GOAL } }], + [ + 'a key it does not know, in a prompt option too', + { ...QUESTION, next: 1, options: [{ id: 'o-1', label: 'First', shortcut: 'f' }] } + ], + [ + 'a block type it does not know', + { kind: 'message', role: 'assistant', blocks: [{ type: 'chart' }] } + ], + [ + 'a goal state it does not know', + { kind: 'status', text: 'Goal', threadGoal: { state: 'paused' } } + ], + ['a turn state it does not know', { kind: 'turn', turnId: 't', state: 'handed-off' }] + ])('is readable: %s', (_name, body) => { + expect(readAgentJournalItemBody(body)).toBe('readable') }) }) -describe('an optional fact this build cannot parse', () => { +describe("a value outside a closed set this build knows: a newer build's", () => { it.each([ - ['a negative turn duration', { kind: 'turn', turnId: 't', state: 'done', durationMs: -1 }], - ['a null turn start', { kind: 'turn', turnId: 't', state: 'done', startedAt: null }], - ['a failure fact with no kind', { kind: 'status', text: 'Failed', failure: { kind: '' } }], + ['a body kind', { kind: 'plan-card', steps: [{ text: 'by a newer build' }] }], + ['a nested literal', { ...PLAN_APPROVAL, subject: { kind: 'diff', path: 'a.ts' } }], [ - 'context facts of a later shape', - { kind: 'turn', turnId: 't', state: 'done', contextUsage: 1 } + 'a nested discriminant', + { kind: 'turn', turnId: 't', state: 'done', contextUsage: { used: { kind: 'measured' } } } ], - [ - 'a tool call id that is only spaces', - { kind: 'tool-call', name: 'Read', state: 'done', callId: ' ' } - ] - ])('drops %s and keeps the row', (_name, body) => { - const { verdict, body: left } = read(body) - expect(verdict).toBe('readable') - const dropped = Object.keys(body).filter((key) => !(key in left)) - expect(dropped).toHaveLength(1) - expect(left).toEqual( - Object.fromEntries(Object.entries(body).filter(([key]) => key !== dropped[0])) - ) - }) - - it('drops a broken annotation nested in a block, an option and a turn lifecycle, and keeps them', () => { - const message = { - kind: 'message', - role: 'assistant', - blocks: [{ type: 'text', text: 'hi', tone: 5 }] - } - expect(read(message)).toEqual({ - verdict: 'readable', - body: { ...message, blocks: [{ type: 'text', text: 'hi' }] } - }) - const question = { ...QUESTION, options: [{ id: 'o-1', label: 'First', description: 3 }] } - expect(read(question)).toEqual({ - verdict: 'readable', - body: { ...question, options: [{ id: 'o-1', label: 'First' }] } - }) - const status = { - ...TURN_STATUS, - turnLifecycle: { ...TURN_STATUS.turnLifecycle, startedAt: 'x' } - } - expect(read(status)).toEqual({ - verdict: 'readable', - body: { ...status, turnLifecycle: { turnId: 't-1', state: 'running' } } - }) + ['one beside damage', { ...PLAN_APPROVAL, title: 5, subject: { kind: 'diff' } }] + ])('is unreadable: %s', (_name, body) => { + expect(readAgentJournalItemBody(body)).toBe('unreadable') }) +}) +describe('anything else that fails: damage', () => { it.each([ - ['a question list that is not a list', { ...QUESTION, questions: null }], - ['a question with no free-text id string', { ...QUESTION, freeTextQuestionId: 4 }], + ['a body that is not an object', null], + ['a body that is a list', [{ kind: 'status' }]], + ['no kind', { text: 'x' }], + ['an empty kind', { kind: '' }], + ['a kind that is not a string', { kind: 7 }], + [ + 'a nested literal that is not a string', + { ...PLAN_APPROVAL, subject: { kind: 5, text: 'x' } } + ], + ['questions that are not a list', { ...QUESTION, questions: null }], + [ + 'a multi-select flag that is not a boolean', + { ...QUESTION, questions: [{ ...QUESTION_ENTRY, multiSelect: 'yes' }] } + ], + [ + 'a question option with no id', + { ...QUESTION, questions: [{ ...QUESTION_ENTRY, options: [{ label: 'First' }] }] } + ], + ['a free-text question id that is not a string', { ...QUESTION, freeTextQuestionId: 4 }], ['a turn lifecycle with no turn', { ...TURN_STATUS, turnLifecycle: { state: 'running' } }], - ['a goal change with no goal', { kind: 'status', text: 'Goal', threadGoal: { state: 'set' } }], [ - 'a plan approval with no plan', + 'a turn lifecycle whose turn is a number', + { ...TURN_STATUS, turnLifecycle: { turnId: 7, state: 'running' } } + ], + [ + 'a goal with no token count', { - kind: 'approval', - title: 'Approve the plan?', - detail: null, - options: [{ id: 'yes', label: 'Yes' }], - resolution: RESOLUTION, - subject: { kind: 'plan' } + kind: 'status', + text: 'Goal', + threadGoal: { state: 'set', goal: { ...GOAL, tokensUsed: null } } } - ] - ])('makes the row unreadable, not damaged, for must-understand %s', (_name, body) => { - expect(read(body)).toEqual({ verdict: 'unreadable', body }) - }) -}) - -describe('a field this build does not know', () => { - it('is ignored and kept, in a prompt option too', () => { - const body = { ...QUESTION, options: [{ id: 'o-1', label: 'First', shortcut: 'f' }], next: 1 } - expect(read(body)).toEqual({ verdict: 'readable', body }) - }) -}) - -describe('a required field that fails', () => { - it.each([ + ], + ['a goal change with no goal', { kind: 'status', text: 'Goal', threadGoal: { state: 'set' } }], + ['an empty plan', { ...PLAN_APPROVAL, subject: { kind: 'plan', text: '' } }], ['question options that are not a list', { ...QUESTION, options: null }], ['a question without its resolution', { ...QUESTION, resolution: undefined }], ['a diff whose patch is broken', { kind: 'diff', path: 'a.ts', patch: { head: 'x' } }], ['a turn with no state', { kind: 'turn', turnId: 't' }], + ['a negative turn duration', { kind: 'turn', turnId: 't', state: 'done', durationMs: -1 }], + ['a failure fact with no kind', { kind: 'status', text: 'Failed', failure: { kind: '' } }], + [ + 'a tool call id that is only spaces', + { kind: 'tool-call', name: 'Read', state: 'done', callId: ' ' } + ], [ 'a known block missing its text', { kind: 'message', role: 'user', blocks: [{ type: 'text' }] } - ] - ])('is damage: %s', (_name, body) => { - expect(read(body).verdict).toBe('malformed') + ], + ['a block type that is not a string', { kind: 'message', role: 'user', blocks: [{ type: 5 }] }] + ])('is malformed: %s', (_name, body) => { + expect(readAgentJournalItemBody(body)).toBe('malformed') }) }) @@ -159,5 +143,8 @@ describe("a submission's body", () => { ) expect(readAgentJournalMessageBody({ kind: 'voice-note', clip: 'x' })).toBe('unreadable') expect(readAgentJournalMessageBody({ kind: 'status', text: 'x' })).toBe('malformed') + expect( + readAgentJournalMessageBody({ kind: 'message', role: 'user', blocks: [{ type: 'text' }] }) + ).toBe('malformed') }) }) diff --git a/src/shared/agent-session-journal-body-admission.ts b/src/shared/agent-session-journal-body-admission.ts index 6ba6cbbf0ca0..2e6d82802b9f 100644 --- a/src/shared/agent-session-journal-body-admission.ts +++ b/src/shared/agent-session-journal-body-admission.ts @@ -1,61 +1,58 @@ -// Reading a persisted item body, by the per-field rule in agent-session-journal-schemas.ts: a body -// is readable (perhaps less an annotation it could not parse), unreadable (a newer build's), or -// damage. +// Reading a persisted body by the extension rule in agent-session-journal-schemas.ts: it parses, or +// it holds a value outside a closed set this build knows (a newer build's), or it is damage. -import { z } from 'zod' -import { - dropUnreadableOptionalFacts, - type AgentJournalOptionalFactPolicy -} from './agent-session-journal-optional-facts' +import type { z } from 'zod' import { AgentJournalItemBodySchema, - AgentJournalMessageBodySchema + isAdmissibleAgentJournalMessageBody } from './agent-session-journal-schemas' /** `unreadable` is never damage: the caller keeps the row and stops writing, as for a newer `v`. */ export type AgentJournalContentVerdict = 'readable' | 'unreadable' | 'malformed' -/** Every body kind this build knows, read off the schema so the two can never disagree. */ -const KNOWN_BODY_KINDS: ReadonlySet = new Set( - AgentJournalItemBodySchema.options.flatMap((option) => [...option.shape.kind.values]) -) - -const DROPPABLE: ReadonlySet = new Set(['droppable']) -const ANY_OPTIONAL: ReadonlySet = new Set([ - 'droppable', - 'must-understand' -]) - -/** Reads an item body in place: an unparseable droppable fact is removed from it. */ export function readAgentJournalItemBody(body: unknown): AgentJournalContentVerdict { - return readBody(AgentJournalItemBodySchema, body) + const parsed = AgentJournalItemBodySchema.safeParse(body, { reportInput: true }) + if (parsed.success) { + return 'readable' + } + // A newer build's value anywhere wins over damage beside it: never delete what a newer build wrote. + return parsed.error.issues.some(isOutsideClosedSet) ? 'unreadable' : 'malformed' } /** The same for a submission's body, which only a user message may be. */ export function readAgentJournalMessageBody(body: unknown): AgentJournalContentVerdict { - return readBody(AgentJournalMessageBodySchema, body) + const verdict = readAgentJournalItemBody(body) + return verdict === 'readable' && !isAdmissibleAgentJournalMessageBody(body) + ? 'malformed' + : verdict } -function readBody(schema: z.core.$ZodType, body: unknown): AgentJournalContentVerdict { - if (typeof body !== 'object' || body === null || Array.isArray(body) || !('kind' in body)) { - return 'malformed' - } - const { kind } = body - if (typeof kind !== 'string' || kind === '') { - return 'malformed' +/** A new string where a closed set of strings expects one of its own: the set's type, so never a + * changed one. A plain union's branches are all searched: each in the body schemas pairs a + * discriminated union with an open fallback, whose failure is never a closed-set one. */ +function isOutsideClosedSet(issue: z.core.$ZodIssue): boolean { + switch (issue.code) { + case 'invalid_value': + return ( + issue.values.some((known) => typeof known === 'string') && isNewClosedSetValue(issue.input) + ) + case 'invalid_union': + if (issue.discriminator !== undefined) { + return isPlainObject(issue.input) && isNewClosedSetValue(issue.input[issue.discriminator]) + } + return issue.errors.some((branch) => branch.some(isOutsideClosedSet)) + case 'invalid_key': + case 'invalid_element': + return issue.issues.some(isOutsideClosedSet) + default: + return false } - if (!KNOWN_BODY_KINDS.has(kind)) { - return 'unreadable' - } - if (z.safeParse(schema, body).success) { - return 'readable' - } - dropUnreadableOptionalFacts(schema, body, DROPPABLE) - if (z.safeParse(schema, body).success) { - return 'readable' - } - // Without its must-understand facts it would read: only they failed, so it is a newer build's. - const probe = structuredClone(body) - dropUnreadableOptionalFacts(schema, probe, ANY_OPTIONAL) - return z.safeParse(schema, probe).success ? 'unreadable' : 'malformed' +} + +function isNewClosedSetValue(value: unknown): boolean { + return typeof value === 'string' && value !== '' +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) } diff --git a/src/shared/agent-session-journal-optional-facts.ts b/src/shared/agent-session-journal-optional-facts.ts deleted file mode 100644 index f589d206af6e..000000000000 --- a/src/shared/agent-session-journal-optional-facts.ts +++ /dev/null @@ -1,130 +0,0 @@ -// What a persisted body does with an optional fact this build cannot parse. Every optional field -// of a journal body schema states it where the field is declared: -// -// - droppable: an annotation (a time, a display detail, a typed copy of what the row also says in -// words). An unparseable one is removed from the row in memory, and the row reads as one written -// before the field existed, which every reader already handles because older rows lack it. -// - must-understand: a fact a reader acts on (a prompt's questions, a turn's lifecycle). An -// unparseable one makes the row unreadable: the chat latches read-only, as for a newer schema -// version, and nothing is deleted. -// -// A required field that fails is damage, repaired as before. A key this build does not know is -// ignored and kept. `unclassifiedOptionalFacts` lets a test hold every optional field reachable -// from a body schema to one of the two policies. - -import { z } from 'zod' - -export type AgentJournalOptionalFactPolicy = 'droppable' | 'must-understand' - -const POLICY_BY_FIELD = new WeakMap() - -function classifiedFact( - schema: T, - policy: AgentJournalOptionalFactPolicy -): z.ZodOptional { - const field = schema.optional() - POLICY_BY_FIELD.set(field, policy) - return field -} - -/** An optional annotation: unparseable, it is dropped and the row keeps the rest. */ -export function droppableFact(schema: T): z.ZodOptional { - return classifiedFact(schema, 'droppable') -} - -/** An optional fact a reader acts on: unparseable, it makes the row unreadable. */ -export function mustUnderstandFact(schema: T): z.ZodOptional { - return classifiedFact(schema, 'must-understand') -} - -function isPlainObject(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - -/** The union members `value` takes: a discriminated union's by its discriminator. A plain union's - * object members cannot be told apart, so only its discriminated members are walked. */ -function unionMembersFor(schema: z.ZodUnion, value: unknown): readonly z.core.$ZodType[] { - if (schema instanceof z.ZodDiscriminatedUnion) { - const discriminator = schema.def.discriminator - if (!isPlainObject(value)) { - return [] - } - return schema.options.filter( - (option) => - option instanceof z.ZodObject && - z.safeParse(option.shape[discriminator], value[discriminator]).success - ) - } - return schema.options.filter((option) => option instanceof z.ZodDiscriminatedUnion) -} - -/** - * Removes, in place, every optional fact under `schema` whose policy is in `policies` and whose - * value fails its own schema. A droppable fact is judged whole; required fields and must-understand - * facts are walked into first, so a broken annotation inside one goes and the fact stays. - */ -export function dropUnreadableOptionalFacts( - schema: z.core.$ZodType, - value: unknown, - policies: ReadonlySet -): void { - if (schema instanceof z.ZodOptional || schema instanceof z.ZodNullable) { - dropUnreadableOptionalFacts(schema.unwrap(), value, policies) - return - } - if (schema instanceof z.ZodArray) { - for (const element of Array.isArray(value) ? value : []) { - dropUnreadableOptionalFacts(schema.element, element, policies) - } - return - } - if (schema instanceof z.ZodUnion) { - for (const member of unionMembersFor(schema, value)) { - dropUnreadableOptionalFacts(member, value, policies) - } - return - } - if (!(schema instanceof z.ZodObject) || !isPlainObject(value)) { - return - } - for (const [key, field] of Object.entries(schema.shape)) { - if (value[key] === undefined) { - continue - } - const policy = POLICY_BY_FIELD.get(field) - if (policy !== 'droppable') { - dropUnreadableOptionalFacts(field, value[key], policies) - } - if (policy !== undefined && policies.has(policy) && !z.safeParse(field, value[key]).success) { - delete value[key] - } - } -} - -/** Paths of optional fields under `schema` that state no policy. Droppable facts are judged whole, - * so the fields inside one need none. */ -export function unclassifiedOptionalFacts(schema: z.core.$ZodType, path = ''): string[] { - if (schema instanceof z.ZodOptional || schema instanceof z.ZodNullable) { - return unclassifiedOptionalFacts(schema.unwrap(), path) - } - if (schema instanceof z.ZodArray) { - return unclassifiedOptionalFacts(schema.element, `${path}[]`) - } - if (schema instanceof z.ZodUnion) { - return schema.options.flatMap((option, index) => - unclassifiedOptionalFacts(option, `${path}|${index}`) - ) - } - if (!(schema instanceof z.ZodObject)) { - return [] - } - return Object.entries(schema.shape).flatMap(([key, field]) => { - const policy = POLICY_BY_FIELD.get(field) - const fieldPath = path ? `${path}.${key}` : key - if (policy === 'droppable') { - return [] - } - const inner = unclassifiedOptionalFacts(field, fieldPath) - return field instanceof z.ZodOptional && policy === undefined ? [fieldPath, ...inner] : inner - }) -} diff --git a/src/shared/agent-session-journal-schemas.ts b/src/shared/agent-session-journal-schemas.ts index 8f1c22cbcf00..65f781d7c012 100644 --- a/src/shared/agent-session-journal-schemas.ts +++ b/src/shared/agent-session-journal-schemas.ts @@ -13,15 +13,15 @@ // and unknown object keys pass — a same-version row written by a slightly // newer build must not be misread as malformed (see journal-row-schema.ts). // -// What a persisted body this build cannot fully read means is decided per field, here: a body -// `kind` it does not know is a newer build's, so the chat reads read-only; every optional field -// is declared droppable or must-understand (agent-session-journal-optional-facts.ts); a required -// field that fails is damage. A new optional field picks its policy; changing what a field holds -// is a new key, never a new type under the old one. +// A newer writer extends a body only by new keys, new values in a closed set (a kind, a +// discriminant, a literal), or a bumped row `v`, never by changing a field's type. A value outside +// a closed set reads as a newer build's, so the chat goes read-only and nothing is deleted +// (agent-session-journal-body-admission.ts); any other failure is damage. A new value of an open +// string (turn, resolution, tool or goal `state`, a block `type`) reads as-is in older builds, so +// it must be safe for them there or ride a bumped `v`. import { z } from 'zod' import { AgentSessionContextUsageSchema } from './agent-session-context-usage-schema' -import { droppableFact, mustUnderstandFact } from './agent-session-journal-optional-facts' import type { AgentJournalItemBody, AgentJournalMessageItem, @@ -44,10 +44,10 @@ const ProviderFrame = z.object({ }) const ToolMetadata = { - mcpIdentity: droppableFact(z.object({ server: z.string(), tool: z.string() })), - exitCode: droppableFact(z.number().int()), - durationMs: droppableFact(z.number().nonnegative()), - webSearchResults: droppableFact(z.array(z.object({ title: z.string(), url: z.string() }))) + mcpIdentity: z.object({ server: z.string(), tool: z.string() }).optional(), + exitCode: z.number().int().optional(), + durationMs: z.number().nonnegative().optional(), + webSearchResults: z.array(z.object({ title: z.string(), url: z.string() })).optional() } const KNOWN_BLOCK_TYPES = new Set([ @@ -70,9 +70,9 @@ const SubagentEntry = z.object({ id: z.string(), label: z.string(), state: z.string().min(1), - tokens: droppableFact(z.number()), - startedAt: droppableFact(z.number()), - settledAt: droppableFact(z.number()) + tokens: z.number().optional(), + startedAt: z.number().optional(), + settledAt: z.number().optional() }) /** Renderers select blocks by `type` equality and skip what they cannot draw, @@ -83,29 +83,29 @@ const Block = z.union([ z.object({ type: z.literal('text'), text: z.string(), - presentation: droppableFact(z.string()), - tone: droppableFact(z.string()), - providerFrame: droppableFact(ProviderFrame) + presentation: z.string().optional(), + tone: z.string().optional(), + providerFrame: ProviderFrame.optional() }), // `input: undefined` loses its key under JSON.stringify, so a persisted // canonical tool call may lack it entirely. z.object({ type: z.literal('tool-call'), name: z.string(), - input: droppableFact(z.unknown()), - callId: droppableFact(ProviderCallId), + input: z.unknown().optional(), + callId: ProviderCallId.optional(), ...ToolMetadata }), z.object({ type: z.literal('tool-result'), output: z.string(), - isError: droppableFact(z.boolean()) + isError: z.boolean().optional() }), z.object({ type: z.literal('image-ref'), - path: droppableFact(z.string()), - url: droppableFact(z.string()), - alt: droppableFact(z.string()) + path: z.string().optional(), + url: z.string().optional(), + alt: z.string().optional() }), z.object({ type: z.literal('subagent-group'), @@ -121,13 +121,13 @@ const Block = z.union([ kind: z.string().min(1), label: z.string(), state: z.string().min(1), - parentToolUseId: droppableFact(z.string()), - summary: droppableFact(z.string()), - error: droppableFact(z.string()), - outputFile: droppableFact(z.string()), - tokens: droppableFact(z.number()), - startedAt: droppableFact(z.number()), - settledAt: droppableFact(z.number()) + parentToolUseId: z.string().optional(), + summary: z.string().optional(), + error: z.string().optional(), + outputFile: z.string().optional(), + tokens: z.number().optional(), + startedAt: z.number().optional(), + settledAt: z.number().optional() }) ]), z.object({ type: z.string() }).refine((block) => !KNOWN_BLOCK_TYPES.has(block.type)) @@ -136,31 +136,30 @@ const Block = z.union([ const PromptOption = z.object({ id: z.string(), label: z.string(), - description: droppableFact(z.string()) + description: z.string().optional() }) const Question = z.object({ id: z.string(), question: z.string(), - header: droppableFact(z.string()), + header: z.string().optional(), multiSelect: z.boolean(), options: z.array(PromptOption), - // Where an answer's free text goes: dropped, it would go under no question. - freeTextQuestionId: mustUnderstandFact(z.string()) + freeTextQuestionId: z.string().optional() }) const Resolution = z.object({ state: z.string().min(1), selectedOptionId: z.string().nullable(), - answers: droppableFact( - z.array( + answers: z + .array( z.object({ questionId: z.string(), optionIds: z.array(z.string()), other: z.string().optional() }) ) - ), + .optional(), resolvedBy: z.string().nullable(), resolvedAt: z.number().nullable() }) @@ -174,16 +173,16 @@ const ApprovalMatchedAskRule = z.object({ const ApprovalSubject = z.object({ kind: z.literal('plan'), text: z.string().min(1), - filePath: droppableFact(z.string()) + filePath: z.string().optional() }) -export const AgentJournalMessageBodySchema = z.object({ +const MessageBody = z.object({ kind: z.literal('message'), role: z.string().min(1), blocks: z.array(Block), // Open like roles: a send mode a newer build writes must not turn the row malformed. - sentAs: droppableFact(z.string().min(1)), - command: droppableFact(z.object({ name: z.string().min(1) })) + sentAs: z.string().min(1).optional(), + command: z.object({ name: z.string().min(1) }).optional() }) const ThreadGoal = z.object({ @@ -214,28 +213,27 @@ const FailureFact = z.object({ }) export const AgentJournalItemBodySchema = z.discriminatedUnion('kind', [ - AgentJournalMessageBodySchema, + MessageBody, z.object({ kind: z.literal('tool-call'), ...ToolMetadata, name: z.string(), // See the tool-call block: the key itself is lost when `input` is undefined. - input: droppableFact(z.unknown()), - callId: droppableFact(ProviderCallId), + input: z.unknown().optional(), + callId: ProviderCallId.optional(), state: z.string().min(1), - output: droppableFact(BoundedPayload) + output: BoundedPayload.optional() }), z.object({ kind: z.literal('diff'), path: z.string(), patch: BoundedPayload }), z.object({ kind: z.literal('approval'), title: z.string(), - displayName: droppableFact(z.string()), - description: droppableFact(z.string()), - decisionReason: droppableFact(z.string()), - blockedPath: droppableFact(z.string()), - matchedAskRule: droppableFact(ApprovalMatchedAskRule), - // The plan being approved: approving without it would approve what the person never saw. - subject: mustUnderstandFact(ApprovalSubject), + displayName: z.string().optional(), + description: z.string().optional(), + decisionReason: z.string().optional(), + blockedPath: z.string().optional(), + matchedAskRule: ApprovalMatchedAskRule.optional(), + subject: ApprovalSubject.optional(), detail: z.string().nullable(), options: z.array(PromptOption), resolution: Resolution @@ -244,33 +242,30 @@ export const AgentJournalItemBodySchema = z.discriminatedUnion('kind', [ kind: z.literal('question'), question: z.string(), options: z.array(PromptOption), - // What an answer is shaped by: dropped, an answer would go to questions nobody was asked. - questions: mustUnderstandFact(z.array(Question)), - freeTextQuestionId: mustUnderstandFact(z.string()), + questions: z.array(Question).optional(), + freeTextQuestionId: z.string().optional(), resolution: Resolution }), z.object({ kind: z.literal('status'), text: z.string(), - presentation: droppableFact(z.string()), - tone: droppableFact(z.string()), - // A turn's start or end and a goal's change: the host settles turns and answers goal edits - // from them, so a fold without one would write from a wrong state. - turnLifecycle: mustUnderstandFact( - z.object({ + presentation: z.string().optional(), + tone: z.string().optional(), + turnLifecycle: z + .object({ turnId: z.string(), state: z.string().min(1), - outcome: droppableFact(z.string().min(1)), - userItemId: droppableFact(z.string().min(1)), - startedAt: droppableFact(z.number().finite().positive()), - requestedAt: droppableFact(z.number().finite().positive()), - completedAt: droppableFact(z.number().finite().positive()), - durationMs: droppableFact(z.number().finite().nonnegative()) + outcome: z.string().min(1).optional(), + userItemId: z.string().min(1).optional(), + startedAt: z.number().finite().positive().optional(), + requestedAt: z.number().finite().positive().optional(), + completedAt: z.number().finite().positive().optional(), + durationMs: z.number().finite().nonnegative().optional() }) - ), - providerFrame: droppableFact(ProviderFrame), - threadGoal: mustUnderstandFact(ThreadGoalState), - failure: droppableFact(FailureFact) + .optional(), + providerFrame: ProviderFrame.optional(), + threadGoal: ThreadGoalState.optional(), + failure: FailureFact.optional() }), z.object({ kind: z.literal('turn'), @@ -279,14 +274,14 @@ export const AgentJournalItemBodySchema = z.discriminatedUnion('kind', [ // Open like `state`: a verdict a newer build writes must not turn the row // malformed. `readAgentJournalTurnOutcome` is where an unplaceable one // becomes unknown rather than an arm a caller would act on. - outcome: droppableFact(z.string().min(1)), - userItemId: droppableFact(z.string().min(1)), - startedAt: droppableFact(z.number().finite().positive()), - requestedAt: droppableFact(z.number().finite().positive()), - completedAt: droppableFact(z.number().finite().positive()), - durationMs: droppableFact(z.number().finite().nonnegative()), - contextUsage: droppableFact(AgentSessionContextUsageSchema), - providerTurnId: droppableFact(z.string().min(1)) + outcome: z.string().min(1).optional(), + userItemId: z.string().min(1).optional(), + startedAt: z.number().finite().positive().optional(), + requestedAt: z.number().finite().positive().optional(), + completedAt: z.number().finite().positive().optional(), + durationMs: z.number().finite().nonnegative().optional(), + contextUsage: AgentSessionContextUsageSchema.optional(), + providerTurnId: z.string().min(1).optional() }) ]) @@ -353,7 +348,7 @@ export function isAdmissibleAgentJournalItemBody(value: unknown): value is Agent export function isAdmissibleAgentJournalMessageBody( value: unknown ): value is AgentJournalMessageItem { - return AgentJournalMessageBodySchema.safeParse(value).success + return MessageBody.safeParse(value).success } export function isAdmissibleAgentJournalRenderItem( @@ -374,9 +369,7 @@ export function isAdmissibleAgentJournalSubmission( type Admits = T export type CanonicalJournalTypesAreAdmissible = [ Admits ? true : false>, - Admits< - AgentJournalMessageItem extends z.input ? true : false - >, + Admits ? true : false>, Admits< AgentJournalRenderItem extends z.input ? true : false >, From b9ed555bdc40325677b62a2874f73a73babe0861 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 00:56:34 -0700 Subject: [PATCH 04/52] fix(native-chat): a read-only chat locks its composer and says why there Removes the separate read-only line (desktop status strip, phone notice component). The host's `readOnly` on whole pages now feeds the existing composer lock instead: desktop disables the composer with "Saved by a newer Orca. Update Orca to continue this chat." as its placeholder, and the phone adds a 'read-only' input-lock reason with the same words. A read-only chat shows no prompt card it would refuse; the draft stays in the composer. The phone's send-failure line is back exactly as on main. --- .../MobileNativeChatComposerNotices.tsx | 46 ----- .../src/session/MobileNativeChatOverlay.tsx | 10 +- .../src/session/MobileNativeChatView.test.ts | 19 +- mobile/src/session/MobileNativeChatView.tsx | 28 +-- ...bile-native-chat-read-only-notice.test.tsx | 120 ------------- .../mobile-native-chat-read-only.test.tsx | 164 ++++++++++++++++++ .../session/mobile-native-chat-view-styles.ts | 10 ++ .../agent-session-read-only-page.test.ts | 24 +-- .../native-chat/NativeChatComposer.tsx | 2 + .../native-chat/NativeChatComposerField.tsx | 4 +- ...eChatStructuredSession.read-only.test.tsx} | 32 +++- ...tiveChatStructuredSession.test-harness.tsx | 2 + .../NativeChatStructuredSession.tsx | 9 +- .../NativeChatStructuredSessionStatus.tsx | 16 -- .../agent-session-write-notice-text.ts | 19 +- .../native-chat-composer-target.test.ts | 12 ++ .../native-chat-composer-target.ts | 11 +- .../native-chat/native-chat-composer-types.ts | 2 + .../src/i18n/en-runtime-required.json | 2 +- src/renderer/src/i18n/locales/en.json | 2 +- src/renderer/src/i18n/locales/es.json | 2 +- src/renderer/src/i18n/locales/fr.json | 2 +- src/renderer/src/i18n/locales/ja.json | 2 +- src/renderer/src/i18n/locales/ko.json | 2 +- src/renderer/src/i18n/locales/zh.json | 2 +- src/shared/agent-session-read-only.ts | 2 +- src/shared/agent-session-write-notice-copy.ts | 2 +- ...s => agent-session-read-only.unit.test.ts} | 41 +++-- 28 files changed, 341 insertions(+), 248 deletions(-) delete mode 100644 mobile/src/session/MobileNativeChatComposerNotices.tsx delete mode 100644 mobile/src/session/mobile-native-chat-read-only-notice.test.tsx create mode 100644 mobile/src/session/mobile-native-chat-read-only.test.tsx rename src/renderer/src/components/native-chat/{NativeChatStructuredSession.read-only-notice.test.tsx => NativeChatStructuredSession.read-only.test.tsx} (61%) create mode 100644 src/renderer/src/components/native-chat/native-chat-composer-target.test.ts rename tests/e2e/cross-version-wire/{agent-session-read-only-notice.unit.test.ts => agent-session-read-only.unit.test.ts} (84%) diff --git a/mobile/src/session/MobileNativeChatComposerNotices.tsx b/mobile/src/session/MobileNativeChatComposerNotices.tsx deleted file mode 100644 index 5626c7407755..000000000000 --- a/mobile/src/session/MobileNativeChatComposerNotices.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { StyleSheet, Text, View } from 'react-native' -import { colors, spacing, typography } from '../theme/mobile-theme' - -/** The lines above the composer: why the host keeps this chat read-only, said before any send, - * and the latest send failure. */ -export function MobileNativeChatComposerNotices({ - readOnlyNotice, - sendErrorMessage -}: { - readOnlyNotice: string | null - sendErrorMessage: string | null | undefined -}): React.JSX.Element { - return ( - <> - {readOnlyNotice ? ( - - {readOnlyNotice} - - ) : null} - {sendErrorMessage ? ( - // This banner is the only channel for a send failure — announce it. - - {sendErrorMessage} - - ) : null} - - ) -} - -const styles = StyleSheet.create({ - notice: { - alignItems: 'center', - paddingHorizontal: spacing.md, - paddingBottom: spacing.xs - }, - readOnlyText: { - color: colors.textMuted, - fontSize: typography.metaSize, - textAlign: 'center' - }, - sendErrorText: { - color: colors.statusRed, - fontSize: typography.metaSize, - fontWeight: '600' - } -}) diff --git a/mobile/src/session/MobileNativeChatOverlay.tsx b/mobile/src/session/MobileNativeChatOverlay.tsx index 2037b940c5aa..b6078b2d2698 100644 --- a/mobile/src/session/MobileNativeChatOverlay.tsx +++ b/mobile/src/session/MobileNativeChatOverlay.tsx @@ -53,6 +53,8 @@ export function MobileNativeChatOverlay({ keyboardInset }: Props): React.JSX.Element | null { const session = controller.nativeChatSession + // A read-only chat refuses every write: its composer locks and says why, and no prompt card shows. + const readOnlyNotice = session.readOnlyNotice ?? null const folded = useMemo(() => foldMobileNativeChatMessages(session.messages), [session.messages]) const streaming = useMobileNativeChatStreamingBubble( folded, @@ -96,9 +98,9 @@ export function MobileNativeChatOverlay({ onAnswerAsk={controller.handleNativeChatAnswerAsk} onCancelAsk={controller.handleNativeChatCancelAsk} onCancelPrompt={controller.handleNativeChatCancelPrompt} - question={controller.nativeChatQuestion} + question={readOnlyNotice ? null : controller.nativeChatQuestion} onAnswerQuestion={controller.handleNativeChatQuestionAnswer} - permission={controller.nativeChatPermission} + permission={readOnlyNotice ? null : controller.nativeChatPermission} onRespondPermission={controller.handleNativeChatRespondPermission} queuedSlot={queuedSlot} onOpenFile={onOpenFile} @@ -122,10 +124,10 @@ export function MobileNativeChatOverlay({ dictationMode={dictationMode} onMicPressIn={onMicPressIn} onMicPressOut={onMicPressOut} - inputLockReason={inputLockReason} + inputLockReason={inputLockReason ?? (readOnlyNotice ? 'read-only' : null)} sendErrorMessage={sendErrorMessage} onClearSendError={onClearSendError} - readOnlyNotice={session.readOnlyNotice ?? null} + readOnlyNotice={readOnlyNotice} filePaths={controller.nativeChatFilePaths} onNeedFiles={controller.loadNativeChatFiles} sessionOptions={controller.nativeChatSessionOptions} diff --git a/mobile/src/session/MobileNativeChatView.test.ts b/mobile/src/session/MobileNativeChatView.test.ts index d224c2e34a0e..40b5e856a4bd 100644 --- a/mobile/src/session/MobileNativeChatView.test.ts +++ b/mobile/src/session/MobileNativeChatView.test.ts @@ -79,7 +79,8 @@ type Overrides = { streaming?: string | null sendErrorMessage?: string | null onClearSendError?: () => void - inputLockReason?: 'disconnected' | 'waiting' | null + inputLockReason?: 'disconnected' | 'waiting' | 'read-only' | null + readOnlyNotice?: string | null onSend?: (text: string) => Promise pending?: Parameters[0]['pending'] structuredActivityUi?: boolean @@ -677,4 +678,20 @@ describe('MobileNativeChatView', () => { vi.useRealTimers() } }) + + it("locks a read-only chat's composer with the host's reason as its placeholder", async () => { + vi.useFakeTimers() + try { + const reason = 'Saved by a newer Orca. Update Orca to continue this chat.' + await render({ inputLockReason: 'read-only', readOnlyNotice: reason }) + await act(async () => { + vi.advanceTimersByTime(600) + }) + + expect(composer().props.disabled).toBe(true) + expect(composer().props.placeholder).toBe(reason) + } finally { + vi.useRealTimers() + } + }) }) diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index 77386edefe4b..57f646f9b119 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -35,7 +35,6 @@ import { MobileAgentWorkingIndicator } from './MobileAgentWorkingIndicator' import type { PendingNativeChatImage } from './mobile-native-chat-image-attachment' import { MobileNativeChatComposer } from './MobileNativeChatComposer' import { MobileNativeChatPromptCard } from './MobileNativeChatPromptCard' -import { MobileNativeChatComposerNotices } from './MobileNativeChatComposerNotices' import { NO_QUEUED_SLOT, type MobileQueuedSlotProps } from './use-mobile-native-chat-queued-slot' import type { MobileChatPermission } from './mobile-native-chat-permission' import type { MobileChatQuestion } from './mobile-native-chat-question' @@ -43,9 +42,10 @@ import type { MobileNativeChatSessionOptionPickersProps } from './MobileNativeCh import { MobileNativeChatMessage } from './MobileNativeChatMessage' import type { MobileNativeChatStatus } from './use-mobile-native-chat-session' -/** Why the composer input is locked: the transport is disconnected, or the - * terminal subscription has not acknowledged its input lease yet. */ -export type MobileNativeChatInputLockReason = 'disconnected' | 'waiting' +/** Why the composer input is locked: the transport is disconnected, the + * terminal subscription has not acknowledged its input lease yet, or the host + * keeps the chat read-only. */ +export type MobileNativeChatInputLockReason = 'disconnected' | 'waiting' | 'read-only' type Props = MobileQueuedSlotProps & { /** Raw transcript, only for telling "still loading" from "loaded and empty". */ @@ -110,7 +110,7 @@ type Props = MobileQueuedSlotProps & { sendErrorMessage?: string | null /** Clears `sendErrorMessage` once a later send is accepted. */ onClearSendError?: () => void - /** Why the host keeps this chat read-only, shown above the composer before any send. */ + /** Why the host keeps this chat read-only: the placeholder of a `read-only` lock. */ readOnlyNotice?: string | null filePaths?: string[] onNeedFiles?: (query: string) => void @@ -421,10 +421,16 @@ export function MobileNativeChatView({ ) : null} - + {sendErrorMessage ? ( + // This banner is the only channel for a send failure — announce it. + + {sendErrorMessage} + + ) : null} ({ - getItem: vi.fn(async () => null), - setItem: vi.fn(async () => {}), - removeItem: vi.fn(async () => {}) -})) -vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage })) -vi.mock('react-native', async () => { - const React = await import('react') - return { - Text: ({ children, ...props }: { children?: ReactNode }) => - React.createElement('Text', props, children), - View: ({ children, ...props }: { children?: ReactNode }) => - React.createElement('View', props, children), - StyleSheet: { create: (styles: unknown) => styles } - } -}) - -import { MobileNativeChatComposerNotices } from './MobileNativeChatComposerNotices' -import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session' - -const NOTICE = - "This chat was saved by a newer Orca, so it's read-only here. Update Orca to continue this chat." - -function snapshot(readOnly?: 'written-by-newer-orca'): AgentSessionSubscribeEvent { - const page: AgentSessionHistoryPage = { - sessionId: 'session-1', - epoch: 'epoch-1', - fence: 3, - direction: 'tail', - items: [], - removedItemIds: [], - submissions: [], - window: { oldest: null, newest: null, nextCursor: { epoch: 'epoch-1', sequence: 0 } }, - liveCursor: { epoch: 'epoch-1', sequence: 0 }, - hasOlder: false, - hasNewer: false, - ...(readOnly ? { readOnly } : {}) - } - return { type: 'snapshot', sessionId: 'session-1', fence: 3, page } -} - -let renderer: ReactTestRenderer | null = null -let hook: ReturnType | null = null -let listener: ((value: unknown) => void) | null = null - -// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the hook calls only sendRequest and subscribe on the client. -const client = { - sendRequest: vi.fn(async () => ({ ok: true, result: {}, _meta: { runtimeId: 'runtime-1' } })), - subscribe: vi.fn((_method: string, _params: unknown, onData: (value: unknown) => void) => { - listener = onData - return () => {} - }) -} as unknown as RpcClient - -function Harness(): null { - hook = useMobileStructuredAgentSession({ - client, - sessionId: 'session-1', - sourceIdentity: 'host-a\0workspace-a', - enabled: true, - connected: true, - hostSupport: null, - agent: 'codex', - onSendError: () => {} - }) - return null -} - -beforeEach(() => { - listener = null -}) - -afterEach(() => { - act(() => renderer?.unmount()) - renderer = null - hook = null -}) - -it("words the host's read-only reason on the phone, and drops it when the host takes writes again", async () => { - act(() => { - renderer = create(createElement(Harness)) - }) - await vi.waitFor(() => expect(listener).not.toBeNull()) - act(() => listener?.(snapshot('written-by-newer-orca'))) - expect(hook?.session.readOnlyNotice).toBe(NOTICE) - act(() => listener?.(snapshot())) - expect(hook?.session.readOnlyNotice).toBeNull() -}) - -it('shows the notice above the composer as plain text, apart from a send failure', () => { - const rendered: { view?: ReactTestRenderer } = {} - act(() => { - rendered.view = create( - createElement(MobileNativeChatComposerNotices, { - readOnlyNotice: NOTICE, - sendErrorMessage: 'Your message was not sent.' - }) - ) - }) - const lines = (rendered.view?.root.findAll((node) => String(node.type) === 'View') ?? []).map( - (node) => [ - node.props.accessibilityRole, - node.findAll((child) => String(child.type) === 'Text')[0]?.props.children - ] - ) - expect(lines).toEqual([ - ['text', NOTICE], - ['alert', 'Your message was not sent.'] - ]) - act(() => rendered.view?.unmount()) -}) diff --git a/mobile/src/session/mobile-native-chat-read-only.test.tsx b/mobile/src/session/mobile-native-chat-read-only.test.tsx new file mode 100644 index 000000000000..65a318853219 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-read-only.test.tsx @@ -0,0 +1,164 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { + AgentSessionHistoryPage, + AgentSessionSubscribeEvent +} from '../../../src/shared/agent-session-wire' +import type { RpcClient } from '../transport/rpc-client' + +const asyncStorage = vi.hoisted(() => ({ + getItem: vi.fn(async () => null), + setItem: vi.fn(async () => {}), + removeItem: vi.fn(async () => {}) +})) +vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage })) +vi.mock('react-native', () => ({ + StyleSheet: { create: (styles: unknown) => styles, absoluteFillObject: {} }, + View: 'View' +})) +vi.mock('./MobileNativeChatView', () => ({ MobileNativeChatView: 'ChatView' })) +vi.mock('./MobileNativeChatQueuedMessages', () => ({ MobileNativeChatQueuedMessages: 'Queued' })) + +import { MobileNativeChatOverlay } from './MobileNativeChatOverlay' +import type { MobileNativeChatController } from './use-mobile-native-chat-controller' +import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session' + +const NOTICE = 'Saved by a newer Orca. Update Orca to continue this chat.' + +function snapshot(readOnly?: 'written-by-newer-orca'): AgentSessionSubscribeEvent { + const page: AgentSessionHistoryPage = { + sessionId: 'session-1', + epoch: 'epoch-1', + fence: 3, + direction: 'tail', + items: [], + removedItemIds: [], + submissions: [], + window: { oldest: null, newest: null, nextCursor: { epoch: 'epoch-1', sequence: 0 } }, + liveCursor: { epoch: 'epoch-1', sequence: 0 }, + hasOlder: false, + hasNewer: false, + ...(readOnly ? { readOnly } : {}) + } + return { type: 'snapshot', sessionId: 'session-1', fence: 3, page } +} + +let renderer: ReactTestRenderer | null = null +let hook: ReturnType | null = null +let listener: ((value: unknown) => void) | null = null + +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the hook calls only sendRequest and subscribe on the client. +const client = { + sendRequest: vi.fn(async () => ({ ok: true, result: {}, _meta: { runtimeId: 'runtime-1' } })), + subscribe: vi.fn((_method: string, _params: unknown, onData: (value: unknown) => void) => { + listener = onData + return () => {} + }) +} as unknown as RpcClient + +function Harness(): null { + hook = useMobileStructuredAgentSession({ + client, + sessionId: 'session-1', + sourceIdentity: 'host-a\0workspace-a', + enabled: true, + connected: true, + hostSupport: null, + agent: 'codex', + onSendError: () => {} + }) + return null +} + +beforeEach(() => { + listener = null +}) + +afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + hook = null +}) + +it("words the host's read-only reason on the phone, and drops it when the host takes writes again", async () => { + act(() => { + renderer = create(createElement(Harness)) + }) + await vi.waitFor(() => expect(listener).not.toBeNull()) + act(() => listener?.(snapshot('written-by-newer-orca'))) + expect(hook?.session.readOnlyNotice).toBe(NOTICE) + act(() => listener?.(snapshot())) + expect(hook?.session.readOnlyNotice).toBeNull() +}) + +function overlayElement( + readOnlyNotice: string | null, + inputLockReason: 'disconnected' | null = null +): ReturnType { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the overlay reads only these controller members; the rest of the controller is unreachable from it. + const controller = { + showNativeChat: true, + nativeChatSession: { messages: [], status: 'ready', readOnlyNotice }, + nativeChatAgent: 'claude', + nativeChatStreamScopeKey: 'tab-a', + nativeChatQuestion: { id: 'question-1' }, + nativeChatPermission: { id: 'permission-1' }, + chatPending: [], + chatImagePreviewsByMessageId: {}, + chatComposerText: 'a draft', + setChatComposerText: vi.fn(), + nativeChatQueued: { + cards: [], + send: vi.fn(), + delete: vi.fn(), + edit: vi.fn(), + pause: null, + resume: vi.fn(), + sessionKey: 'session-a' + } + } as unknown as MobileNativeChatController + return createElement(MobileNativeChatOverlay, { + controller, + images: {} as never, + onMicPress: vi.fn(), + micActive: false, + dictationMode: 'toggle', + onMicPressIn: vi.fn(), + onMicPressOut: vi.fn(), + inputLockReason, + sendErrorMessage: null, + onClearSendError: vi.fn(), + sendSurfaceId: 'tab-a', + getSendCompletionGeneration: () => 0, + keyboardInset: 0 + }) +} + +function chatViewProps(element: ReturnType): Record { + act(() => { + renderer = create(element) + }) + const props = renderer!.root.findByType('ChatView' as never).props + act(() => renderer?.unmount()) + renderer = null + return props +} + +it('locks the phone composer with the reason as its placeholder, with no prompt card to answer', () => { + expect(chatViewProps(overlayElement(NOTICE))).toMatchObject({ + inputLockReason: 'read-only', + readOnlyNotice: NOTICE, + question: null, + permission: null, + composerText: 'a draft' + }) + expect(chatViewProps(overlayElement(NOTICE, 'disconnected'))).toMatchObject({ + inputLockReason: 'disconnected' + }) + expect(chatViewProps(overlayElement(null))).toMatchObject({ + inputLockReason: null, + question: { id: 'question-1' }, + permission: { id: 'permission-1' } + }) +}) diff --git a/mobile/src/session/mobile-native-chat-view-styles.ts b/mobile/src/session/mobile-native-chat-view-styles.ts index a3e0d7093e46..160477cfad80 100644 --- a/mobile/src/session/mobile-native-chat-view-styles.ts +++ b/mobile/src/session/mobile-native-chat-view-styles.ts @@ -29,6 +29,16 @@ export const styles = StyleSheet.create({ fontSize: typography.metaSize, fontWeight: '700' }, + sendError: { + alignItems: 'center', + paddingHorizontal: spacing.md, + paddingBottom: spacing.xs + }, + sendErrorText: { + color: colors.statusRed, + fontSize: typography.metaSize, + fontWeight: '600' + }, chromeToggle: { flexDirection: 'row', alignItems: 'center', diff --git a/src/main/native-chat/agent-session-wire/agent-session-read-only-page.test.ts b/src/main/native-chat/agent-session-wire/agent-session-read-only-page.test.ts index ead663be80bf..1e7e4012127c 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-read-only-page.test.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-read-only-page.test.ts @@ -1,5 +1,6 @@ -// The host says why it keeps a chat read-only on every whole page it serves, so a client can say -// it before a send is refused: a hydration page, a history page, and a catch-up's reset alike. +// The host says why it keeps a chat read-only on every whole page it serves, so a client can lock +// its composer before a send is refused: a hydration page, a history page, and a catch-up's reset +// alike. import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -53,14 +54,16 @@ function open(): Promise { return journals.open({ identity: IDENTITY, stateDirectory: root }) } -/** A chat of one item, reopened after a newer build appended `row` at the next sequence. */ -async function reopenedAfter(row: (epoch: string) => Record) { +/** A chat of `texts`, reopened after a newer build appended `row` at the next sequence. */ +async function reopenedAfter(row: (epoch: string) => Record, texts = ['before']) { const journal = await open() - await journal.appendItem( - { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 }, - { kind: 'status', text: 'before' }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) + for (const [ordinal, text] of texts.entries()) { + await journal.appendItem( + { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal }, + { kind: 'status', text }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + } const seq = journal.cursor().sequence + 1 const epoch = journal.epoch await journals.closeAll() @@ -106,8 +109,7 @@ function attachedClientNotice(journal: AgentSessionJournal): string | null { return parts ? agentSessionWriteNoticeEnglish(parts) : null } -const NOTICE = - "This chat was saved by a newer Orca, so it's read-only here. Update Orca to continue this chat." +const NOTICE = 'Saved by a newer Orca. Update Orca to continue this chat.' const ITEM = { v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, kind: 'item', itemId: 'x', revision: 1 } diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index d9fb9f1ec2d7..2a0c16402b86 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -57,6 +57,7 @@ const NativeChatComposerPane = forwardRef (await import('./NativeChatStructuredSession.test-harness')).createStructuredSessionMocks() @@ -28,8 +29,21 @@ afterEach(() => { resetStructuredSessionMocks() }) -const NOTICE = - "This chat was saved by a newer Orca, so it's read-only here. Update Orca to continue this chat." +const REASON = 'Saved by a newer Orca. Update Orca to continue this chat.' + +const PENDING_APPROVAL: AgentJournalRenderItem = { + itemId: 'approval-item', + revision: 1, + sequence: 1, + observedAt: 1, + body: { + kind: 'approval', + title: 'Allow command?', + detail: 'pnpm test', + options: [{ id: 'allow', label: 'Allow' }], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + } +} function renderSession() { return render( @@ -44,18 +58,22 @@ function renderSession() { ) } -it('says up front that a newer Orca saved the chat and that updating continues it', () => { +it('locks the composer with the reason as its placeholder, in place of a card it would refuse', () => { mocks.readOnly = 'written-by-newer-orca' + mocks.promptItems = [PENDING_APPROVAL] renderSession() - expect(screen.getByText(NOTICE).getAttribute('role')).toBe('status') + expect(screen.getByTestId('structured-composer')).toBeTruthy() + expect(mocks.composerProps).toMatchObject({ canSend: false, lockReason: REASON }) + expect(document.querySelector('[data-native-chat-approval-card-mock]')).toBeNull() + expect(screen.queryByText(REASON)).toBeNull() }) -it('says nothing on a writable chat, or for a reason this client cannot word', () => { +it('leaves the composer open on a writable chat, or for a reason this client cannot word', () => { renderSession() - expect(screen.queryByText(/saved by a newer Orca/)).toBeNull() + expect(mocks.composerProps).toMatchObject({ canSend: true, lockReason: undefined }) cleanup() // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: stands in for an arm a newer host could publish, which this build's type cannot name. mocks.readOnly = 'a-later-reason' as 'written-by-newer-orca' renderSession() - expect(screen.queryByText(/saved by a newer Orca/)).toBeNull() + expect(mocks.composerProps).toMatchObject({ canSend: true, lockReason: undefined }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx index b6c8db405d0f..2a800192c36a 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx @@ -85,6 +85,8 @@ export function createStructuredSessionMocks() { messages: null as null | unknown[], messageListProps: initialMessageListProps, composerProps: nullable<{ + canSend?: boolean + lockReason?: string launchSeed?: NativeChatLaunchSeed structuredTransport?: Record isWorking?: boolean diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index df9eaefda8a6..941556a8b107 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -31,6 +31,7 @@ import { structuredAgentSessionReadFailureNotice } from './structured-agent-sess import { useStructuredAgentSessionStartFailureFacts } from './use-structured-agent-session-start-failure-facts' import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices' import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' +import { agentSessionReadOnlyText } from './agent-session-write-notice-text' const NO_SUBMISSIONS: readonly AgentJournalSubmission[] = [] @@ -160,7 +161,9 @@ export function NativeChatStructuredSession( rootRef, { sessionId: props.sessionId, isVisible: props.isVisible } ) - const prompt = controller.prompts[0] ?? null + const readOnlyText = agentSessionReadOnlyText(controller.readOnly) + // A read-only chat refuses every answer, so the composer says why in place of a prompt card. + const prompt = readOnlyText ? null : (controller.prompts[0] ?? null) const approvalBody = prompt?.body.kind === 'approval' ? prompt.body : null const approval = approvalBody ? { @@ -325,7 +328,6 @@ export function NativeChatStructuredSession( viewState.kind === 'error' || !readFailure?.named ? controller.error : readFailure.text } reconnecting={viewState.kind !== 'error' && readFailure !== null && !readFailure.named} - readOnly={controller.readOnly} composerError={composerError} isVisible={props.isVisible} backgroundTasks={controller.backgroundTasks} @@ -394,7 +396,8 @@ export function NativeChatStructuredSession( paneKey={paneKey} targetPtyId={null} agent={props.agent} - canSend={!prompt} + canSend={!readOnlyText} + lockReason={readOnlyText} isWorking={controller.canStop} onStop={() => void controller.stop()} steerQueued={controller.queuedMessages.steerNewest} diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSessionStatus.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSessionStatus.tsx index 78e31349abfd..c6f8316444f2 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSessionStatus.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSessionStatus.tsx @@ -4,11 +4,6 @@ import { useDelayedStatus } from '@/hooks/use-delayed-status' import { NativeChatBackgroundTasksStatus } from './NativeChatBackgroundTasksStatus' import type { StructuredSessionBackgroundTasksView } from './structured-session-background-tasks-view' import { useStructuredSessionChildRowContext } from './use-structured-session-child-row-context' -import { - agentSessionReadOnlyNoticeParts, - type AgentSessionReadOnlyReason -} from '../../../../shared/agent-session-read-only' -import { agentSessionWriteNoticeText } from './agent-session-write-notice-text' type StoppingBackgroundTasks = { sessionId: string @@ -33,8 +28,6 @@ export function NativeChatStructuredSessionStatus(props: { error: string | null /** A read the pane is reconnecting on its own: said plainly, not as an error. */ reconnecting?: boolean - /** Why the host keeps this chat read-only: said up front, before a send is refused. */ - readOnly?: AgentSessionReadOnlyReason composerError: string | null isVisible: boolean backgroundTasks: StructuredSessionBackgroundTasksView @@ -49,7 +42,6 @@ export function NativeChatStructuredSessionStatus(props: { SLOW_STARTUP_NOTICE_DELAY_MS ) const childRowContext = useStructuredSessionChildRowContext(props.paneKey) - const readOnlyParts = agentSessionReadOnlyNoticeParts(props.readOnly) const onStop = (taskId?: string) => { const sessionId = props.sessionId @@ -92,14 +84,6 @@ export function NativeChatStructuredSessionStatus(props: { )}

) : null} - {readOnlyParts ? ( -

- {agentSessionWriteNoticeText(readOnlyParts)} -

- ) : null} {props.reconnecting && !props.error ? (

{translate('components.native-chat.state.reconnecting', 'Reconnecting to this chat…')} diff --git a/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts b/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts index e088e4d432c5..3bcadbb1aba0 100644 --- a/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts +++ b/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts @@ -4,6 +4,10 @@ import { translate } from '@/i18n/i18n' import { agentSessionFailureSentence } from '../../../../shared/agent-session-failure-words' import { agentSessionWriteNoticeParts } from '../../../../shared/agent-session-refusal-notice' +import { + agentSessionReadOnlyNoticeParts, + type AgentSessionReadOnlyReason +} from '../../../../shared/agent-session-read-only' import { AGENT_SESSION_WRITE_NOTICE_COPY as COPY, type AgentSessionWriteNoticePart, @@ -54,11 +58,8 @@ const SENTENCES: Record string> = { 'components.native-chat.writeNotice.updateOrcaToKeepUsing', COPY.updateOrcaToKeepUsing ), - chatReadOnlyNewerOrca: () => - translate( - 'components.native-chat.writeNotice.chatReadOnlyNewerOrca', - COPY.chatReadOnlyNewerOrca - ), + chatSavedByNewerOrca: () => + translate('components.native-chat.writeNotice.chatSavedByNewerOrca', COPY.chatSavedByNewerOrca), updateOrcaToContinueChat: () => translate( 'components.native-chat.writeNotice.updateOrcaToContinueChat', @@ -155,3 +156,11 @@ export function agentSessionWriteFailureText( ): string { return agentSessionWriteNoticeText(agentSessionWriteNoticeParts(failure, write)) } + +/** Why the host keeps a chat read-only, in words; none for a reason this client cannot word. */ +export function agentSessionReadOnlyText( + reason: AgentSessionReadOnlyReason | undefined +): string | undefined { + const parts = agentSessionReadOnlyNoticeParts(reason) + return parts ? agentSessionWriteNoticeText(parts) : undefined +} diff --git a/src/renderer/src/components/native-chat/native-chat-composer-target.test.ts b/src/renderer/src/components/native-chat/native-chat-composer-target.test.ts new file mode 100644 index 000000000000..c2c4ad9ae3c3 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-composer-target.test.ts @@ -0,0 +1,12 @@ +import { expect, it } from 'vitest' +import { nativeChatComposerPlaceholder } from './native-chat-composer-target' + +it("says a locked composer's reason when one is given, else that another device holds the input", () => { + expect(nativeChatComposerPlaceholder(true, false, 'Saved by a newer Orca.')).toBe( + 'Saved by a newer Orca.' + ) + expect(nativeChatComposerPlaceholder(true, false)).toBe('Input is held by another device.') + expect(nativeChatComposerPlaceholder(true, true, 'Saved by a newer Orca.')).toBe( + 'Send a message…' + ) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-composer-target.ts b/src/renderer/src/components/native-chat/native-chat-composer-target.ts index c88882f7ced3..8c908e655db2 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-target.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-target.ts @@ -11,7 +11,11 @@ export type NativeChatResolvedTarget = { * pathological clipboard can't stall the round-trip. */ export const NATIVE_CHAT_CONTEXT_PASTE_MAX_BYTES = 16 * 1024 * 1024 -export function nativeChatComposerPlaceholder(hasPty: boolean, canSend: boolean): string { +export function nativeChatComposerPlaceholder( + hasPty: boolean, + canSend: boolean, + lockReason?: string +): string { if (!hasPty) { return translate( 'components.native-chat.composer.noPty', @@ -19,7 +23,10 @@ export function nativeChatComposerPlaceholder(hasPty: boolean, canSend: boolean) ) } if (!canSend) { - return translate('components.native-chat.composer.locked', 'Input is held by another device.') + return ( + lockReason ?? + translate('components.native-chat.composer.locked', 'Input is held by another device.') + ) } return translate('components.native-chat.composer.placeholder', 'Send a message…') } diff --git a/src/renderer/src/components/native-chat/native-chat-composer-types.ts b/src/renderer/src/components/native-chat/native-chat-composer-types.ts index 922d16924f3f..8c9a7fdd0620 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-types.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-types.ts @@ -55,6 +55,8 @@ export type NativeChatComposerProps = { agent: AgentType /** Guard desktop sends while a mobile client owns the terminal input lease. */ canSend?: boolean + /** What the placeholder says while `canSend` is false; absent, another device holds the input. */ + lockReason?: string /** True while the hosted TUI reports an in-flight turn; swaps Send to Stop. */ isWorking?: boolean /** Interrupt the hosted agent, usually by sending ESC into the PTY. */ diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 18b514c3eb82..36e9820d78b5 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2838,7 +2838,7 @@ "backgroundTasksRunning": "Background tasks are still running.", "capacity": "Orca has received too many requests in the last day.", "chatNotFound": "The Orca running this chat has no record of it.", - "chatReadOnlyNewerOrca": "This chat was saved by a newer Orca, so it's read-only here.", + "chatSavedByNewerOrca": "Saved by a newer Orca.", "clearUnfinished": "The last /clear didn't finish.", "commandRunning": "A /compact or /clear is still running.", "conversationCleared": "This conversation has been cleared.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 6a72ab3aa9b5..fbae0bd77f26 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17572,7 +17572,7 @@ "historyUnavailable": "Orca couldn't open this chat's history right now.", "savedByNewerOrca": "Chats were saved by a newer Orca.", "updateOrcaToKeepUsing": "Update Orca to keep using them.", - "chatReadOnlyNewerOrca": "This chat was saved by a newer Orca, so it's read-only here.", + "chatSavedByNewerOrca": "Saved by a newer Orca.", "updateOrcaToContinueChat": "Update Orca to continue this chat.", "unsupported": "The Orca running this chat doesn't support this. Update Orca, then try again.", "unreachable": "Orca couldn't reach the agent.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 9b2940574360..cb9b7eb0e1fc 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -15015,7 +15015,7 @@ "historyUnavailable": "Orca no pudo abrir el historial de este chat en este momento.", "savedByNewerOrca": "Los chats se guardaron con una versión más reciente de Orca.", "updateOrcaToKeepUsing": "Actualice Orca para seguir usándolos.", - "chatReadOnlyNewerOrca": "Este chat se guardó con una versión más reciente de Orca, así que aquí es de solo lectura.", + "chatSavedByNewerOrca": "Guardado con una versión más reciente de Orca.", "updateOrcaToContinueChat": "Actualice Orca para continuar este chat.", "unsupported": "La versión de Orca que ejecuta este chat no lo admite. Actualice Orca y vuelva a intentarlo.", "unreachable": "Orca no pudo comunicarse con el agente.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 16ddeb7527c1..86cd383e7453 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17481,7 +17481,7 @@ "historyUnavailable": "Orca n'a pas pu ouvrir l'historique de ce chat pour le moment.", "savedByNewerOrca": "Les chats ont été enregistrés par une version plus récente d'Orca.", "updateOrcaToKeepUsing": "Mettez à jour Orca pour continuer à les utiliser.", - "chatReadOnlyNewerOrca": "Ce chat a été enregistré par une version plus récente d'Orca ; il est donc en lecture seule ici.", + "chatSavedByNewerOrca": "Enregistré par une version plus récente d'Orca.", "updateOrcaToContinueChat": "Mettez à jour Orca pour poursuivre ce chat.", "unsupported": "L'instance d'Orca qui exécute ce chat ne le prend pas en charge. Mettez Orca à jour, puis réessayez.", "unreachable": "Orca n'a pas pu joindre l'agent.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 93299d8d8238..5d4cdf7d29a8 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17417,7 +17417,7 @@ "historyUnavailable": "Orca は現在このチャットの履歴を開けませんでした。", "savedByNewerOrca": "チャットは新しいバージョンの Orca で保存されました。", "updateOrcaToKeepUsing": "引き続き使用するには、Orca を更新してください。", - "chatReadOnlyNewerOrca": "このチャットは新しいバージョンの Orca で保存されたため、ここでは読み取り専用です。", + "chatSavedByNewerOrca": "新しいバージョンの Orca で保存されました。", "updateOrcaToContinueChat": "このチャットを続けるには、Orca を更新してください。", "unsupported": "このチャットを実行している Orca はこの操作に対応していません。Orca を更新してから、もう一度お試しください。", "unreachable": "Orca は Agent に接続できませんでした。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 316e5a639098..f53aa2f22655 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17417,7 +17417,7 @@ "historyUnavailable": "Orca가 지금 이 채팅의 기록을 열지 못했습니다.", "savedByNewerOrca": "채팅이 최신 버전의 Orca에서 저장되었습니다.", "updateOrcaToKeepUsing": "계속 사용하려면 Orca를 업데이트하세요.", - "chatReadOnlyNewerOrca": "이 채팅은 최신 버전의 Orca에서 저장되어 여기서는 읽기 전용입니다.", + "chatSavedByNewerOrca": "최신 버전의 Orca에서 저장되었습니다.", "updateOrcaToContinueChat": "이 채팅을 계속하려면 Orca를 업데이트하세요.", "unsupported": "이 채팅을 실행 중인 Orca가 이 기능을 지원하지 않습니다. Orca를 업데이트한 후 다시 시도하세요.", "unreachable": "Orca가 에이전트에 연결하지 못했습니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 9fd68ee72a19..3975dbdca71a 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17382,7 +17382,7 @@ "historyUnavailable": "Orca 目前无法打开此聊天的历史记录。", "savedByNewerOrca": "这些聊天由更新版本的 Orca 保存。", "updateOrcaToKeepUsing": "请更新 Orca 以继续使用这些聊天。", - "chatReadOnlyNewerOrca": "此聊天由更新版本的 Orca 保存,因此在此处为只读。", + "chatSavedByNewerOrca": "由更新版本的 Orca 保存。", "updateOrcaToContinueChat": "请更新 Orca 以继续此聊天。", "unsupported": "运行此聊天的 Orca 不支持此操作。请更新 Orca 后重试。", "unreachable": "Orca 无法连接到智能体。", diff --git a/src/shared/agent-session-read-only.ts b/src/shared/agent-session-read-only.ts index 3500a19f7781..5977b9723523 100644 --- a/src/shared/agent-session-read-only.ts +++ b/src/shared/agent-session-read-only.ts @@ -13,6 +13,6 @@ export function agentSessionReadOnlyNoticeParts( reason: AgentSessionReadOnlyReason | undefined ): AgentSessionWriteNoticeSentence[] | null { return reason === 'written-by-newer-orca' - ? ['chatReadOnlyNewerOrca', 'updateOrcaToContinueChat'] + ? ['chatSavedByNewerOrca', 'updateOrcaToContinueChat'] : null } diff --git a/src/shared/agent-session-write-notice-copy.ts b/src/shared/agent-session-write-notice-copy.ts index 15bb611b65fe..ccc9ea00460f 100644 --- a/src/shared/agent-session-write-notice-copy.ts +++ b/src/shared/agent-session-write-notice-copy.ts @@ -33,7 +33,7 @@ export const AGENT_SESSION_WRITE_NOTICE_COPY = { historyUnavailable: "Orca couldn't open this chat's history right now.", savedByNewerOrca: 'Chats were saved by a newer Orca.', updateOrcaToKeepUsing: 'Update Orca to keep using them.', - chatReadOnlyNewerOrca: "This chat was saved by a newer Orca, so it's read-only here.", + chatSavedByNewerOrca: 'Saved by a newer Orca.', updateOrcaToContinueChat: 'Update Orca to continue this chat.', unsupported: "The Orca running this chat doesn't support this. Update Orca, then try again.", unreachable: "Orca couldn't reach the agent.", diff --git a/tests/e2e/cross-version-wire/agent-session-read-only-notice.unit.test.ts b/tests/e2e/cross-version-wire/agent-session-read-only.unit.test.ts similarity index 84% rename from tests/e2e/cross-version-wire/agent-session-read-only-notice.unit.test.ts rename to tests/e2e/cross-version-wire/agent-session-read-only.unit.test.ts index 9aa28b377bc5..86b53a8a78d2 100644 --- a/tests/e2e/cross-version-wire/agent-session-read-only-notice.unit.test.ts +++ b/tests/e2e/cross-version-wire/agent-session-read-only.unit.test.ts @@ -32,9 +32,9 @@ import { /** * A host that keeps a chat read-only now says why on every whole page (`page.readOnly`), and this - * build keeps a body of a kind it does not know read-only instead of deleting from it. Both meet - * builds that predate them: an older client is sent the new field (Rule 1), and an older host - * opens a journal holding a newer body kind. + * build keeps a body holding a value outside a closed set it knows (a body kind, a nested literal) + * read-only instead of deleting from it. Both meet builds that predate them: an older client is + * sent the new field (Rule 1), and an older host opens a journal holding such a body. */ const SUITE_TIMEOUT_MS = 180_000 // A main build that shares this one's host database and schema version, so a downgrade to it opens @@ -43,7 +43,7 @@ const WRITABLE_BASELINE_REF = '3727100cc9dbcea6201f8a3e506676a3c4b53b18' const JOURNAL = 'src/main/native-chat/agent-session-journal' const IDENTITY: AgentSessionJournalIdentity = { - sessionId: 'session-read-only-notice', + sessionId: 'session-read-only', workspaceId: 'ws-1', hostId: 'host-1', agent: 'codex', @@ -56,7 +56,7 @@ let directory: string const journals = createTrackedJournalOpener() beforeAll(() => { - directory = mkdtempSync(join(tmpdir(), 'orca-read-only-notice-xv-')) + directory = mkdtempSync(join(tmpdir(), 'orca-read-only-xv-')) }) afterAll(async () => { @@ -74,8 +74,20 @@ function releaseExport(module: Record, name: string): T { return value as T } -/** This build's journal of one item, then a body of a kind this build does not know: closed. */ -async function journalWithNewerBody(): Promise<{ rows: string[]; newer: string }> { +const NEWER_BODY_KIND = { kind: 'plan-card', steps: [{ text: 'by a newer build' }] } +const NEWER_NESTED_LITERAL = { + kind: 'approval', + title: 'Approve the change?', + detail: null, + options: [{ id: 'yes', label: 'Yes' }], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null }, + subject: { kind: 'diff', path: 'a.ts' } +} + +/** This build's journal of one item, then `body` as a newer build wrote it: closed. */ +async function journalWithNewerBody( + body: Record = NEWER_BODY_KIND +): Promise<{ rows: string[]; newer: string }> { const journal = await journals.open({ identity: IDENTITY, stateDirectory: directory }) await journal.appendItem( { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 }, @@ -92,7 +104,7 @@ async function journalWithNewerBody(): Promise<{ rows: string[]; newer: string } ts: 2_000, itemId: 'codex:thread-1:turn-1:1', revision: 1, - body: { kind: 'plan-card', steps: [{ text: 'by a newer build' }] } + body }) await journals.closeAll() insertTestJournalRowJson( @@ -172,12 +184,15 @@ describe('a chat a newer Orca saved, across versions', () => { // Why a new body kind ships its reader first, or rides a bumped `v`: this build keeps one and goes // read-only, but a build from before deletes the journal from it. Move the pinned build to the // first release with this rule, and the older build keeps the row too. - it( - "this build keeps a newer build's body kind and goes read-only; a build before it deletes it", - async () => { + it.each([ + ['body kind', NEWER_BODY_KIND], + ['nested literal', NEWER_NESTED_LITERAL] + ])( + "this build keeps a newer build's %s and goes read-only; a build before it deletes it", + async (_value, body) => { rmSync(directory, { recursive: true, force: true }) - directory = mkdtempSync(join(tmpdir(), 'orca-read-only-notice-xv-')) - const { rows, newer } = await journalWithNewerBody() + directory = mkdtempSync(join(tmpdir(), 'orca-read-only-xv-')) + const { rows, newer } = await journalWithNewerBody(body) const reopened = await journals.open({ identity: IDENTITY, stateDirectory: directory }) expect(reopened.isReadOnly).toBe(true) await journals.closeAll() From fed6129d3d5b714055e322c53b5d78b543dc2526 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 00:56:41 -0700 Subject: [PATCH 05/52] fix(native-chat): a read-only chat pages back through its history A read-only journal answered every history request with a reset, so a chat this PR now keeps read-only more often could show only its newest page. Backward reads now serve the snapshot the open folded, as the first page already does; a forward read of rows still resets. --- .../agent-session-history-page.ts | 5 ++-- .../agent-session-read-only-page.test.ts | 24 +++++++++++++++++++ ...ed-agent-session-queued-newer-orca.test.ts | 4 ++-- 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/agent-session-history-page.ts b/src/main/native-chat/agent-session-wire/agent-session-history-page.ts index 913bb4dae9cd..abdaf2b3ce5f 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-history-page.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-history-page.ts @@ -95,9 +95,8 @@ function readHistory( snapshot: AgentJournalSnapshot, scope: AgentSessionHistoryScope ): AgentSessionHistoryResult { - if (journal.isReadOnly) { - return historyReset(snapshot, 'schema_unreadable') - } + // A read-only journal serves backward pages from the snapshot its open folded, as its first page + // does; only a forward read of its rows resets (journal-cursor.ts). const limit = resolveHistoryLimit(request.limit) if (request.direction === 'after') { return readForward(journal, snapshot, request.cursor, limit) diff --git a/src/main/native-chat/agent-session-wire/agent-session-read-only-page.test.ts b/src/main/native-chat/agent-session-wire/agent-session-read-only-page.test.ts index 1e7e4012127c..bc8ecc89862b 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-read-only-page.test.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-read-only-page.test.ts @@ -146,6 +146,30 @@ describe('a chat the host keeps read-only', () => { }) }) +it('pages back through a read-only chat; only a forward read of its rows resets', async () => { + const journal = await reopenedAfter( + () => ({ v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, kind: 'future-mark' }), + ['first', 'second'] + ) + expect(journal.isReadOnly).toBe(true) + const request = { sessionId: IDENTITY.sessionId, limit: 1 } + const tail = readAgentSessionHistory(journal, { ...request, direction: 'tail' }) + expect(tail).toMatchObject({ ok: true, page: { hasOlder: true } }) + const older = readAgentSessionHistory(journal, { + ...request, + direction: 'before', + cursor: tail.page.window.nextCursor + }) + expect(older).toMatchObject({ ok: true, page: { readOnly: 'written-by-newer-orca' } }) + expect(older.page.items.map((item) => item.body)).toEqual([{ kind: 'status', text: 'first' }]) + const after = readAgentSessionHistory(journal, { + ...request, + direction: 'after', + cursor: { epoch: journal.epoch, sequence: 0 } + }) + expect(after).toMatchObject({ ok: false, reset: 'schema_unreadable' }) +}) + it('says nothing on a chat that takes writes', async () => { const journal = await reopenedAfter(() => ({ ...ITEM, body: { kind: 'status', text: 'later' } })) expect(journal.isReadOnly).toBe(false) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-newer-orca.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-newer-orca.test.ts index 2dd2f567b879..a57719c7e446 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-newer-orca.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-newer-orca.test.ts @@ -42,10 +42,10 @@ async function reopenOnNewerOrcaDatabase(close: () => Promise): Promise Object.defineProperty(openTestJournalHostDatabase(rig.root), 'readOnly', { value: true }) } -/** A read-only journal's history answers as a `schema_unreadable` reset carrying its page. */ +/** A read-only journal's history page, which says the chat is read-only. */ async function readOnlyQueue() { const answer = await rig.host.history({ sessionId: HOST_TEST_SESSION, direction: 'tail' }) - expect(answer).toMatchObject({ ok: false, reset: 'schema_unreadable' }) + expect(answer).toMatchObject({ ok: true, page: { readOnly: 'written-by-newer-orca' } }) const page = 'page' in answer ? answer.page : undefined return { cards: (page?.queuedMessages ?? []).map(({ messageId, state }) => ({ messageId, state })), From 9745a4ea7264908b1dc34e8807320972018ce7ff Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 00:59:18 -0700 Subject: [PATCH 06/52] test(native-chat): an unknown key in a question entry is read and kept --- src/shared/agent-session-journal-body-admission.test.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/shared/agent-session-journal-body-admission.test.ts b/src/shared/agent-session-journal-body-admission.test.ts index 67d8696f2b44..76386ef81fc4 100644 --- a/src/shared/agent-session-journal-body-admission.test.ts +++ b/src/shared/agent-session-journal-body-admission.test.ts @@ -49,8 +49,13 @@ describe('a body this build reads', () => { ['a turn lifecycle', TURN_STATUS], ['a goal change', { kind: 'status', text: 'Goal', threadGoal: { state: 'set', goal: GOAL } }], [ - 'a key it does not know, in a prompt option too', - { ...QUESTION, next: 1, options: [{ id: 'o-1', label: 'First', shortcut: 'f' }] } + 'a key it does not know, in a question and an option too', + { + ...QUESTION, + next: 1, + options: [{ id: 'o-1', label: 'First', shortcut: 'f' }], + questions: [{ ...QUESTION_ENTRY, hint: 'Pick one' }] + } ], [ 'a block type it does not know', From 918d9629d78ac029470b44473b769bc3878b2bd5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 01:01:41 -0700 Subject: [PATCH 07/52] test(mobile): the read-only overlay test typechecks --- mobile/src/session/mobile-native-chat-read-only.test.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mobile/src/session/mobile-native-chat-read-only.test.tsx b/mobile/src/session/mobile-native-chat-read-only.test.tsx index 65a318853219..c253bee5cef1 100644 --- a/mobile/src/session/mobile-native-chat-read-only.test.tsx +++ b/mobile/src/session/mobile-native-chat-read-only.test.tsx @@ -120,6 +120,7 @@ function overlayElement( } as unknown as MobileNativeChatController return createElement(MobileNativeChatOverlay, { controller, + onOpenFile: vi.fn(), images: {} as never, onMicPress: vi.fn(), micActive: false, @@ -139,7 +140,8 @@ function chatViewProps(element: ReturnType): Record { renderer = create(element) }) - const props = renderer!.root.findByType('ChatView' as never).props + const [view] = renderer!.root.findAll((node) => String(node.type) === 'ChatView') + const props: Record = view!.props act(() => renderer?.unmount()) renderer = null return props From a6f5d8c7450cc898db488f0ca98c08534bd0981b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 01:02:54 -0700 Subject: [PATCH 08/52] chore(native-chat): satisfy the changed-code gate in admission and the read-only overlay test --- .../mobile-native-chat-read-only.test.tsx | 8 ++++- .../agent-session-journal-body-admission.ts | 29 +++++++++---------- 2 files changed, 21 insertions(+), 16 deletions(-) diff --git a/mobile/src/session/mobile-native-chat-read-only.test.tsx b/mobile/src/session/mobile-native-chat-read-only.test.tsx index c253bee5cef1..5a2daf19dc41 100644 --- a/mobile/src/session/mobile-native-chat-read-only.test.tsx +++ b/mobile/src/session/mobile-native-chat-read-only.test.tsx @@ -121,7 +121,13 @@ function overlayElement( return createElement(MobileNativeChatOverlay, { controller, onOpenFile: vi.fn(), - images: {} as never, + images: { + attachments: [], + isAttaching: false, + attachImage: vi.fn(async () => {}), + removeAttachment: vi.fn(), + sendNativeChat: vi.fn(async () => true) + }, onMicPress: vi.fn(), micActive: false, dictationMode: 'toggle', diff --git a/src/shared/agent-session-journal-body-admission.ts b/src/shared/agent-session-journal-body-admission.ts index 2e6d82802b9f..aeb2a20dc6fd 100644 --- a/src/shared/agent-session-journal-body-admission.ts +++ b/src/shared/agent-session-journal-body-admission.ts @@ -31,22 +31,21 @@ export function readAgentJournalMessageBody(body: unknown): AgentJournalContentV * changed one. A plain union's branches are all searched: each in the body schemas pairs a * discriminated union with an open fallback, whose failure is never a closed-set one. */ function isOutsideClosedSet(issue: z.core.$ZodIssue): boolean { - switch (issue.code) { - case 'invalid_value': - return ( - issue.values.some((known) => typeof known === 'string') && isNewClosedSetValue(issue.input) - ) - case 'invalid_union': - if (issue.discriminator !== undefined) { - return isPlainObject(issue.input) && isNewClosedSetValue(issue.input[issue.discriminator]) - } - return issue.errors.some((branch) => branch.some(isOutsideClosedSet)) - case 'invalid_key': - case 'invalid_element': - return issue.issues.some(isOutsideClosedSet) - default: - return false + if (issue.code === 'invalid_value') { + return ( + issue.values.some((known) => typeof known === 'string') && isNewClosedSetValue(issue.input) + ) } + if (issue.code === 'invalid_union') { + if (issue.discriminator !== undefined) { + return isPlainObject(issue.input) && isNewClosedSetValue(issue.input[issue.discriminator]) + } + return issue.errors.some((branch) => branch.some(isOutsideClosedSet)) + } + if (issue.code === 'invalid_key' || issue.code === 'invalid_element') { + return issue.issues.some(isOutsideClosedSet) + } + return false } function isNewClosedSetValue(value: unknown): boolean { From acfb8325a304c9afc50ff568a6e680c865ecb3a7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 01:24:55 -0700 Subject: [PATCH 09/52] fix(native-chat): a new value inside a known block or goal arm reads as a newer build's The open fallback arm of the block and goal unions now aborts, so zod reports the known arm's own failure instead of only the fallback's; a future closed set there reads unreadable, damage there still repairs. The schema headers name the context-usage exception and say a new open-string value must be safe for every older build (rewind keeps only known states; pages carry no row version). --- .../journal-row-schema.ts | 9 ++++--- .../journal-unknown-content.test.ts | 23 +++++++++++++++++ ...ent-session-journal-body-admission.test.ts | 25 ++++++++++++++++--- .../agent-session-journal-body-admission.ts | 17 ++++++++++--- src/shared/agent-session-journal-schemas.ts | 23 +++++++++++------ 5 files changed, 78 insertions(+), 19 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/journal-row-schema.ts b/src/main/native-chat/agent-session-journal/journal-row-schema.ts index 59fe2ed18021..adb2b8d2d3f8 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-schema.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-schema.ts @@ -13,10 +13,11 @@ // reader first and is written only once no supported build lacks that reader, // or is written at a bumped `v`. // -// Inside a known row the same holds a level down. A lifecycle mutation of a kind this build does -// not know, or a body holding a value outside a closed set it knows (a body kind, a nested -// discriminant, a literal), is UNREADABLE, whatever else in the row fails. Any other failure is -// damage, which the open repairs. Builds from before this rule delete the journal from such a +// Inside a known row with that envelope the same holds a level down. A lifecycle mutation of a kind +// this build does not know, or a body holding a value outside a closed set it knows (a body kind, +// a nested discriminant, a literal), is UNREADABLE, whatever else in the row fails; a turn's +// context usage is the exception, dropped like the other unusable annotations. Any other failure +// is damage, which the open repairs. Builds from before this rule delete the journal from such a // row, so the same reader-first-or-`v` rule applies to a new value in a closed set. import type { AgentSessionFailureFact } from '../../../shared/agent-session-failure' diff --git a/src/main/native-chat/agent-session-journal/journal-unknown-content.test.ts b/src/main/native-chat/agent-session-journal/journal-unknown-content.test.ts index 49906af2d5d7..ebe38219fd9f 100644 --- a/src/main/native-chat/agent-session-journal/journal-unknown-content.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-unknown-content.test.ts @@ -216,6 +216,29 @@ describe("a newer build's content inside a known row", () => { }) }) +describe("a turn's context usage of a newer shape", () => { + it('is dropped like an unusable annotation: the chat stays writable and keeps the row', async () => { + const { written } = await journalWith((epoch) => [ + itemRow(epoch, 'codex:thread-1:turn-1:2', { + kind: 'turn', + turnId: 'turn-1', + state: 'completed', + contextUsage: { used: { kind: 'measured', capturedAt: 1 } } + }) + ]) + const journal = await open() + expect(journal.isReadOnly).toBe(false) + expect(journal.repair).toEqual({ malformedRows: 0 }) + expect(journal.snapshot().items[2]?.body).toEqual({ + kind: 'turn', + turnId: 'turn-1', + state: 'completed' + }) + await journals.closeAll() + expect(stored()).toEqual(written) + }) +}) + describe('damage', () => { it.each([ ['a broken required field', { kind: 'diff', path: 'a.ts', patch: { head: 'x' } }], diff --git a/src/shared/agent-session-journal-body-admission.test.ts b/src/shared/agent-session-journal-body-admission.test.ts index 76386ef81fc4..7189326d89d3 100644 --- a/src/shared/agent-session-journal-body-admission.test.ts +++ b/src/shared/agent-session-journal-body-admission.test.ts @@ -1,8 +1,11 @@ import { describe, expect, it } from 'vitest' +import { z } from 'zod' import { + readAgentJournalContent, readAgentJournalItemBody, readAgentJournalMessageBody } from './agent-session-journal-body-admission' +import { unknownDiscriminantArm } from './agent-session-journal-schemas' const RESOLUTION = { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } const QUESTION_ENTRY = { @@ -75,16 +78,30 @@ describe("a value outside a closed set this build knows: a newer build's", () => it.each([ ['a body kind', { kind: 'plan-card', steps: [{ text: 'by a newer build' }] }], ['a nested literal', { ...PLAN_APPROVAL, subject: { kind: 'diff', path: 'a.ts' } }], - [ - 'a nested discriminant', - { kind: 'turn', turnId: 't', state: 'done', contextUsage: { used: { kind: 'measured' } } } - ], ['one beside damage', { ...PLAN_APPROVAL, title: 5, subject: { kind: 'diff' } }] ])('is unreadable: %s', (_name, body) => { expect(readAgentJournalItemBody(body)).toBe('unreadable') }) }) +describe('a closed set inside a known arm of an open union, as blocks and goal changes are built', () => { + const OpenBlock = z.union([ + z.discriminatedUnion('type', [ + z.object({ type: z.literal('text'), text: z.string(), tone: z.enum(['info']).optional() }) + ]), + unknownDiscriminantArm('type', new Set(['text'])) + ]) + + it("reads a new value there as a newer build's, damage there as damage", () => { + expect(readAgentJournalContent(OpenBlock, { type: 'text', text: 'x', tone: 'critical' })).toBe( + 'unreadable' + ) + expect(readAgentJournalContent(OpenBlock, { type: 'text', text: 5 })).toBe('malformed') + expect(readAgentJournalContent(OpenBlock, { type: 'chart' })).toBe('readable') + expect(readAgentJournalContent(OpenBlock, { type: 5 })).toBe('malformed') + }) +}) + describe('anything else that fails: damage', () => { it.each([ ['a body that is not an object', null], diff --git a/src/shared/agent-session-journal-body-admission.ts b/src/shared/agent-session-journal-body-admission.ts index aeb2a20dc6fd..47ef889a9cf1 100644 --- a/src/shared/agent-session-journal-body-admission.ts +++ b/src/shared/agent-session-journal-body-admission.ts @@ -11,11 +11,19 @@ import { export type AgentJournalContentVerdict = 'readable' | 'unreadable' | 'malformed' export function readAgentJournalItemBody(body: unknown): AgentJournalContentVerdict { - const parsed = AgentJournalItemBodySchema.safeParse(body, { reportInput: true }) + return readAgentJournalContent(AgentJournalItemBodySchema, body) +} + +/** The verdict for `value` against any journal schema, by the rule above. */ +export function readAgentJournalContent( + schema: z.ZodType, + value: unknown +): AgentJournalContentVerdict { + const parsed = schema.safeParse(value, { reportInput: true }) if (parsed.success) { return 'readable' } - // A newer build's value anywhere wins over damage beside it: never delete what a newer build wrote. + // A newer build's value anywhere wins over damage beside it: never delete what it wrote. return parsed.error.issues.some(isOutsideClosedSet) ? 'unreadable' : 'malformed' } @@ -28,8 +36,9 @@ export function readAgentJournalMessageBody(body: unknown): AgentJournalContentV } /** A new string where a closed set of strings expects one of its own: the set's type, so never a - * changed one. A plain union's branches are all searched: each in the body schemas pairs a - * discriminated union with an open fallback, whose failure is never a closed-set one. */ + * changed one. zod reports every branch of a plain union only when each branch aborts; the body + * schemas' plain unions pair a discriminated union with an aborting open fallback + * (`unknownDiscriminantArm`), whose own failure is never a closed-set one. */ function isOutsideClosedSet(issue: z.core.$ZodIssue): boolean { if (issue.code === 'invalid_value') { return ( diff --git a/src/shared/agent-session-journal-schemas.ts b/src/shared/agent-session-journal-schemas.ts index 65f781d7c012..2be720b928dd 100644 --- a/src/shared/agent-session-journal-schemas.ts +++ b/src/shared/agent-session-journal-schemas.ts @@ -14,11 +14,14 @@ // newer build must not be misread as malformed (see journal-row-schema.ts). // // A newer writer extends a body only by new keys, new values in a closed set (a kind, a -// discriminant, a literal), or a bumped row `v`, never by changing a field's type. A value outside -// a closed set reads as a newer build's, so the chat goes read-only and nothing is deleted -// (agent-session-journal-body-admission.ts); any other failure is damage. A new value of an open -// string (turn, resolution, tool or goal `state`, a block `type`) reads as-is in older builds, so -// it must be safe for them there or ride a bumped `v`. +// discriminant, a literal), or a bumped row `v`, never by changing a field's type or a bound this +// build checks. A value outside a closed set reads as a newer build's, so the chat goes read-only +// and nothing is deleted (agent-session-journal-body-admission.ts); the one exception is a turn's +// context usage, which is dropped (journal-row-unusable-annotations.ts). Any other failure is +// damage. A new value of an open string (turn, resolution, tool or goal `state`, a block `type`) +// is no newer build's: older builds read it as-is, a rewind keeps only the states it knows +// (structured-rewind-journal-body.ts), and history pages carry no row `v`, so older clients see +// it too. Such a value must be safe for every older build. import { z } from 'zod' import { AgentSessionContextUsageSchema } from './agent-session-context-usage-schema' @@ -59,6 +62,12 @@ const KNOWN_BLOCK_TYPES = new Set([ 'background-task' ]) +/** The arm a discriminant this build does not know reads through. It aborts, so a known arm's + * own failure still reaches the reader (agent-session-journal-body-admission.ts). */ +export function unknownDiscriminantArm(key: string, known: ReadonlySet) { + return z.object({ [key]: z.string() }).refine((v) => !known.has(v[key] ?? ''), { abort: true }) +} + /** Provider IDs are opaque; reject all-whitespace values without rewriting valid IDs. */ const ProviderCallId = z .string() @@ -130,7 +139,7 @@ const Block = z.union([ settledAt: z.number().optional() }) ]), - z.object({ type: z.string() }).refine((block) => !KNOWN_BLOCK_TYPES.has(block.type)) + unknownDiscriminantArm('type', KNOWN_BLOCK_TYPES) ]) const PromptOption = z.object({ @@ -201,7 +210,7 @@ const ThreadGoalState = z.union([ z.object({ state: z.literal('set'), goal: ThreadGoal }), z.object({ state: z.literal('cleared') }) ]), - z.object({ state: z.string() }).refine((value) => !['set', 'cleared'].includes(value.state)) + unknownDiscriminantArm('state', new Set(['set', 'cleared'])) ]) /** Open like `state`: a kind, audience or refusal detail a newer host writes must not turn the row From 922bf0cbe620740e7929eb389513b9ecc33b9eef Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 01:26:15 -0700 Subject: [PATCH 10/52] fix(native-chat): a read-only chat reconnects for a whole page, so an updated host unlocks it The client keeps `readOnly` until a whole page replaces it, but reconnected at its cursor and got only batches, so a desktop attached to a host updated in place stayed locked with "Update Orca". While it holds the latch it now subscribes without a cursor; the snapshot re-derives the state on any host version. The phone already subscribes without one. --- .../structured-agent-session-read-owner.ts | 3 +- .../structured-agent-session-reducer.ts | 4 +- ...t-session-read-only-reconnect.unit.test.ts | 118 ++++++++++++++++++ 3 files changed, 122 insertions(+), 3 deletions(-) create mode 100644 tests/e2e/structured-agent-session-read-only-reconnect.unit.test.ts diff --git a/src/renderer/src/components/native-chat/structured-agent-session-read-owner.ts b/src/renderer/src/components/native-chat/structured-agent-session-read-owner.ts index e3f87a8e95e6..d540a55a5af0 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-read-owner.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-read-owner.ts @@ -224,7 +224,8 @@ function createReadOwner( const transport = startStructuredAgentSessionReadTransport({ applyEvent: (event) => apply({ type: 'event', event }), applyError: (message, refusal) => apply({ type: 'error', message, refusal }), - getCursor: () => snapshot.state.cursor, + // A read-only chat asks for a whole page, the only frame that can say it takes writes again. + getCursor: () => (snapshot.state.readOnly ? null : snapshot.state.cursor), onHistoryReadInvalidated: invalidateOlderPages, hydrate: snapshot.state.epoch === null ? hydrate : undefined, sessionId, diff --git a/src/shared/structured-agent-session-reducer.ts b/src/shared/structured-agent-session-reducer.ts index a69486c2e094..83867c72b36b 100644 --- a/src/shared/structured-agent-session-reducer.ts +++ b/src/shared/structured-agent-session-reducer.ts @@ -75,8 +75,8 @@ export type StructuredAgentSessionState = { /** Bumped per live batch that leaves a turn row's newest revision outside the window * (dropped or trimmed), so a whole-journal answer derived from turn rows is asked for again. */ unloadedTurnRevisions?: number - /** Why the host keeps this chat read-only, from its latest whole page; a host stays read-only - * until it restarts, which sends a new one. */ + /** Why the host keeps this chat read-only, from its latest whole page. Only a whole page clears + * it, so a client holding it reconnects for one. */ readOnly?: AgentSessionReadOnlyReason } diff --git a/tests/e2e/structured-agent-session-read-only-reconnect.unit.test.ts b/tests/e2e/structured-agent-session-read-only-reconnect.unit.test.ts new file mode 100644 index 000000000000..72e4649aab0c --- /dev/null +++ b/tests/e2e/structured-agent-session-read-only-reconnect.unit.test.ts @@ -0,0 +1,118 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { + AgentSessionHistoryRequest, + AgentSessionSubscribeEvent +} from '../../src/shared/agent-session-wire' +import type { AgentJournalCursor } from '../../src/shared/agent-session-journal-types' +import Database from '../../src/main/sqlite/sync-database' +import { + closeTestJournalHostDatabase, + createTrackedJournalOpener +} from '../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' +import { JOURNAL_DB_SCHEMA_VERSION } from '../../src/main/native-chat/agent-session-journal/journal-database-schema' +import type { AgentSessionJournal } from '../../src/main/native-chat/agent-session-journal/journal-store' +import { readAgentSessionHistory } from '../../src/main/native-chat/agent-session-wire/agent-session-history-page' +import { AgentSessionSubscribers } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-subscribers' + +const mocks = vi.hoisted(() => ({ call: vi.fn(), subscribe: vi.fn() })) +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: mocks.call, + subscribeStructuredAgentSession: mocks.subscribe +})) + +import { + getStructuredAgentSessionReadOwner, + resetStructuredAgentSessionReadOwnersForTests +} from '../../src/renderer/src/components/native-chat/structured-agent-session-read-owner' + +const SESSION = 'read-only-reconnect' +const target = { kind: 'local' } as const +const journals = createTrackedJournalOpener() +let root: string + +beforeEach(async () => { + vi.resetAllMocks() + root = await mkdtemp(join(tmpdir(), 'orca-read-only-reconnect-')) +}) +afterEach(async () => { + resetStructuredAgentSessionReadOwnersForTests() + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +function open(): Promise { + return journals.open({ + identity: { + sessionId: SESSION, + workspaceId: 'folder-workspace', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } + }, + stateDirectory: root + }) +} + +/** Reopens the host's journal on a database stamped `version`, as a host restarting would. */ +async function reopenAt(version: number): Promise { + await journals.closeAll() + closeTestJournalHostDatabase(root) + const db = new Database(join(root, 'agent-session-journal.db')) + db.pragma(`user_version = ${version}`) + db.close() + return open() +} + +/** Serves the client from whichever journal the host holds now. */ +function serve(host: { journal: AgentSessionJournal }): void { + mocks.call.mockImplementation((_target, _method, request: AgentSessionHistoryRequest) => + Promise.resolve(structuredClone(readAgentSessionHistory(host.journal, request))) + ) + mocks.subscribe.mockImplementation( + ( + _target, + request: { cursor?: AgentJournalCursor }, + onEvent: (event: AgentSessionSubscribeEvent) => void + ) => + Promise.resolve({ + unsubscribe: new AgentSessionSubscribers().open({ + id: 'pane', + sessionId: SESSION, + journal: host.journal, + fence: 1, + cursor: request.cursor, + emit: (event) => onEvent(structuredClone(event)) + }) + }) + ) +} + +it('reconnects a read-only chat for a whole page, so an updated host unlocks it', async () => { + const first = await open() + await first.appendItem( + { provider: 'orca', clientMessageId: 'hello' }, + { kind: 'status', text: 'hello' }, + { fence: 1 } + ) + const host = { journal: await reopenAt(JOURNAL_DB_SCHEMA_VERSION + 1) } + expect(host.journal.isReadOnly).toBe(true) + serve(host) + const owner = getStructuredAgentSessionReadOwner(SESSION, target) + const unlisten = owner.subscribe(() => {}) + const deactivate = owner.activate() + await vi.waitFor(() => expect(owner.getSnapshot().state.readOnly).toBe('written-by-newer-orca')) + await vi.waitFor(() => expect(mocks.subscribe).toHaveBeenCalledTimes(1)) + deactivate() + + host.journal = await reopenAt(JOURNAL_DB_SCHEMA_VERSION) + expect(host.journal.isReadOnly).toBe(false) + const stop = owner.activate() + await vi.waitFor(() => expect(mocks.subscribe).toHaveBeenCalledTimes(2)) + expect(mocks.subscribe.mock.calls[1]?.[1]).toEqual({ sessionId: SESSION }) + await vi.waitFor(() => expect(owner.getSnapshot().state).not.toHaveProperty('readOnly')) + stop() + unlisten() +}) From 80e832725043e3240242087ae1f0ba2a8f6ac1d8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 01:36:46 -0700 Subject: [PATCH 11/52] fix(native-chat): every desktop write control follows the one read-only fact The structured session's transport state derives one fact from the page's `readOnly`: the words for why the host refuses writes. Every write control reads it. While it holds, the chat has no turn and no work, as the host projects it, so the working row and Stop go away; a pending prompt stays shown above the composer with its card disabled; queued cards' Send now, Delete and Edit, the queue's Resume, the goal banner's actions, the model and option pickers, dictation and background-task Stop are disabled or withheld. The composer placeholder stays the one place the reason is said. `lockReason` now locks the composer by itself. --- .../NativeChatApprovalCard.test.tsx | 26 ++++ .../native-chat/NativeChatApprovalCard.tsx | 11 +- .../native-chat/NativeChatComposer.test.tsx | 134 ++++++++++-------- .../native-chat/NativeChatComposer.tsx | 8 +- .../native-chat/NativeChatComposerActions.tsx | 3 + .../native-chat/NativeChatComposerField.tsx | 3 + .../NativeChatQuestionCard.test.tsx | 22 +++ .../native-chat/NativeChatQuestionCard.tsx | 15 +- .../NativeChatQueuedMessageCard.tsx | 8 +- .../NativeChatQueuedMessageList.test.tsx | 17 +++ .../NativeChatQueuedMessageList.tsx | 9 +- .../NativeChatSessionOptionPickers.test.tsx | 19 +++ .../NativeChatSessionOptionPickers.tsx | 14 +- ...veChatStructuredSession.read-only.test.tsx | 20 +-- ...tiveChatStructuredSession.test-harness.tsx | 4 +- .../NativeChatStructuredSession.tsx | 20 +-- .../native-chat/native-chat-composer-types.ts | 3 +- ...nt-session-queued-messages.resume.test.tsx | 1 + ...red-agent-session-queued-messages.test.tsx | 5 + ...tructured-agent-session-queued-messages.ts | 10 +- ...uctured-agent-session-thread-goal.test.tsx | 9 ++ ...se-structured-agent-session-thread-goal.ts | 6 +- ...red-agent-session-transport-state.test.tsx | 56 ++++++++ ...tructured-agent-session-transport-state.ts | 21 ++- .../use-structured-agent-session.ts | 15 +- 25 files changed, 340 insertions(+), 119 deletions(-) create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-transport-state.test.tsx diff --git a/src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx b/src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx index 150b6452921f..92b548ca3d60 100644 --- a/src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx @@ -7,6 +7,32 @@ import { NativeChatApprovalCard } from './NativeChatApprovalCard' afterEach(cleanup) describe('NativeChatApprovalCard', () => { + it('a disabled card shows the request with nothing to press, and Escape cancels nothing', () => { + const onChoose = vi.fn() + const onCancel = vi.fn() + render( + + ) + expect(screen.getByText('pnpm test')).toBeTruthy() + for (const name of ['Allow', 'Cancel']) { + const button = screen.getByRole('button', { name }) + expect(button.hasAttribute('disabled')).toBe(true) + fireEvent.click(button) + } + fireEvent.keyDown(screen.getByRole('group', { name: 'Allow command?' }), { key: 'Escape' }) + expect(onChoose).not.toHaveBeenCalled() + expect(onCancel).not.toHaveBeenCalled() + }) + it('exposes cancellation while it owns the composer region', () => { const onCancel = vi.fn() diff --git a/src/renderer/src/components/native-chat/NativeChatApprovalCard.tsx b/src/renderer/src/components/native-chat/NativeChatApprovalCard.tsx index 99007d666a32..6a69926bd738 100644 --- a/src/renderer/src/components/native-chat/NativeChatApprovalCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatApprovalCard.tsx @@ -15,6 +15,8 @@ export type NativeChatApprovalCardProps = { /** Cancel the active provider turn while this card owns the composer region. */ onCancel?: () => void shouldFocus?: boolean + /** Shown but not answerable: the host refuses every answer. */ + disabled?: boolean /** A plan body renders as markdown; these make its file paths clickable. */ onLinkClick?: CommentMarkdownLinkClickHandler allowFileUriLinks?: boolean @@ -30,6 +32,7 @@ export function NativeChatApprovalCard({ onChoose, onCancel, shouldFocus = false, + disabled = false, onLinkClick, allowFileUriLinks = false }: NativeChatApprovalCardProps): React.JSX.Element { @@ -58,7 +61,7 @@ export function NativeChatApprovalCard({ aria-label={approval.title} tabIndex={-1} onKeyDown={(event) => { - if (event.key === 'Escape' && !event.nativeEvent.isComposing && onCancel) { + if (event.key === 'Escape' && !event.nativeEvent.isComposing && onCancel && !disabled) { event.preventDefault() event.stopPropagation() onCancel() @@ -76,9 +79,10 @@ export function NativeChatApprovalCard({ {onCancel ? ( @@ -156,9 +160,10 @@ export function NativeChatApprovalCard({ @@ -216,7 +221,7 @@ export function NativeChatQuestionCard({ label={opt.label} description={opt.description} selected={(selections[index] ?? []).includes(i)} - disabled={isSubmitting} + disabled={locked} onSelect={() => pickOption(i)} /> ))} @@ -244,7 +249,7 @@ export function NativeChatQuestionCard({ still be acted on. */} setOther(index, e.target.value)} // Click, not focus: tabbing through the field toward Submit must not @@ -277,7 +282,7 @@ export function NativeChatQuestionCard({ )} @@ -173,6 +176,7 @@ export function NativeChatQueuedMessageCard({ variant="ghost" size="icon-xs" aria-label={translate('components.native-chat.queuedMessages.delete', 'Delete')} + disabled={disabled} onClick={onDelete} > @@ -197,7 +201,7 @@ export function NativeChatQueuedMessageCard({ - + {translate('components.native-chat.queuedMessages.editMessage', 'Edit message')} diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx index 41fd4852a202..6cd1a8477845 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx @@ -58,6 +58,7 @@ function controller( pause, resume: vi.fn(async () => {}), resuming: false, + writable: true, steer: vi.fn(async () => {}), remove: vi.fn(async () => {}), edit: vi.fn(async () => {}), @@ -470,4 +471,20 @@ describe('NativeChatQueuedMessageList', () => { fireEvent.click(await screen.findByRole('menuitem', { name: 'Turn off queueing' })) expect(mocks.updateSettings).toHaveBeenCalledWith({ nativeChatQueueFollowUps: false }) }) + + it('a read-only chat keeps its cards shown, with nothing to press that the host would refuse', async () => { + const owner = { + ...controller([card({ messageId: 'draft-1', hold: 'queue-paused' })], { reason: 'stopped' }), + writable: false + } + renderList(owner) + for (const name of ['Steer', 'Delete', 'Resume']) { + expect(screen.getByRole('button', { name }).hasAttribute('disabled')).toBe(true) + } + fireEvent.pointerDown(screen.getByRole('button', { name: 'More actions' })) + const edit = await screen.findByRole('menuitem', { name: 'Edit message' }) + expect(edit.getAttribute('aria-disabled')).toBe('true') + fireEvent.click(edit) + expect(owner.edit).not.toHaveBeenCalled() + }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx index 17223e0194cb..4d335bcf7e62 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx @@ -45,7 +45,7 @@ export function NativeChatQueuedMessageList({ {pause ? ( refocusAfter(controller.resume())} /> ) : null} @@ -61,6 +61,7 @@ export function NativeChatQueuedMessageList({ key={card.messageId} card={card} showsSteerShortcut={card === newest} + disabled={!controller.writable} onSteer={() => refocusAfter(controller.steer(card.messageId))} onDelete={() => refocusAfter(controller.remove(card.messageId))} onEdit={() => refocusAfter(controller.edit(card.messageId))} @@ -100,11 +101,11 @@ function queuePauseText(pause: { reason: string }): string { function NativeChatQueuePauseRow({ pause, - resuming, + disabled, onResume }: { pause: { reason: string } - resuming: boolean + disabled: boolean onResume: () => void }): React.JSX.Element { const text = queuePauseText(pause) @@ -114,7 +115,7 @@ function NativeChatQueuePauseRow({

{text}

- diff --git a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx index 45d30a315181..9689a23c666b 100644 --- a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx @@ -341,6 +341,25 @@ describe('NativeChatSessionOptionPickers', () => { ).toBe('true') }) + it('keeps both pickers shown but shut while the host refuses every change', () => { + render( + + ) + for (const name of ['Model Opus 4.8', 'Effort High']) { + const trigger = screen.getByRole('button', { name }) + expect(trigger.parentElement?.getAttribute('data-disabled')).toBe('true') + expect(trigger.closest('[data-testid="dropdown-root"]')?.getAttribute('data-open')).not.toBe( + 'true' + ) + } + }) + it('does not duplicate titles for unknown values or misname generic controls', () => { const { rerender } = render( (null) @@ -244,9 +247,10 @@ function NativeChatSessionOptionPickersInner({ if (!surface || !model) { return null } - const requestedModelSequence = pickerRequest?.id === model.id ? pickerRequest.sequence : null - const requestedOptionsSequence = options.some((descriptor) => descriptor.id === pickerRequest?.id) - ? (pickerRequest?.sequence ?? null) + const request = disabled ? null : pickerRequest + const requestedModelSequence = request?.id === model.id ? request.sequence : null + const requestedOptionsSequence = options.some((descriptor) => descriptor.id === request?.id) + ? (request?.sequence ?? null) : null const setOption = (descriptor: SessionOptionDescriptor, value: SessionOptionValue): void => { @@ -273,7 +277,7 @@ function NativeChatSessionOptionPickersInner({ @@ -297,7 +301,7 @@ function NativeChatSessionOptionPickersInner({ diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.read-only.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.read-only.test.tsx index ce07b9411f33..75bbf38aca58 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.read-only.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.read-only.test.tsx @@ -58,22 +58,24 @@ function renderSession() { ) } -it('locks the composer with the reason as its placeholder, in place of a card it would refuse', () => { - mocks.readOnly = 'written-by-newer-orca' +it('locks the composer with the reason as its placeholder, under the pending prompt it cannot answer', () => { + mocks.readOnly = REASON mocks.promptItems = [PENDING_APPROVAL] renderSession() expect(screen.getByTestId('structured-composer')).toBeTruthy() - expect(mocks.composerProps).toMatchObject({ canSend: false, lockReason: REASON }) - expect(document.querySelector('[data-native-chat-approval-card-mock]')).toBeNull() + expect(mocks.composerProps).toMatchObject({ lockReason: REASON }) + expect(document.querySelector('[data-native-chat-approval-card-mock]')).not.toBeNull() + expect(mocks.approvalCardProps).toMatchObject({ disabled: true, shouldFocus: false }) expect(screen.queryByText(REASON)).toBeNull() }) -it('leaves the composer open on a writable chat, or for a reason this client cannot word', () => { +it('leaves a writable chat as it was: the card takes the composer slot, answerable', () => { + mocks.promptItems = [PENDING_APPROVAL] renderSession() - expect(mocks.composerProps).toMatchObject({ canSend: true, lockReason: undefined }) + expect(screen.queryByTestId('structured-composer')).toBeNull() + expect(mocks.approvalCardProps).toMatchObject({ disabled: false }) cleanup() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: stands in for an arm a newer host could publish, which this build's type cannot name. - mocks.readOnly = 'a-later-reason' as 'written-by-newer-orca' + mocks.promptItems = [] renderSession() - expect(mocks.composerProps).toMatchObject({ canSend: true, lockReason: undefined }) + expect(mocks.composerProps).toMatchObject({ lockReason: undefined }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx index 2a800192c36a..e49f7cf9f3ae 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx @@ -5,7 +5,6 @@ import type { QueuedMessageCard } from './structured-agent-session-queued-cards' import type { AgentSessionBackgroundTask } from '../../../../shared/agent-session-wire' import type { AgentSessionWriteRefusal } from '../../../../shared/agent-session-write-failure' import type { AgentSessionRefusalReference } from '../../../../shared/agent-session-wire-refusals' -import type { AgentSessionReadOnlyReason } from '../../../../shared/agent-session-read-only' import type { NativeChatApprovalCardProps } from './NativeChatApprovalCard' import type { NativeChatDeliveryNotice } from './NativeChatMessageRow' import type { NativeChatQuestionCardProps } from './NativeChatQuestionCard' @@ -81,7 +80,7 @@ export function createStructuredSessionMocks() { mode: 'static' as 'static' | 'outbox', status: 'ready' as 'idle' | 'loading' | 'ready' | 'error', readRefusal: absent(), - readOnly: absent(), + readOnly: absent(), messages: null as null | unknown[], messageListProps: initialMessageListProps, composerProps: nullable<{ @@ -197,6 +196,7 @@ export function createStructuredSessionMocks() { stop: mocks.stop, queuedMessages: { cards: mocks.queuedCards, + writable: mocks.readOnly === undefined, steer: mocks.queuedSteer, remove: mocks.queuedRemove, edit: mocks.queuedEdit, diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 941556a8b107..3d1a2b119405 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -31,7 +31,6 @@ import { structuredAgentSessionReadFailureNotice } from './structured-agent-sess import { useStructuredAgentSessionStartFailureFacts } from './use-structured-agent-session-start-failure-facts' import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices' import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' -import { agentSessionReadOnlyText } from './agent-session-write-notice-text' const NO_SUBMISSIONS: readonly AgentJournalSubmission[] = [] @@ -161,9 +160,9 @@ export function NativeChatStructuredSession( rootRef, { sessionId: props.sessionId, isVisible: props.isVisible } ) - const readOnlyText = agentSessionReadOnlyText(controller.readOnly) - // A read-only chat refuses every answer, so the composer says why in place of a prompt card. - const prompt = readOnlyText ? null : (controller.prompts[0] ?? null) + const prompt = controller.prompts[0] ?? null + // A read-only chat shows its pending prompt unanswerable, above the composer that says why. + const composerShown = prompt === null || controller.readOnly !== undefined const approvalBody = prompt?.body.kind === 'approval' ? prompt.body : null const approval = approvalBody ? { @@ -194,7 +193,7 @@ export function NativeChatStructuredSession( composerRef, isVisible: props.isVisible, isFocusedGroup: props.isFocusedGroup, - composerReady: prompt === null + composerReady: composerShown }) const questionBody = prompt?.body.kind === 'question' ? prompt.body : null const questions = questionBody ? agentSessionPromptQuestions(questionBody) : [] @@ -352,7 +351,8 @@ export function NativeChatStructuredSession( approval={approval} onChoose={(optionId) => void controller.respond(prompt, { kind: 'option', optionId })} onCancel={cancelPrompt} - shouldFocus={props.isVisible && props.isFocusedGroup} + disabled={controller.readOnly !== undefined} + shouldFocus={!controller.readOnly && props.isVisible && props.isFocusedGroup} onLinkClick={onLinkClick} allowFileUriLinks={onLinkClick !== undefined} /> @@ -387,24 +387,24 @@ export function NativeChatStructuredSession( } }} onCancel={cancelPrompt} + disabled={controller.readOnly !== undefined} /> ) : null} - {prompt ? null : ( + {composerShown ? ( void controller.stop()} steerQueued={controller.queuedMessages.steerNewest} structuredTransport={structuredTransport} launchSeed={{ ...launchDraftSignal, ownsTabWideLaunchDraft: true }} /> - )} + ) : null} {paneCommands.menu} diff --git a/src/renderer/src/components/native-chat/native-chat-composer-types.ts b/src/renderer/src/components/native-chat/native-chat-composer-types.ts index 8c9a7fdd0620..5c92e4a6ef2c 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-types.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-types.ts @@ -55,7 +55,8 @@ export type NativeChatComposerProps = { agent: AgentType /** Guard desktop sends while a mobile client owns the terminal input lease. */ canSend?: boolean - /** What the placeholder says while `canSend` is false; absent, another device holds the input. */ + /** Locks the composer, its placeholder saying why; `canSend` false alone means another device + * holds the input. */ lockReason?: string /** True while the hosted TUI reports an in-flight turn; swaps Send to Stop. */ isWorking?: boolean diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx index fa4ae91b1eae..fc93f193289c 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx @@ -35,6 +35,7 @@ function renderController() { queuePause: { reason: 'stopped' }, submissions: [], hasPendingPrompt: false, + writable: true, composerScopeKey: undefined, mutate }) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.test.tsx index 5fdfc0a95c7a..2d4335852be9 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.test.tsx @@ -35,6 +35,7 @@ function createHarness( overrides: { queuedMessages?: AgentSessionQueuedMessage[] enabled?: boolean + writable?: boolean composerScopeKey?: string | undefined mutateResult?: (call: MutateCall) => unknown } = {} @@ -51,6 +52,7 @@ function createHarness( queuePause: null, submissions: [], hasPendingPrompt: false, + writable: overrides.writable ?? true, composerScopeKey: 'composerScopeKey' in overrides ? overrides.composerScopeKey : SCOPE, // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: each scripted answer is the result shape of the one mutate it responds to; generic erasure cannot express that. mutate: mutate as StructuredAgentSessionMutate @@ -92,6 +94,9 @@ describe('queued message actions', () => { expect(empty.result.current.steerNewest()).toBe(false) const disabled = createHarness({ enabled: false }) expect(disabled.result.current.steerNewest()).toBe(false) + const readOnly = createHarness({ writable: false }) + expect(readOnly.result.current.steerNewest()).toBe(false) + expect(readOnly.result.current.writable).toBe(false) }) it('Delete withdraws through queuedMessageDelete without touching the composer', async () => { diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts index 4596fb67180f..d5be28d8894b 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts @@ -32,6 +32,8 @@ export type StructuredAgentSessionQueuedMessagesController = { resume: () => Promise /** A Resume is in flight. */ resuming: boolean + /** False while the host refuses every write: no card action or Resume can land. */ + writable: boolean /** Send-now into the running turn; the transcript shows it at delivery position. */ steer: (messageId: string) => Promise remove: (messageId: string) => Promise @@ -54,10 +56,12 @@ export function useStructuredAgentSessionQueuedMessages(args: { queuePause: AgentSessionQueuePause | null submissions: readonly AgentJournalSubmission[] hasPendingPrompt: boolean + writable: boolean composerScopeKey: string | undefined mutate: StructuredAgentSessionMutate }): StructuredAgentSessionQueuedMessagesController { const { composerScopeKey, enabled, hasPendingPrompt, mutate, queuedMessages, submissions } = args + const { writable } = args const pause = args.queuePause const cards = useMemo( @@ -167,7 +171,7 @@ export function useStructuredAgentSessionQueuedMessages(args: { }, [mutate]) const steerNewest = useCallback((): boolean => { - if (!enabled) { + if (!enabled || !writable) { return false } const newest = newestSteerableQueuedMessageCard(cardsRef.current) @@ -176,7 +180,7 @@ export function useStructuredAgentSessionQueuedMessages(args: { } void steer(newest.messageId) return true - }, [enabled, steer]) + }, [enabled, steer, writable]) - return { cards, pause, resume, resuming, steer, remove, edit, steerNewest } + return { cards, pause, resume, resuming, writable, steer, remove, edit, steerNewest } } diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-thread-goal.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-thread-goal.test.tsx index 2de4a2564e4e..ca59c9aac168 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-thread-goal.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-thread-goal.test.tsx @@ -58,12 +58,14 @@ function harness(options: { journalItems?: readonly AgentJournalRenderItem[] support?: { current: AgentJournalThreadGoal | null } mutate?: StructuredAgentSessionMutate + writable?: boolean }) { const mutate = options.mutate ?? mutateWith(async () => null).mutate return renderHook(() => useStructuredAgentSessionThreadGoal({ journalItems: options.journalItems ?? [], support: options.support, + writable: options.writable ?? true, mutate }) ) @@ -74,6 +76,13 @@ describe('useStructuredAgentSessionThreadGoal', () => { expect(harness({ journalItems: [goalRow(1, GOAL)] }).result.current).toBeNull() }) + it("holds the goal's actions while the host refuses every write", () => { + expect(harness({ support: { current: GOAL } }).result.current?.pending).toBe(false) + expect(harness({ support: { current: GOAL }, writable: false }).result.current?.pending).toBe( + true + ) + }) + it('reads the goal off the loaded window, and off the host answer only when the window has none', () => { const older = { ...GOAL, objective: 'Older goal' } const fromWindow = harness({ journalItems: [goalRow(1, GOAL)], support: { current: older } }) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-thread-goal.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-thread-goal.ts index a963a3b9c9ae..5b884a704299 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-thread-goal.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-thread-goal.ts @@ -13,6 +13,7 @@ import type { StructuredAgentSessionMutate } from './use-structured-agent-sessio export type StructuredAgentSessionThreadGoal = { goal: AgentJournalThreadGoal | null + /** A change is in flight, or the host takes none. */ pending: boolean /** Resolves false when the change was refused or not sent; the error surfaces separately. */ change: (change: AgentSessionThreadGoalChange) => Promise @@ -22,9 +23,10 @@ export type StructuredAgentSessionThreadGoal = { export function useStructuredAgentSessionThreadGoal(args: { journalItems: readonly AgentJournalRenderItem[] support: AgentSessionOptionsResult['threadGoal'] + writable: boolean mutate: StructuredAgentSessionMutate }): StructuredAgentSessionThreadGoal | null { - const { journalItems, mutate, support } = args + const { journalItems, mutate, support, writable } = args const [pending, setPending] = useState(false) const pendingRef = useRef(false) // The loaded window reaches the live head, so a goal row in it is newer than the @@ -52,5 +54,5 @@ export function useStructuredAgentSessionThreadGoal(args: { }, [mutate] ) - return support ? { goal, pending, change } : null + return support ? { goal, pending: pending || !writable, change } : null } diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-transport-state.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-transport-state.test.tsx new file mode 100644 index 000000000000..68ccd62c9fb4 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-transport-state.test.tsx @@ -0,0 +1,56 @@ +// @vitest-environment happy-dom +import { renderHook } from '@testing-library/react' +import { expect, it } from 'vitest' +import type { AgentSessionReadOnlyReason } from '../../../../shared/agent-session-read-only' +import { + EMPTY_STRUCTURED_AGENT_SESSION, + type StructuredAgentSessionState +} from '../../../../shared/structured-agent-session-reducer' +import { useStructuredAgentSessionTransportState } from './use-structured-agent-session-transport-state' + +/** A chat whose turn opened and never settled, as a read-only host leaves it. */ +function openTurn(readOnly?: AgentSessionReadOnlyReason): StructuredAgentSessionState { + return { + ...EMPTY_STRUCTURED_AGENT_SESSION, + epoch: 'epoch-1', + fence: 1, + status: 'ready', + items: [ + { + itemId: 'turn-1', + revision: 1, + sequence: 1, + observedAt: 1, + body: { kind: 'turn', turnId: 'turn-1', state: 'running' } + } + ], + backgroundTasks: { state: 'monitoring', tasks: [], supportsTaskStop: true }, + ...(readOnly ? { readOnly } : {}) + } +} + +function transport(state: StructuredAgentSessionState) { + return renderHook(() => useStructuredAgentSessionTransportState(state, true)).result.current +} + +it('a read-only chat has the words for why, no turn and no work, as its host projects it', () => { + const readOnly = transport(openTurn('written-by-newer-orca')) + expect(readOnly).toMatchObject({ + readOnly: 'Saved by a newer Orca. Update Orca to continue this chat.', + turnId: null, + isWorking: false, + backgroundTasks: { supportsStop: false, supportsStopAll: false } + }) + expect(transport(openTurn())).toMatchObject({ + readOnly: undefined, + turnId: 'turn-1', + isWorking: true, + backgroundTasks: { supportsStop: true } + }) +}) + +it('a reason this client cannot word leaves the chat as the journal says', () => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: stands in for an arm a newer host could publish, which this build's type cannot name. + const later = transport(openTurn('a-later-reason' as AgentSessionReadOnlyReason)) + expect(later).toMatchObject({ readOnly: undefined, turnId: 'turn-1', isWorking: true }) +}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-transport-state.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-transport-state.ts index 3b235b4e8ebc..c9b3f560918a 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-transport-state.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-transport-state.ts @@ -6,6 +6,7 @@ import type { StructuredAgentSubagentRoster } from '../../../../shared/structure import { selectStructuredAgentTurnActivity } from '../../../../shared/native-chat-turn-activity' import { structuredSessionBackgroundTasksView } from './structured-session-background-tasks-view' import { useStructuredAgentTurnTiming } from './use-structured-agent-turn-timing' +import { agentSessionReadOnlyText } from './agent-session-write-notice-text' const NO_JOURNAL_ITEMS: StructuredAgentSessionState['items'] = [] const NO_SUBMISSIONS: StructuredAgentSessionState['submissions'] = [] @@ -19,9 +20,13 @@ export function useStructuredAgentSessionTransportState( const submissions = enabled ? state.submissions : NO_SUBMISSIONS const subagentRoster = (enabled ? state.subagentRoster : undefined) ?? NO_SUBAGENT_ROSTER const fence = enabled ? state.fence : null - const turnId = activeStructuredAgentSessionTurnId(journalItems) + // The one fact every write control reads: why the host refuses writes here, or none. + const readOnly = enabled ? agentSessionReadOnlyText(state.readOnly) : undefined + // A read-only host projects no turn (structured-agent-session-status-journal-projection.ts). + const turnId = readOnly ? null : activeStructuredAgentSessionTurnId(journalItems) // The rule the host projects every session list's Working from, so this chat cannot disagree. - const isWorking = isStructuredAgentSessionMainAgentWorking(turnId, submissions, fence) + const isWorking = + !readOnly && isStructuredAgentSessionMainAgentWorking(turnId, submissions, fence) const turnActivity = useMemo( () => selectStructuredAgentTurnActivity(journalItems, turnId, enabled ? state.activity : null), [enabled, journalItems, state.activity, turnId] @@ -34,7 +39,12 @@ export function useStructuredAgentSessionTransportState( }, turnId ) + const backgroundTasks = structuredSessionBackgroundTasksView( + enabled ? state.backgroundTasks : null, + turnId + ) return { + readOnly, journalItems, subagentRoster, submissions, @@ -46,9 +56,8 @@ export function useStructuredAgentSessionTransportState( // null = no drafts or no claim; the projection treats both as an empty list. queuedMessages: (enabled ? state.queuedMessages : null) ?? null, queuePause: (enabled ? state.queuePause : null) ?? null, - backgroundTasks: structuredSessionBackgroundTasksView( - enabled ? state.backgroundTasks : null, - turnId - ) + backgroundTasks: readOnly + ? { ...backgroundTasks, supportsStop: false, supportsStopAll: false } + : backgroundTasks } } diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index 99be79f82a2d..87b4a9428c2a 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -128,6 +128,7 @@ export function useStructuredAgentSession(args: { const threadGoal = useStructuredAgentSessionThreadGoal({ journalItems: transportState.journalItems, support: threadGoalSupport, + writable: !transportState.readOnly, mutate }) const contextUsage = useStructuredAgentSessionContextUsage( @@ -149,10 +150,11 @@ export function useStructuredAgentSession(args: { const stopsConversation = useStructuredAgentSessionHostStopsConversation(target) && transportState.fence !== null const canStop = - transportState.turnId !== null || - (stopsConversation && - (transportState.isWorking || - hasUnsentStructuredAgentSessionOutboxEntry(outbox, transportState.submissions))) + !transportState.readOnly && + (transportState.turnId !== null || + (stopsConversation && + (transportState.isWorking || + hasUnsentStructuredAgentSessionOutboxEntry(outbox, transportState.submissions)))) // A queued send is a card, never a transcript bubble. const isWorking = transportState.isWorking const transcriptOutbox = useMemo( @@ -174,6 +176,7 @@ export function useStructuredAgentSession(args: { queuePause: transportState.queuePause, submissions: transportState.submissions, hasPendingPrompt: prompts.length > 0, + writable: !transportState.readOnly, composerScopeKey, mutate }) @@ -206,8 +209,8 @@ export function useStructuredAgentSession(args: { error: outboxController.error, /** The refusal the failed read met, while `status` is `error`. */ readRefusal: transportEnabled ? state.readRefusal : undefined, - /** Why the host keeps this chat read-only; absent while it takes writes. */ - readOnly: transportEnabled ? state.readOnly : undefined, + /** Why the host refuses every write here, in words; absent while it takes them. */ + readOnly: transportState.readOnly, hasOlder: transportEnabled && state.hasOlder, railOutline: transportEnabled ? railOutline : null, loadingOlder: transportEnabled && loadingOlder, From fff016eaea37b845c244a344cdda4a69bf2befe2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 01:41:03 -0700 Subject: [PATCH 12/52] fix(mobile): every phone write control follows the one read-only fact The phone session derives one fact from the page's `readOnly`, the words for why the host refuses writes, and every write control reads it: no turn or work while it holds, so no Stop; the pending prompt stays shown with its card disabled; queued cards and Resume are inert; the model and option pickers stay shut; the composer field is not editable, so its placeholder keeps saying why, and the lock applies at once instead of after the transport lock's settle. --- .../session/MobileNativeChatComposer.test.ts | 45 +++++++++++++++++++ .../src/session/MobileNativeChatComposer.tsx | 12 +++-- .../src/session/MobileNativeChatOverlay.tsx | 11 ++--- .../MobileNativeChatPermission.test.ts | 18 ++++++++ .../session/MobileNativeChatPermission.tsx | 14 ++++-- .../session/MobileNativeChatPromptCard.tsx | 7 ++- .../src/session/MobileNativeChatQuestion.tsx | 16 +++++-- .../MobileNativeChatQueuedMessages.test.tsx | 15 +++++++ .../MobileNativeChatQueuedMessages.tsx | 9 ++-- .../MobileNativeChatSessionOptionPickers.tsx | 11 +++-- .../src/session/MobileNativeChatView.test.ts | 25 +++++------ mobile/src/session/MobileNativeChatView.tsx | 10 +++-- .../mobile-native-chat-read-only.test.tsx | 41 ++++++++++++----- .../use-mobile-native-chat-queued-slot.ts | 17 +++++-- .../use-mobile-structured-agent-session.ts | 11 +++-- 15 files changed, 205 insertions(+), 57 deletions(-) diff --git a/mobile/src/session/MobileNativeChatComposer.test.ts b/mobile/src/session/MobileNativeChatComposer.test.ts index 902022ab03e7..0a047a50bc5b 100644 --- a/mobile/src/session/MobileNativeChatComposer.test.ts +++ b/mobile/src/session/MobileNativeChatComposer.test.ts @@ -691,4 +691,49 @@ describe('MobileNativeChatComposer', () => { expect(Keyboard.dismiss).not.toHaveBeenCalled() }) + + it('a read-only lock keeps the field uneditable and the option pickers and dictation shut', async () => { + const controller = { + snapshot: [ + { + id: 'model', + label: 'Model', + category: 'model' as const, + kind: { type: 'select' as const, choices: [{ value: 'opus', label: 'Opus 4.8' }] }, + valueSource: 'unknown' as const, + settable: true + } + ], + pendingId: null, + setOption: vi.fn(), + invokeAction: vi.fn(), + recordCommand: vi.fn() + } + await act(async () => { + renderer = create( + createElement(MobileNativeChatComposer, { + value: '', + onChangeText: vi.fn(), + onSend: vi.fn(async () => true), + sendSurfaceId: 'tab-a', + getSendCompletionGeneration: getCurrentSendCompletionGeneration, + sessionOptions: { isWorking: false, controller }, + onMicPress: vi.fn(), + disabled: true, + readOnly: true, + placeholder: 'Saved by a newer Orca.' + }) + ) + }) + const pressable = (label: string) => + renderer!.root.find( + (node) => String(node.type) === 'Pressable' && node.props.accessibilityLabel === label + ) + expect(renderer!.root.find((node) => String(node.type) === 'TextInput').props).toMatchObject({ + editable: false, + placeholder: 'Saved by a newer Orca.' + }) + expect(pressable('Model, Model').props.accessibilityState.disabled).toBe(true) + expect(pressable('Dictate').props.disabled).toBe(true) + }) }) diff --git a/mobile/src/session/MobileNativeChatComposer.tsx b/mobile/src/session/MobileNativeChatComposer.tsx index b04e09d80e25..1bc900c077b6 100644 --- a/mobile/src/session/MobileNativeChatComposer.tsx +++ b/mobile/src/session/MobileNativeChatComposer.tsx @@ -68,6 +68,9 @@ type Props = { onMicPressIn?: () => void onMicPressOut?: () => void disabled?: boolean + /** A lasting lock (the host refuses writes): the field is not editable, so its placeholder + * reason stays shown, and the option pickers stay shut. */ + readOnly?: boolean placeholder?: string filePaths?: string[] onNeedFiles?: (query: string) => void @@ -94,6 +97,7 @@ export function MobileNativeChatComposer({ onMicPressIn, onMicPressOut, disabled = false, + readOnly = false, placeholder = 'Message, @files, /commands', filePaths = NO_FILE_PATHS, onNeedFiles @@ -267,9 +271,10 @@ export function MobileNativeChatComposer({ placeholderTextColor={colors.textMuted} selectionColor={colors.accentBlue} multiline - // Why: never revoke `editable` — iOS resigns first responder on a focused - // field, so a transient lock would yank the keyboard mid-typing (#10681). - // The lock gates sending; the draft survives and rides the next send. + // Why: never revoke `editable` for a transient lock — iOS resigns first responder on + // a focused field, so it would yank the keyboard mid-typing (#10681). The lock gates + // sending; the draft survives and rides the next send. A read-only lock lasts. + editable={!readOnly} textAlignVertical="top" /> @@ -291,6 +296,7 @@ export function MobileNativeChatComposer({ ) : null} diff --git a/mobile/src/session/MobileNativeChatOverlay.tsx b/mobile/src/session/MobileNativeChatOverlay.tsx index b6078b2d2698..fbdc41837a44 100644 --- a/mobile/src/session/MobileNativeChatOverlay.tsx +++ b/mobile/src/session/MobileNativeChatOverlay.tsx @@ -53,7 +53,7 @@ export function MobileNativeChatOverlay({ keyboardInset }: Props): React.JSX.Element | null { const session = controller.nativeChatSession - // A read-only chat refuses every write: its composer locks and says why, and no prompt card shows. + // The session's one read-only fact: every write control below reads it. const readOnlyNotice = session.readOnlyNotice ?? null const folded = useMemo(() => foldMobileNativeChatMessages(session.messages), [session.messages]) const streaming = useMobileNativeChatStreamingBubble( @@ -70,7 +70,8 @@ export function MobileNativeChatOverlay({ onEdit: queued.edit, pause: queued.pause, onResume: queued.resume, - sessionKey: queued.sessionKey + sessionKey: queued.sessionKey, + disabled: readOnlyNotice !== null }) if (!controller.showNativeChat) { return null @@ -98,9 +99,9 @@ export function MobileNativeChatOverlay({ onAnswerAsk={controller.handleNativeChatAnswerAsk} onCancelAsk={controller.handleNativeChatCancelAsk} onCancelPrompt={controller.handleNativeChatCancelPrompt} - question={readOnlyNotice ? null : controller.nativeChatQuestion} + question={controller.nativeChatQuestion} onAnswerQuestion={controller.handleNativeChatQuestionAnswer} - permission={readOnlyNotice ? null : controller.nativeChatPermission} + permission={controller.nativeChatPermission} onRespondPermission={controller.handleNativeChatRespondPermission} queuedSlot={queuedSlot} onOpenFile={onOpenFile} @@ -124,7 +125,7 @@ export function MobileNativeChatOverlay({ dictationMode={dictationMode} onMicPressIn={onMicPressIn} onMicPressOut={onMicPressOut} - inputLockReason={inputLockReason ?? (readOnlyNotice ? 'read-only' : null)} + inputLockReason={inputLockReason} sendErrorMessage={sendErrorMessage} onClearSendError={onClearSendError} readOnlyNotice={readOnlyNotice} diff --git a/mobile/src/session/MobileNativeChatPermission.test.ts b/mobile/src/session/MobileNativeChatPermission.test.ts index 68e5c21df8a4..ef216aba7c7e 100644 --- a/mobile/src/session/MobileNativeChatPermission.test.ts +++ b/mobile/src/session/MobileNativeChatPermission.test.ts @@ -145,4 +145,22 @@ describe('MobileNativeChatPermission', () => { expect(content.findAllByProps({ children: 'raw json that must not be shown' })).toHaveLength(0) expect(actions.findAllByProps({ children: 'Approve plan' })).toHaveLength(1) }) + + it('a disabled card shows the request with nothing to press', async () => { + const onRespond = vi.fn(async () => true) + const onCancel = vi.fn(async () => true) + await act(async () => { + renderer = create( + createElement(MobileNativeChatPermission, { + permission: { title: 'Approve?', options: [{ label: 'Allow', send: '1' }] }, + onRespond, + onCancel, + disabled: true + }) + ) + }) + const pressables = renderer!.root.findAllByType('Pressable') + expect(pressables.length).toBe(2) + expect(pressables.every((pressable) => pressable.props.disabled === true)).toBe(true) + }) }) diff --git a/mobile/src/session/MobileNativeChatPermission.tsx b/mobile/src/session/MobileNativeChatPermission.tsx index 29f3e4219816..d74354a27a37 100644 --- a/mobile/src/session/MobileNativeChatPermission.tsx +++ b/mobile/src/session/MobileNativeChatPermission.tsx @@ -11,13 +11,17 @@ import type { MobileChatPermission } from './mobile-native-chat-permission' function MobileNativeChatPermissionImpl({ permission, onRespond, - onCancel + onCancel, + disabled = false }: { permission: MobileChatPermission onRespond: (send: string) => Promise onCancel?: (prompt?: NonNullable) => Promise + /** Shown but not answerable: the host refuses every answer. */ + disabled?: boolean }): React.JSX.Element { - const [submitting, setSubmitting] = useState(false) + const [inFlight, setSubmitting] = useState(false) + const submitting = inFlight || disabled const submittingRef = useRef(false) const hasContext = Boolean( permission.description || @@ -114,7 +118,8 @@ function MobileNativeChatPermissionImpl({ style={({ pressed }) => [ styles.option, isPrimary ? styles.optionPrimary : styles.optionSecondary, - pressed && !submitting && styles.optionPressed + pressed && !submitting && styles.optionPressed, + disabled && styles.disabled ]} hitSlop={6} onPress={() => respond(option.send)} @@ -212,6 +217,9 @@ const styles = StyleSheet.create({ optionPressed: { opacity: 0.7 }, + disabled: { + opacity: 0.5 + }, optionText: { color: colors.textPrimary, fontSize: typography.bodySize, diff --git a/mobile/src/session/MobileNativeChatPromptCard.tsx b/mobile/src/session/MobileNativeChatPromptCard.tsx index 31a007801dc5..03b75702fbdf 100644 --- a/mobile/src/session/MobileNativeChatPromptCard.tsx +++ b/mobile/src/session/MobileNativeChatPromptCard.tsx @@ -19,7 +19,8 @@ export function MobileNativeChatPromptCard({ permission, onRespondPermission, question, - onAnswerQuestion + onAnswerQuestion, + disabled = false }: { ask?: AskPrompt | null askKey?: string | null @@ -31,6 +32,8 @@ export function MobileNativeChatPromptCard({ onRespondPermission?: (send: string) => Promise question?: MobileChatQuestion | null onAnswerQuestion?: (text: string) => Promise + /** A structured prompt stays shown but not answerable: the host refuses every answer. */ + disabled?: boolean }): React.JSX.Element | null { if (ask) { return ( @@ -61,6 +64,7 @@ export function MobileNativeChatPromptCard({ permission={permission} onRespond={async (send) => (await onRespondPermission?.(send)) ?? false} onCancel={onCancelPrompt} + disabled={disabled} /> ) } @@ -71,6 +75,7 @@ export function MobileNativeChatPromptCard({ question={question} onAnswer={async (text) => (await onAnswerQuestion?.(text)) ?? false} onCancel={onCancelPrompt} + disabled={disabled} /> ) } diff --git a/mobile/src/session/MobileNativeChatQuestion.tsx b/mobile/src/session/MobileNativeChatQuestion.tsx index f5f9e69cb8ec..4958d7797410 100644 --- a/mobile/src/session/MobileNativeChatQuestion.tsx +++ b/mobile/src/session/MobileNativeChatQuestion.tsx @@ -14,6 +14,8 @@ type Props = { question: MobileChatQuestion onAnswer: (text: string) => Promise onCancel?: (prompt?: NonNullable) => Promise + /** Shown but not answerable: the host refuses every answer. */ + disabled?: boolean } /** Renders an agent's choice prompt as a tappable card. Single-select answers @@ -23,11 +25,13 @@ type Props = { export function MobileNativeChatQuestion({ question, onAnswer, - onCancel + onCancel, + disabled = false }: Props): React.JSX.Element { const [selectedOptionIndexes, setSelectedOptionIndexes] = useState([]) const [freeText, setFreeText] = useState('') - const [sending, setSending] = useState(false) + const [inFlight, setSending] = useState(false) + const sending = inFlight || disabled const sendingRef = useRef(false) const allowOther = question.allowOther !== false @@ -133,9 +137,11 @@ export function MobileNativeChatQuestion({ style={({ pressed }) => [ styles.option, isSelected && styles.optionSelected, - pressed && styles.pressed + pressed && styles.pressed, + disabled && styles.disabled ]} onPress={() => (question.multiSelect ? toggle(optIndex) : answerSingle(optIndex))} + disabled={disabled} > {question.multiSelect ? ( @@ -183,6 +189,7 @@ export function MobileNativeChatQuestion({ placeholderTextColor={colors.textMuted} selectionColor={colors.accentBlue} onSubmitEditing={submitFreeText} + editable={!disabled} returnKeyType="send" multiline /> @@ -317,5 +324,8 @@ const styles = StyleSheet.create({ }, pressed: { opacity: 0.7 + }, + disabled: { + opacity: 0.5 } }) diff --git a/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx b/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx index b2929f1a1a57..0f6e519bb346 100644 --- a/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx +++ b/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx @@ -383,4 +383,19 @@ describe('MobileNativeChatQueuedMessages', () => { expect(flatStyle(row.props.style).minHeight).toBeGreaterThanOrEqual(44) } }) + + it('a read-only chat shows its cards with nothing to press', async () => { + const mounted = await mount({ + cards: [card({ messageId: 'draft-1' })], + pause: { reason: 'stopped' }, + onSend: vi.fn(async () => true), + onDelete: vi.fn(async () => true), + onResume: vi.fn(async () => true), + disabled: true + }) + const pressables = mounted.root.findAll((node) => String(node.type) === 'Pressable') + expect(pressables.length).toBeGreaterThan(1) + expect(pressables.every((pressable) => pressable.props.disabled === true)).toBe(true) + expect(texts(mounted)).toContain('text of draft-1') + }) }) diff --git a/mobile/src/session/MobileNativeChatQueuedMessages.tsx b/mobile/src/session/MobileNativeChatQueuedMessages.tsx index 57f2fa410ffe..4eaf486ece05 100644 --- a/mobile/src/session/MobileNativeChatQueuedMessages.tsx +++ b/mobile/src/session/MobileNativeChatQueuedMessages.tsx @@ -34,6 +34,8 @@ export type MobileNativeChatQueuedMessagesProps = { /** The whole queue's pause: the box's first row, with Resume. */ pause?: MobileQueuePause onResume?: () => Promise + /** The host refuses every write: the cards stay shown with nothing to press. */ + disabled?: boolean } /** The host-held queued drafts, as one box of compact rows between transcript and @@ -44,7 +46,8 @@ export function MobileNativeChatQueuedMessages({ onDelete, onEdit, pause, - onResume + onResume, + disabled = false }: MobileNativeChatQueuedMessagesProps): React.JSX.Element | null { // One in-flight action per card; a second tap must not double-consume. The ref // closes the same-frame double tap the disabled state cannot. @@ -67,7 +70,7 @@ export function MobileNativeChatQueuedMessages({ setBusyIds(new Set(inFlightRef.current)) } } - const resuming = busyIds.has(RESUME_KEY) + const resuming = disabled || busyIds.has(RESUME_KEY) // A card that drained or was removed while its menu was open closes the menu, for good: a // Stop-requeued draft comes back under the same id and must not reopen it. const menuCard = cards.find((card) => card.messageId === menuFor) @@ -103,7 +106,7 @@ export function MobileNativeChatQueuedMessages({ ) : null} {cards.map((card, index) => { - const busy = busyIds.has(card.messageId) + const busy = disabled || busyIds.has(card.messageId) const returned = card.state === 'returned' // "Steer" submits beside the running turn, the paused queue's cards too; a card whose // own send failed, or a returned one, is sent again. diff --git a/mobile/src/session/MobileNativeChatSessionOptionPickers.tsx b/mobile/src/session/MobileNativeChatSessionOptionPickers.tsx index 7a0ec85912fd..b1db761b6c7c 100644 --- a/mobile/src/session/MobileNativeChatSessionOptionPickers.tsx +++ b/mobile/src/session/MobileNativeChatSessionOptionPickers.tsx @@ -33,19 +33,24 @@ export type MobileNativeChatSessionOptionPickersProps = { * window would be submitted as part of the user's prompt. The composer blocks * the reverse direction on `pendingId`; this is the same guard mirrored. */ sendInFlight?: boolean + /** The host refuses every change: the pill stays shown, shut. */ + locked?: boolean } /** Combined model/session-option trigger and its mobile bottom drawer. */ export function MobileNativeChatSessionOptionPickers({ controller, isWorking, - sendInFlight = false + sendInFlight = false, + locked = false }: MobileNativeChatSessionOptionPickersProps): React.JSX.Element | null { const [openDescriptorId, setOpenDescriptorId] = useState(null) const [lastRequest, setLastRequest] = useState(controller.optionPickerRequest) if (controller.optionPickerRequest && lastRequest !== controller.optionPickerRequest) { setLastRequest(controller.optionPickerRequest) - setOpenDescriptorId(controller.optionPickerRequest.id) + if (!locked) { + setOpenDescriptorId(controller.optionPickerRequest.id) + } } const { snapshot, pendingId } = controller const model = snapshot.find((descriptor) => descriptor.category === 'model') @@ -53,7 +58,7 @@ export function MobileNativeChatSessionOptionPickers({ if (!model) { return null } - const disabled = isWorking || pendingId !== null || sendInFlight + const disabled = locked || isWorking || pendingId !== null || sendInFlight const activeDescriptor = snapshot.find((descriptor) => descriptor.id === openDescriptorId) const modelView = activeDescriptor?.id === model.id const modelLabel = mobileModelPillLabel(model) diff --git a/mobile/src/session/MobileNativeChatView.test.ts b/mobile/src/session/MobileNativeChatView.test.ts index 40b5e856a4bd..8f4eae4a5803 100644 --- a/mobile/src/session/MobileNativeChatView.test.ts +++ b/mobile/src/session/MobileNativeChatView.test.ts @@ -79,7 +79,7 @@ type Overrides = { streaming?: string | null sendErrorMessage?: string | null onClearSendError?: () => void - inputLockReason?: 'disconnected' | 'waiting' | 'read-only' | null + inputLockReason?: 'disconnected' | 'waiting' | null readOnlyNotice?: string | null onSend?: (text: string) => Promise pending?: Parameters[0]['pending'] @@ -679,19 +679,16 @@ describe('MobileNativeChatView', () => { } }) - it("locks a read-only chat's composer with the host's reason as its placeholder", async () => { - vi.useFakeTimers() - try { - const reason = 'Saved by a newer Orca. Update Orca to continue this chat.' - await render({ inputLockReason: 'read-only', readOnlyNotice: reason }) - await act(async () => { - vi.advanceTimersByTime(600) - }) + it("locks a read-only chat's composer at once, with the reason as its placeholder", async () => { + const reason = 'Saved by a newer Orca. Update Orca to continue this chat.' + await render({ + readOnlyNotice: reason, + structuredActivityUi: true, + permission: { title: 'Allow command?', options: [{ label: 'Allow', send: 'allow' }] } + }) - expect(composer().props.disabled).toBe(true) - expect(composer().props.placeholder).toBe(reason) - } finally { - vi.useRealTimers() - } + expect(composer().props).toMatchObject({ disabled: true, readOnly: true, placeholder: reason }) + const [card] = renderer!.root.findAll((node) => String(node.type) === 'ChatPermission') + expect(card?.props.disabled).toBe(true) }) }) diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index 57f646f9b119..2dd8b7b68f09 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -310,7 +310,9 @@ export function MobileNativeChatView({ const emptyState = mobileNativeChatEmptyState(status, agent ?? null, error) const showLoading = status === 'loading' && messages.length === 0 - const lockReason = useSettledMobileNativeChatInputLock(inputLockReason) + // A read-only lock lasts, so it needs no settling; a transport lock outranks it. + const lockReason = + useSettledMobileNativeChatInputLock(inputLockReason) ?? (readOnlyNotice ? 'read-only' : null) return ( @@ -392,6 +394,7 @@ export function MobileNativeChatView({ onRespondPermission={onRespondPermission} question={question} onAnswerQuestion={onAnswerQuestion} + disabled={readOnlyNotice !== null} /> @@ -452,14 +455,13 @@ export function MobileNativeChatView({ onMicPressIn={onMicPressIn} onMicPressOut={onMicPressOut} disabled={lockReason !== null} + readOnly={lockReason === 'read-only'} placeholder={ lockReason === 'disconnected' ? 'Reconnecting…' : lockReason === 'waiting' ? 'Waiting for terminal…' - : lockReason === 'read-only' && readOnlyNotice - ? readOnlyNotice - : 'Message, @files, /commands' + : (readOnlyNotice ?? 'Message, @files, /commands') } filePaths={filePaths} onNeedFiles={onNeedFiles} diff --git a/mobile/src/session/mobile-native-chat-read-only.test.tsx b/mobile/src/session/mobile-native-chat-read-only.test.tsx index 5a2daf19dc41..ef54b979e389 100644 --- a/mobile/src/session/mobile-native-chat-read-only.test.tsx +++ b/mobile/src/session/mobile-native-chat-read-only.test.tsx @@ -32,7 +32,16 @@ function snapshot(readOnly?: 'written-by-newer-orca'): AgentSessionSubscribeEven epoch: 'epoch-1', fence: 3, direction: 'tail', - items: [], + // A turn that opened and never settled, as a read-only host leaves it. + items: [ + { + itemId: 'turn-1', + revision: 1, + sequence: 1, + observedAt: 1, + body: { kind: 'turn', turnId: 'turn-1', state: 'running' } + } + ], removedItemIds: [], submissions: [], window: { oldest: null, newest: null, nextCursor: { epoch: 'epoch-1', sequence: 0 } }, @@ -81,15 +90,17 @@ afterEach(() => { hook = null }) -it("words the host's read-only reason on the phone, and drops it when the host takes writes again", async () => { +it("words the host's read-only reason on the phone with no turn or work, until the host takes writes", async () => { act(() => { renderer = create(createElement(Harness)) }) await vi.waitFor(() => expect(listener).not.toBeNull()) act(() => listener?.(snapshot('written-by-newer-orca'))) expect(hook?.session.readOnlyNotice).toBe(NOTICE) + expect(hook).toMatchObject({ turnId: null, isWorking: false }) act(() => listener?.(snapshot())) expect(hook?.session.readOnlyNotice).toBeNull() + expect(hook).toMatchObject({ turnId: 'turn-1', isWorking: true }) }) function overlayElement( @@ -153,20 +164,26 @@ function chatViewProps(element: ReturnType): Record { - expect(chatViewProps(overlayElement(NOTICE))).toMatchObject({ - inputLockReason: 'read-only', +/** The queued cards the overlay hands the chat view. */ +function queuedCardsProps(props: Record): Record { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the overlay builds this slot with useMobileNativeChatQueuedSlot, whose `cards` is one element. + return (props.queuedSlot as { cards: { props: Record } }).cards.props +} + +it("hands the session's one read-only fact to the chat view: prompts shown, queued cards inert", () => { + const readOnly = chatViewProps(overlayElement(NOTICE)) + expect(readOnly).toMatchObject({ + inputLockReason: null, readOnlyNotice: NOTICE, - question: null, - permission: null, + question: { id: 'question-1' }, + permission: { id: 'permission-1' }, composerText: 'a draft' }) + expect(queuedCardsProps(readOnly)).toMatchObject({ disabled: true }) expect(chatViewProps(overlayElement(NOTICE, 'disconnected'))).toMatchObject({ inputLockReason: 'disconnected' }) - expect(chatViewProps(overlayElement(null))).toMatchObject({ - inputLockReason: null, - question: { id: 'question-1' }, - permission: { id: 'permission-1' } - }) + const writable = chatViewProps(overlayElement(null)) + expect(writable).toMatchObject({ inputLockReason: null, readOnlyNotice: null }) + expect(queuedCardsProps(writable)).toMatchObject({ disabled: false }) }) diff --git a/mobile/src/session/use-mobile-native-chat-queued-slot.ts b/mobile/src/session/use-mobile-native-chat-queued-slot.ts index aac805a5c20e..27a3f0896728 100644 --- a/mobile/src/session/use-mobile-native-chat-queued-slot.ts +++ b/mobile/src/session/use-mobile-native-chat-queued-slot.ts @@ -31,7 +31,7 @@ export function useMobileNativeChatQueuedSlot( const { composerInputRef, editQueuedMessage } = useMobileNativeChatQueuedEditFocus( queuedMessages.onEdit ) - const { cards, onSend, onDelete, pause, onResume, sessionKey } = queuedMessages + const { cards, onSend, onDelete, pause, onResume, sessionKey, disabled } = queuedMessages return useMemo( () => ({ cards: createElement(MobileNativeChatQueuedMessages, { @@ -41,10 +41,21 @@ export function useMobileNativeChatQueuedSlot( onDelete, onEdit: editQueuedMessage, pause, - onResume + onResume, + disabled }), composerInputRef }), - [cards, composerInputRef, editQueuedMessage, onDelete, onResume, onSend, pause, sessionKey] + [ + cards, + composerInputRef, + disabled, + editQueuedMessage, + onDelete, + onResume, + onSend, + pause, + sessionKey + ] ) } diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index c3078dd84737..fd7632018df7 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -162,7 +162,10 @@ export function useMobileStructuredAgentSession(args: { () => projectStructuredAgentSessionMessages(state.items, [], state.submissions), [state.items, state.submissions] ) - const turnId = activeStructuredAgentSessionTurnId(state.items) + // The one fact every write control reads: why the host refuses writes here, or none. + const readOnlyNotice = mobileReadOnlyNotice(state.readOnly) + // A read-only host projects no turn (structured-agent-session-status-journal-projection.ts). + const turnId = readOnlyNotice ? null : activeStructuredAgentSessionTurnId(state.items) const turnTiming = useMobileStructuredAgentTurnTiming(state, turnId) const activityText = selectStructuredAgentTurnActivity(state.items, turnId, state.activity)?.text ?? null @@ -225,9 +228,11 @@ export function useMobileStructuredAgentSession(args: { hasMore: state.hasOlder, loadingEarlier: loadingOlder, loadEarlier, - readOnlyNotice: mobileReadOnlyNotice(state.readOnly) + readOnlyNotice }, - isWorking: isStructuredAgentSessionMainAgentWorking(turnId, state.submissions, state.fence), + isWorking: + !readOnlyNotice && + isStructuredAgentSessionMainAgentWorking(turnId, state.submissions, state.fence), turnId, turnIndicator, ...turnTiming, From d7e00774f04d636bb1ef608baa63cc3a2ee0cc15 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 01:41:37 -0700 Subject: [PATCH 13/52] test(native-chat): the newest release hydrates a read-only chat and scrolls back through it --- .../agent-session-read-only.unit.test.ts | 64 ++++++++++++++++++- 1 file changed, 62 insertions(+), 2 deletions(-) diff --git a/tests/e2e/cross-version-wire/agent-session-read-only.unit.test.ts b/tests/e2e/cross-version-wire/agent-session-read-only.unit.test.ts index 86b53a8a78d2..fdc26bd0e8fe 100644 --- a/tests/e2e/cross-version-wire/agent-session-read-only.unit.test.ts +++ b/tests/e2e/cross-version-wire/agent-session-read-only.unit.test.ts @@ -23,7 +23,10 @@ import { liveTestJournalRows, openTestJournalHostDatabase } from '../../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' -import { readAgentSessionHydrationPage } from '../../../src/main/native-chat/agent-session-wire/agent-session-history-page' +import { + readAgentSessionHistory, + readAgentSessionHydrationPage +} from '../../../src/main/native-chat/agent-session-wire/agent-session-history-page' import { importReleaseCheckoutModule, materializeReleaseCheckout, @@ -84,7 +87,7 @@ const NEWER_NESTED_LITERAL = { subject: { kind: 'diff', path: 'a.ts' } } -/** This build's journal of one item, then `body` as a newer build wrote it: closed. */ +/** This build's journal of two items, then `body` as a newer build wrote it: closed. */ async function journalWithNewerBody( body: Record = NEWER_BODY_KIND ): Promise<{ rows: string[]; newer: string }> { @@ -94,6 +97,11 @@ async function journalWithNewerBody( { kind: 'status', text: 'before' }, { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) + await journal.appendItem( + { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 2 }, + { kind: 'status', text: 'also before' }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) const seq = journal.cursor().sequence + 1 const newer = JSON.stringify({ v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, @@ -160,6 +168,58 @@ describe('a chat a newer Orca saved, across versions', () => { SUITE_TIMEOUT_MS ) + it( + 'old client against new host: it hydrates a read-only chat and scrolls back through it as through a writable one', + async () => { + rmSync(directory, { recursive: true, force: true }) + directory = mkdtempSync(join(tmpdir(), 'orca-read-only-xv-')) + await journalWithNewerBody() + const journal = await journals.open({ identity: IDENTITY, stateDirectory: directory }) + expect(journal.isReadOnly).toBe(true) + const request = { sessionId: IDENTITY.sessionId, limit: 1 } + const tail = readAgentSessionHistory(journal, { ...request, direction: 'tail' }) + const requestedCursor = tail.page.window.nextCursor + const older = readAgentSessionHistory(journal, { + ...request, + direction: 'before', + cursor: requestedCursor + }) + await journals.closeAll() + // Both are pages, not resets, and carry the field, or this compares nothing. + expect([tail.ok, older.ok, tail.page.readOnly, older.page.readOnly]).toEqual([ + true, + true, + 'written-by-newer-orca', + 'written-by-newer-orca' + ]) + const { readOnly: _tailField, ...tailWithout } = tail.page + const { readOnly: _olderField, ...olderWithout } = older.page + + const checkout = await materializeReleaseCheckout(resolveBaselineReleaseRef()) + const reducer = await importReleaseCheckoutModule( + checkout, + 'src/shared/structured-agent-session-reducer.ts' + ) + const reduce = releaseExport(reducer, 'reduceStructuredAgentSession') + const empty = releaseExport(reducer, 'EMPTY_STRUCTURED_AGENT_SESSION') + const scrolledBack = (first: AgentSessionHistoryPage, next: AgentSessionHistoryPage) => + reduce( + reduce(empty, { type: 'history-page', page: first }, 1), + { type: 'older-page', requestedCursor, page: next }, + 1 + ) + const state = scrolledBack(tail.page, older.page) + expect(state).toEqual(scrolledBack(tailWithout, olderWithout)) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the released reducer's state keeps its loaded items under `items`, as every release has. + const items = (state as { items: { body: unknown }[] }).items.map((item) => item.body) + expect(items).toEqual([ + { kind: 'status', text: 'before' }, + { kind: 'status', text: 'also before' } + ]) + }, + SUITE_TIMEOUT_MS + ) + it('new client against old host: a page with no field says nothing', () => { const page: AgentSessionHistoryPage = { sessionId: IDENTITY.sessionId, From 42ea1d355984ea49747d10c5bb3480cc41b5f24a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 01:43:13 -0700 Subject: [PATCH 14/52] test(native-chat): a read-only chat reads idle even with a send its provider never answered --- .../mobile-native-chat-read-only.test.tsx | 13 ++++++++++++- ...ctured-agent-session-transport-state.test.tsx | 16 +++++++++++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/mobile/src/session/mobile-native-chat-read-only.test.tsx b/mobile/src/session/mobile-native-chat-read-only.test.tsx index ef54b979e389..a9bd69985cc8 100644 --- a/mobile/src/session/mobile-native-chat-read-only.test.tsx +++ b/mobile/src/session/mobile-native-chat-read-only.test.tsx @@ -43,7 +43,18 @@ function snapshot(readOnly?: 'written-by-newer-orca'): AgentSessionSubscribeEven } ], removedItemIds: [], - submissions: [], + submissions: [ + { + clientMessageId: 'send-1', + fence: 3, + payloadFingerprint: 'fingerprint', + dispatchState: 'pending', + providerItemId: null, + reason: null, + submittedAt: 1, + resolvedAt: null + } + ], window: { oldest: null, newest: null, nextCursor: { epoch: 'epoch-1', sequence: 0 } }, liveCursor: { epoch: 'epoch-1', sequence: 0 }, hasOlder: false, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-transport-state.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-transport-state.test.tsx index 68ccd62c9fb4..1caba1713dda 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-transport-state.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-transport-state.test.tsx @@ -8,7 +8,8 @@ import { } from '../../../../shared/structured-agent-session-reducer' import { useStructuredAgentSessionTransportState } from './use-structured-agent-session-transport-state' -/** A chat whose turn opened and never settled, as a read-only host leaves it. */ +/** A chat whose turn opened and never settled, and a send never answered, as a read-only host + * leaves them. */ function openTurn(readOnly?: AgentSessionReadOnlyReason): StructuredAgentSessionState { return { ...EMPTY_STRUCTURED_AGENT_SESSION, @@ -24,6 +25,19 @@ function openTurn(readOnly?: AgentSessionReadOnlyReason): StructuredAgentSession body: { kind: 'turn', turnId: 'turn-1', state: 'running' } } ], + // A send the provider never answered: on a writable chat it alone reads as working. + submissions: [ + { + clientMessageId: 'send-1', + fence: 1, + payloadFingerprint: 'fingerprint', + dispatchState: 'pending', + providerItemId: null, + reason: null, + submittedAt: 1, + resolvedAt: null + } + ], backgroundTasks: { state: 'monitoring', tasks: [], supportsTaskStop: true }, ...(readOnly ? { readOnly } : {}) } From d543a0aedc1a6ddb64832362d3465f1836b19260 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 01:45:22 -0700 Subject: [PATCH 15/52] test(native-chat): composer lock test without type assertions --- .../src/components/native-chat/NativeChatComposer.test.tsx | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx index 1b675dc00a40..f60ec36f87de 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx @@ -14,7 +14,7 @@ const mocks = vi.hoisted(() => ({ composerIsComposing: null as (() => boolean) | null, attachmentIsComposing: null as (() => boolean) | null, flushPendingAttachments: vi.fn(), - voice: { enabled: false } as { enabled: boolean; sttModel?: string }, + voice: ((): { enabled: boolean; sttModel?: string } => ({ enabled: false }))(), fieldProps: null as { onSend?: () => void onStop?: () => void @@ -27,11 +27,6 @@ const mocks = vi.hoisted(() => ({ sessionOptionsSnapshot?: SessionOptionDescriptor[] attachDisabled?: boolean sendButtonDisabled?: boolean - disabled?: boolean - canSend?: boolean - lockReason?: string - dictationDisabled?: boolean - sessionOptionsDisabled?: boolean autocomplete?: { mode: string; items?: { kind: string; name: string }[] } } | null, modelSwitchOutcome: 'applied' as 'applied' | 'rejected' | 'unknown', From b9c9efabd009e262a27bb01600c6da960c401b32 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 02:10:35 -0700 Subject: [PATCH 16/52] fix(native-chat): round-3 read-only fixes - An old /model or option request no longer reopens its picker when a read-only chat unlocks: the menu is keyed by the request alone, and a lock only keeps it from mounting open. - A disabled question card still steps through its questions; only answering is locked. - A failed send's notice in a read-only chat says only that the message was not sent, with no Retry: the composer already says why, and no retry can land. Its wiring moves into useStructuredAgentSessionDeliveryNotices. - The host's background-task stop flags stand; the client no longer overrides them. - The view reads the read-only fact through one local flag. - The schema header says what an older build's rewind does with each open string. --- .../NativeChatQuestionCard.test.tsx | 38 ++++++++ .../native-chat/NativeChatQuestionCard.tsx | 3 +- ...hatSessionOptionPickers.read-only.test.tsx | 92 +++++++++++++++++++ .../NativeChatSessionOptionPickers.tsx | 12 +-- .../NativeChatStructuredSession.tsx | 61 ++++-------- ...red-agent-session-delivery-notices.test.ts | 21 +++++ ...ructured-agent-session-delivery-notices.ts | 23 +++-- ...ructured-agent-session-delivery-notices.ts | 50 ++++++++++ ...red-agent-session-transport-state.test.tsx | 7 +- ...tructured-agent-session-transport-state.ts | 11 +-- src/shared/agent-session-journal-schemas.ts | 7 +- 11 files changed, 250 insertions(+), 75 deletions(-) create mode 100644 src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.read-only.test.tsx create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.ts diff --git a/src/renderer/src/components/native-chat/NativeChatQuestionCard.test.tsx b/src/renderer/src/components/native-chat/NativeChatQuestionCard.test.tsx index 90c14ed78465..54969c43c83a 100644 --- a/src/renderer/src/components/native-chat/NativeChatQuestionCard.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQuestionCard.test.tsx @@ -96,6 +96,44 @@ const tabsOrSpaces: AskPrompt = { } describe('NativeChatQuestionCard', () => { + it('a disabled card with several questions still steps through them, answering none', () => { + const onAnswer = vi.fn() + const twoQuestions: AskPrompt = { + questions: [ + { + question: 'First question?', + header: 'One', + multiSelect: false, + options: [{ label: 'A' }] + }, + { + question: 'Second question?', + header: 'Two', + multiSelect: false, + options: [{ label: 'C' }] + } + ] + } + act(() => { + root.render( + {}} + disabled + /> + ) + }) + click( + [...container.querySelectorAll('button')].find((b) => b.textContent?.includes('Two')), + 'tab Two' + ) + expect(container.textContent).toContain('Second question?') + clickOption('C') + expect(optionPressed('C')).toBe('false') + expect(onAnswer).not.toHaveBeenCalled() + }) + it('a disabled card shows the question with nothing to press and no "Sending…"', () => { const onAnswer = vi.fn() act(() => { diff --git a/src/renderer/src/components/native-chat/NativeChatQuestionCard.tsx b/src/renderer/src/components/native-chat/NativeChatQuestionCard.tsx index 2b44a88cf3df..ae46cabfd0f5 100644 --- a/src/renderer/src/components/native-chat/NativeChatQuestionCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQuestionCard.tsx @@ -169,7 +169,8 @@ export function NativeChatQuestionCard({ @@ -174,7 +170,7 @@ export function NativeChatApprovalCard({ @@ -222,7 +216,7 @@ export function NativeChatQuestionCard({ label={opt.label} description={opt.description} selected={(selections[index] ?? []).includes(i)} - disabled={locked} + disabled={isSubmitting} onSelect={() => pickOption(i)} /> ))} @@ -250,7 +244,7 @@ export function NativeChatQuestionCard({ still be acted on. */} setOther(index, e.target.value)} // Click, not focus: tabbing through the field toward Submit must not @@ -283,7 +277,7 @@ export function NativeChatQuestionCard({ )} @@ -176,7 +173,6 @@ export function NativeChatQueuedMessageCard({ variant="ghost" size="icon-xs" aria-label={translate('components.native-chat.queuedMessages.delete', 'Delete')} - disabled={disabled} onClick={onDelete} > @@ -201,7 +197,7 @@ export function NativeChatQueuedMessageCard({ - + {translate('components.native-chat.queuedMessages.editMessage', 'Edit message')} diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx index 6cd1a8477845..41fd4852a202 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx @@ -58,7 +58,6 @@ function controller( pause, resume: vi.fn(async () => {}), resuming: false, - writable: true, steer: vi.fn(async () => {}), remove: vi.fn(async () => {}), edit: vi.fn(async () => {}), @@ -471,20 +470,4 @@ describe('NativeChatQueuedMessageList', () => { fireEvent.click(await screen.findByRole('menuitem', { name: 'Turn off queueing' })) expect(mocks.updateSettings).toHaveBeenCalledWith({ nativeChatQueueFollowUps: false }) }) - - it('a read-only chat keeps its cards shown, with nothing to press that the host would refuse', async () => { - const owner = { - ...controller([card({ messageId: 'draft-1', hold: 'queue-paused' })], { reason: 'stopped' }), - writable: false - } - renderList(owner) - for (const name of ['Steer', 'Delete', 'Resume']) { - expect(screen.getByRole('button', { name }).hasAttribute('disabled')).toBe(true) - } - fireEvent.pointerDown(screen.getByRole('button', { name: 'More actions' })) - const edit = await screen.findByRole('menuitem', { name: 'Edit message' }) - expect(edit.getAttribute('aria-disabled')).toBe('true') - fireEvent.click(edit) - expect(owner.edit).not.toHaveBeenCalled() - }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx index 4d335bcf7e62..17223e0194cb 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx @@ -45,7 +45,7 @@ export function NativeChatQueuedMessageList({ {pause ? ( refocusAfter(controller.resume())} /> ) : null} @@ -61,7 +61,6 @@ export function NativeChatQueuedMessageList({ key={card.messageId} card={card} showsSteerShortcut={card === newest} - disabled={!controller.writable} onSteer={() => refocusAfter(controller.steer(card.messageId))} onDelete={() => refocusAfter(controller.remove(card.messageId))} onEdit={() => refocusAfter(controller.edit(card.messageId))} @@ -101,11 +100,11 @@ function queuePauseText(pause: { reason: string }): string { function NativeChatQueuePauseRow({ pause, - disabled, + resuming, onResume }: { pause: { reason: string } - disabled: boolean + resuming: boolean onResume: () => void }): React.JSX.Element { const text = queuePauseText(pause) @@ -115,7 +114,7 @@ function NativeChatQueuePauseRow({

{text}

- diff --git a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.lock-lands.test.tsx b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.lock-lands.test.tsx deleted file mode 100644 index 4044f66a4c8f..000000000000 --- a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.lock-lands.test.tsx +++ /dev/null @@ -1,62 +0,0 @@ -// @vitest-environment happy-dom -import { act, cleanup, fireEvent, render } from '@testing-library/react' -import { afterEach, expect, it, vi } from 'vitest' -import type { SessionOptionDescriptor } from '../../../../shared/native-chat-session-options' -import { TooltipProvider } from '@/components/ui/tooltip' - -vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) - -import { NativeChatSessionOptionPickers } from './NativeChatSessionOptionPickers' - -const surface = { - getSnapshot: vi.fn(() => []), - setOption: vi.fn(), - invokeAction: vi.fn(), - subscribe: vi.fn(() => vi.fn()) -} -const model: SessionOptionDescriptor = { - id: 'model', - label: 'Model', - category: 'model', - kind: { - type: 'select', - currentValue: 'opus', - choices: [ - { value: 'opus', label: 'Opus' }, - { value: 'sonnet', label: 'Sonnet' } - ] - }, - valueSource: 'applied', - transport: 'catalog', - settable: true -} - -afterEach(cleanup) - -function pickers(disabled: boolean): React.JSX.Element { - return ( - - - - ) -} - -it('a menu open when the lock lands offers nothing the host would refuse', async () => { - const { rerender } = render(pickers(false)) - expect(document.querySelector('[role="menu"]')).not.toBeNull() - rerender(pickers(true)) - const sonnet = [...document.querySelectorAll('[role="menuitemradio"]')].find((node) => - node.textContent?.includes('Sonnet') - ) - expect(sonnet?.getAttribute('aria-disabled')).toBe('true') - await act(async () => { - fireEvent.click(sonnet!) - }) - expect(surface.setOption).not.toHaveBeenCalled() -}) diff --git a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.read-only.test.tsx b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.read-only.test.tsx deleted file mode 100644 index 639706c04e58..000000000000 --- a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.read-only.test.tsx +++ /dev/null @@ -1,91 +0,0 @@ -// @vitest-environment happy-dom -import { cleanup, render } from '@testing-library/react' -import type * as ReactModule from 'react' -import { afterEach, expect, it, vi } from 'vitest' -import type { SessionOptionDescriptor } from '../../../../shared/native-chat-session-options' - -vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) -vi.mock('@/components/ui/tooltip', () => ({ - Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, - TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}, - TooltipContent: ({ children }: { children: React.ReactNode }) =>
{children}
-})) -const mounts = vi.hoisted(() => new Array()) -vi.mock('@/components/ui/dropdown-menu', async () => { - const React = await vi.importActual('react') - const Pass = ({ children }: { children?: React.ReactNode }) =>
{children}
- return { - // Records each mount and whether it mounted open, as an uncontrolled menu opens on defaultOpen. - DropdownMenu: ({ - children, - defaultOpen - }: { - children: React.ReactNode - defaultOpen?: boolean - }) => { - // A lazy initial state runs once per mount. - React.useState(() => mounts.push(Boolean(defaultOpen))) - return
{children}
- }, - DropdownMenuTrigger: Pass, - DropdownMenuContent: Pass, - DropdownMenuLabel: Pass, - DropdownMenuSeparator: () =>
, - DropdownMenuItem: Pass, - DropdownMenuRadioGroup: Pass, - DropdownMenuRadioItem: Pass - } -}) - -import { NativeChatSessionOptionPickers } from './NativeChatSessionOptionPickers' - -const surface = { - getSnapshot: vi.fn(() => []), - setOption: vi.fn(), - invokeAction: vi.fn(), - subscribe: vi.fn(() => vi.fn()) -} -const model: SessionOptionDescriptor = { - id: 'model', - label: 'Model', - category: 'model', - kind: { - type: 'select', - currentValue: 'opus', - choices: [ - { value: 'opus', label: 'Opus' }, - { value: 'sonnet', label: 'Sonnet' } - ] - }, - valueSource: 'applied', - transport: 'catalog', - settable: true -} - -afterEach(() => { - cleanup() - mounts.length = 0 -}) - -it('an old /model request does not reopen its menu when a read-only chat unlocks', () => { - const props = { surface, snapshot: [model], isWorking: false } - const request = { id: 'model', sequence: 1 } - const { rerender } = render() - expect(mounts).toEqual([true]) - rerender() - rerender() - expect(mounts).toEqual([true]) -}) - -it('a request made while locked mounts the menu shut', () => { - render( - - ) - expect(mounts).toEqual([false]) -}) diff --git a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx index 9689a23c666b..45d30a315181 100644 --- a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx @@ -341,25 +341,6 @@ describe('NativeChatSessionOptionPickers', () => { ).toBe('true') }) - it('keeps both pickers shown but shut while the host refuses every change', () => { - render( - - ) - for (const name of ['Model Opus 4.8', 'Effort High']) { - const trigger = screen.getByRole('button', { name }) - expect(trigger.parentElement?.getAttribute('data-disabled')).toBe('true') - expect(trigger.closest('[data-testid="dropdown-root"]')?.getAttribute('data-open')).not.toBe( - 'true' - ) - } - }) - it('does not duplicate titles for unknown values or misname generic controls', () => { const { rerender } = render( (null) @@ -271,13 +268,12 @@ function NativeChatSessionOptionPickersInner({
@@ -287,7 +283,7 @@ function NativeChatSessionOptionPickersInner({ ) : null} setOption(model, value)} invokeAction={() => invokeAction(model)} /> @@ -296,12 +292,12 @@ function NativeChatSessionOptionPickersInner({ {options.length > 0 ? ( @@ -321,7 +317,7 @@ function NativeChatSessionOptionPickersInner({ ) : null} setOption(descriptor, value)} invokeAction={() => invokeAction(descriptor)} /> diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.newer-approval.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.newer-approval.test.tsx index 363c5c15e54a..f2b015d7fb38 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.newer-approval.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.newer-approval.test.tsx @@ -61,7 +61,6 @@ it("hands the card a newer Orca's subject in a writable chat, and its cancel goe /> ) expect(mocks.approvalCardProps).toMatchObject({ - disabled: false, approval: { subject: { kind: 'diff' } } }) expect(mocks.approvalCardProps?.approval).not.toHaveProperty('detail') @@ -107,8 +106,7 @@ it('keeps the composer open and writable beside a card this build cannot answer' mocks.promptItems = [NEWER_APPROVAL] renderSession() expect(mocks.composerProps).not.toBeNull() - expect(mocks.composerProps?.lockReason).toBeUndefined() - expect(mocks.approvalCardProps).toMatchObject({ disabled: false, shouldFocus: false }) + expect(mocks.approvalCardProps).toMatchObject({ shouldFocus: false }) }) it('gives a card this build can answer the composer slot', () => { diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.read-error.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.read-error.test.tsx index 3422e66254e1..3fe3f3037d8c 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.read-error.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.read-error.test.tsx @@ -71,6 +71,8 @@ it('says a damaged history cannot load in one line, without claiming Orca keeps expect(screen.queryByText('Could not load conversation')).toBeNull() expect(screen.queryByText(/keeps trying/)).toBeNull() expect(screen.queryByText(/agent_session_/)).toBeNull() + // Nothing to send into: a send would only be refused and say it again. + expect(mocks.composerProps).toBeNull() }) it("names a history that couldn't open right now once, and that the pane keeps trying", () => { @@ -86,6 +88,8 @@ it("names a history that couldn't open right now once, and that the pane keeps t expect(screen.getByText('Orca keeps trying to load it.')).toBeTruthy() expect(screen.queryByText(/could not be read/)).toBeNull() expect(screen.queryByText(/Try again/)).toBeNull() + // It can clear, so the composer stays and a send waits for the read. + expect(mocks.composerProps).not.toBeNull() }) it("says only that it keeps trying under a code's own words that the history didn't load", () => { @@ -103,17 +107,19 @@ it("says only that it keeps trying under a code's own words that the history did expect(screen.queryByText(/could not be read/)).toBeNull() }) -it('keeps the whole retrying line under a newer Orca\'s words, which name no one chat for "it"', () => { +it('says once that a newer Orca saved the chat and only an update opens it, with no composer', () => { mocks.status = 'error' mocks.readRefusal = journalRefusal('journalWrittenByNewerOrca') mocks.messages = [] renderPane() - expect(screen.getByText(/^Chats were saved by a newer Orca\./)).toBeTruthy() expect( - screen.getByText('The transcript could not be read. Orca keeps trying to load it.') - ).toBeTruthy() + screen.getAllByText('This chat was saved by a newer Orca. Update Orca to open it.') + ).toHaveLength(1) + expect(screen.queryByText('Could not load conversation')).toBeNull() + expect(screen.queryByText(/keeps trying/)).toBeNull() + expect(mocks.composerProps).toBeNull() }) it('says only that it is reconnecting, not as an error, when a failure names nothing', () => { diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.read-only.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.read-only.test.tsx deleted file mode 100644 index 75bbf38aca58..000000000000 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.read-only.test.tsx +++ /dev/null @@ -1,81 +0,0 @@ -// @vitest-environment happy-dom - -import { cleanup, render, screen } from '@testing-library/react' -import { afterEach, expect, it, vi } from 'vitest' -import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' - -const { mocks, moduleFactories, resetStructuredSessionMocks } = await vi.hoisted(async () => - (await import('./NativeChatStructuredSession.test-harness')).createStructuredSessionMocks() -) - -vi.mock('@/runtime/structured-agent-session-client', () => - moduleFactories.structuredAgentSessionClient() -) -vi.mock('./use-structured-agent-session', () => moduleFactories.useStructuredAgentSession()) -vi.mock('./use-native-chat-font-scale', () => moduleFactories.useNativeChatFontScale()) -vi.mock('./use-native-chat-file-link-context', () => moduleFactories.useNativeChatFileLinkContext()) -vi.mock('./use-native-chat-file-link-click', () => moduleFactories.useNativeChatFileLinkClick()) -vi.mock('./NativeChatMessageList', () => moduleFactories.nativeChatMessageList()) -vi.mock('./NativeChatComposer', () => moduleFactories.nativeChatComposer()) -vi.mock('./NativeChatEmptyState', () => moduleFactories.nativeChatEmptyState()) -vi.mock('./NativeChatApprovalCard', () => moduleFactories.nativeChatApprovalCard()) -vi.mock('./NativeChatQuestionCard', () => moduleFactories.nativeChatQuestionCard()) - -import { NativeChatStructuredSession } from './NativeChatStructuredSession' - -afterEach(() => { - cleanup() - localStorage.clear() - resetStructuredSessionMocks() -}) - -const REASON = 'Saved by a newer Orca. Update Orca to continue this chat.' - -const PENDING_APPROVAL: AgentJournalRenderItem = { - itemId: 'approval-item', - revision: 1, - sequence: 1, - observedAt: 1, - body: { - kind: 'approval', - title: 'Allow command?', - detail: 'pnpm test', - options: [{ id: 'allow', label: 'Allow' }], - resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } - } -} - -function renderSession() { - return render( - - ) -} - -it('locks the composer with the reason as its placeholder, under the pending prompt it cannot answer', () => { - mocks.readOnly = REASON - mocks.promptItems = [PENDING_APPROVAL] - renderSession() - expect(screen.getByTestId('structured-composer')).toBeTruthy() - expect(mocks.composerProps).toMatchObject({ lockReason: REASON }) - expect(document.querySelector('[data-native-chat-approval-card-mock]')).not.toBeNull() - expect(mocks.approvalCardProps).toMatchObject({ disabled: true, shouldFocus: false }) - expect(screen.queryByText(REASON)).toBeNull() -}) - -it('leaves a writable chat as it was: the card takes the composer slot, answerable', () => { - mocks.promptItems = [PENDING_APPROVAL] - renderSession() - expect(screen.queryByTestId('structured-composer')).toBeNull() - expect(mocks.approvalCardProps).toMatchObject({ disabled: false }) - cleanup() - mocks.promptItems = [] - renderSession() - expect(mocks.composerProps).toMatchObject({ lockReason: undefined }) -}) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx index e49f7cf9f3ae..6e39ec0129de 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx @@ -80,12 +80,9 @@ export function createStructuredSessionMocks() { mode: 'static' as 'static' | 'outbox', status: 'ready' as 'idle' | 'loading' | 'ready' | 'error', readRefusal: absent(), - readOnly: absent(), messages: null as null | unknown[], messageListProps: initialMessageListProps, composerProps: nullable<{ - canSend?: boolean - lockReason?: string launchSeed?: NativeChatLaunchSeed structuredTransport?: Record isWorking?: boolean @@ -171,7 +168,6 @@ export function createStructuredSessionMocks() { status: mocks.status, error: outbox.error, readRefusal: mocks.readRefusal, - readOnly: mocks.readOnly, hasOlder: mocks.hasOlder, loadingOlder: mocks.loadingOlder, olderHistoryGeneration: mocks.olderHistoryGeneration, @@ -196,7 +192,6 @@ export function createStructuredSessionMocks() { stop: mocks.stop, queuedMessages: { cards: mocks.queuedCards, - writable: mocks.readOnly === undefined, steer: mocks.queuedSteer, remove: mocks.queuedRemove, edit: mocks.queuedEdit, @@ -309,7 +304,6 @@ export function createStructuredSessionMocks() { mocks.mode = 'static' mocks.status = 'ready' mocks.readRefusal = undefined - mocks.readOnly = undefined mocks.messages = null mocks.messageListProps = null mocks.composerProps = null diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 415b8026f597..2829d6b8a29b 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -1,4 +1,4 @@ -import { useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { agentSessionPromptQuestions } from '../../../../shared/agent-session-question-answer' import { dispatchStructuredAgentSessionComposerCommand } from '../../../../shared/structured-agent-session-composer' import { structuredAgentSessionPaneKey } from '../../../../shared/structured-agent-session-projection' @@ -28,9 +28,13 @@ import { useAppStore } from '../../store' import { structuredAgentLabel } from '@/lib/structured-agent-session-launch-label' import { NativeChatThreadGoalBanner } from './NativeChatThreadGoalBanner' import { structuredAgentSessionReadFailureNotice } from './structured-agent-session-read-failure-notice' -import { useStructuredAgentSessionDeliveryNotices } from './use-structured-agent-session-delivery-notices' +import { useStructuredAgentSessionStartFailureFacts } from './use-structured-agent-session-start-failure-facts' +import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices' +import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' import { pendingPromptsAllUnanswerableHere } from '../../../../shared/agent-session-approval-subject' +const NO_SUBMISSIONS: readonly AgentJournalSubmission[] = [] + export function NativeChatStructuredSession( props: Omit ): React.JSX.Element { @@ -110,18 +114,41 @@ export function NativeChatStructuredSession( }), [controller, props.agent, props.sessionId] ) - const agentLabel = structuredAgentLabel(props.agent === 'codex' ? 'codex' : 'claude') - // The session's one read-only fact, as its words (`controller.readOnly`) or as a flag here. - const readOnly = controller.readOnly !== undefined - const deliveryNotices = useStructuredAgentSessionDeliveryNotices({ - outbox: controller.outbox, - retry: controller.retry, - submissions: controller.submissions, - journalItems: controller.journalItems, - failedHere: controller.failedHere, - agentLabel, - readOnly + // Read at click time, so the notices stay put while the outbox's Retry is rebuilt each render. + const retryRef = useRef(controller.retry) + useEffect(() => { + retryRef.current = controller.retry }) + const retryDelivery = useCallback((clientMessageId: string) => { + retryRef.current(clientMessageId) + }, []) + const agentLabel = structuredAgentLabel(props.agent === 'codex' ? 'codex' : 'claude') + // Only a rejected message reads the journal's rows, so a new batch of them re-renders no row else. + const hasRejected = controller.outbox.some((entry) => entry.state === 'rejected') + const rejectionRows = hasRejected ? controller.submissions : NO_SUBMISSIONS + const startFailures = useStructuredAgentSessionStartFailureFacts( + controller.journalItems, + hasRejected + ) + const deliveryNotices = useMemo( + () => + structuredAgentSessionDeliveryNotices( + controller.outbox, + agentLabel, + retryDelivery, + rejectionRows, + startFailures, + controller.failedHere + ), + [ + controller.outbox, + agentLabel, + retryDelivery, + rejectionRows, + startFailures, + controller.failedHere + ] + ) const viewState = selectNativeChatViewState(session, { readRetries: true }) const readFailure = controller.status === 'error' @@ -136,10 +163,9 @@ export function NativeChatStructuredSession( ) const prompt = controller.prompts[0] ?? null // Prompts this build cannot answer leave the composer open: a send starts a turn, whose card - // cancel then works. A read-only chat shows its prompt unanswerable, above the composer that - // says why. + // cancel then works. A chat that cannot load says so once, in the pane, and offers no composer. const promptsUnanswerable = pendingPromptsAllUnanswerableHere(controller.prompts) - const composerShown = prompt === null || readOnly || promptsUnanswerable + const composerShown = (prompt === null || promptsUnanswerable) && !readFailure?.final const approvalBody = prompt?.body.kind === 'approval' ? prompt.body : null const approval = approvalBody ? { @@ -328,8 +354,7 @@ export function NativeChatStructuredSession( approval={approval} onChoose={(optionId) => void controller.respond(prompt, { kind: 'option', optionId })} onCancel={cancelPrompt} - disabled={readOnly} - shouldFocus={!readOnly && !promptsUnanswerable && props.isVisible && props.isFocusedGroup} + shouldFocus={!promptsUnanswerable && props.isVisible && props.isFocusedGroup} onLinkClick={onLinkClick} allowFileUriLinks={onLinkClick !== undefined} /> @@ -364,7 +389,6 @@ export function NativeChatStructuredSession( } }} onCancel={cancelPrompt} - disabled={readOnly} /> ) : null} {composerShown ? ( @@ -374,7 +398,6 @@ export function NativeChatStructuredSession( paneKey={paneKey} targetPtyId={null} agent={props.agent} - lockReason={controller.readOnly} isWorking={controller.canStop} onStop={() => void controller.stop()} steerQueued={controller.queuedMessages.steerNewest} diff --git a/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts b/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts index 3bcadbb1aba0..7e225b1367d7 100644 --- a/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts +++ b/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts @@ -4,10 +4,6 @@ import { translate } from '@/i18n/i18n' import { agentSessionFailureSentence } from '../../../../shared/agent-session-failure-words' import { agentSessionWriteNoticeParts } from '../../../../shared/agent-session-refusal-notice' -import { - agentSessionReadOnlyNoticeParts, - type AgentSessionReadOnlyReason -} from '../../../../shared/agent-session-read-only' import { AGENT_SESSION_WRITE_NOTICE_COPY as COPY, type AgentSessionWriteNoticePart, @@ -60,11 +56,8 @@ const SENTENCES: Record string> = { ), chatSavedByNewerOrca: () => translate('components.native-chat.writeNotice.chatSavedByNewerOrca', COPY.chatSavedByNewerOrca), - updateOrcaToContinueChat: () => - translate( - 'components.native-chat.writeNotice.updateOrcaToContinueChat', - COPY.updateOrcaToContinueChat - ), + updateOrcaToOpenChat: () => + translate('components.native-chat.writeNotice.updateOrcaToOpenChat', COPY.updateOrcaToOpenChat), unsupported: () => translate('components.native-chat.writeNotice.unsupported', COPY.unsupported), unreachable: () => translate('components.native-chat.writeNotice.unreachable', COPY.unreachable), recordFailed: () => @@ -156,11 +149,3 @@ export function agentSessionWriteFailureText( ): string { return agentSessionWriteNoticeText(agentSessionWriteNoticeParts(failure, write)) } - -/** Why the host keeps a chat read-only, in words; none for a reason this client cannot word. */ -export function agentSessionReadOnlyText( - reason: AgentSessionReadOnlyReason | undefined -): string | undefined { - const parts = agentSessionReadOnlyNoticeParts(reason) - return parts ? agentSessionWriteNoticeText(parts) : undefined -} diff --git a/src/renderer/src/components/native-chat/native-chat-composer-target.test.ts b/src/renderer/src/components/native-chat/native-chat-composer-target.test.ts deleted file mode 100644 index c2c4ad9ae3c3..000000000000 --- a/src/renderer/src/components/native-chat/native-chat-composer-target.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { expect, it } from 'vitest' -import { nativeChatComposerPlaceholder } from './native-chat-composer-target' - -it("says a locked composer's reason when one is given, else that another device holds the input", () => { - expect(nativeChatComposerPlaceholder(true, false, 'Saved by a newer Orca.')).toBe( - 'Saved by a newer Orca.' - ) - expect(nativeChatComposerPlaceholder(true, false)).toBe('Input is held by another device.') - expect(nativeChatComposerPlaceholder(true, true, 'Saved by a newer Orca.')).toBe( - 'Send a message…' - ) -}) diff --git a/src/renderer/src/components/native-chat/native-chat-composer-target.ts b/src/renderer/src/components/native-chat/native-chat-composer-target.ts index 8c908e655db2..c88882f7ced3 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-target.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-target.ts @@ -11,11 +11,7 @@ export type NativeChatResolvedTarget = { * pathological clipboard can't stall the round-trip. */ export const NATIVE_CHAT_CONTEXT_PASTE_MAX_BYTES = 16 * 1024 * 1024 -export function nativeChatComposerPlaceholder( - hasPty: boolean, - canSend: boolean, - lockReason?: string -): string { +export function nativeChatComposerPlaceholder(hasPty: boolean, canSend: boolean): string { if (!hasPty) { return translate( 'components.native-chat.composer.noPty', @@ -23,10 +19,7 @@ export function nativeChatComposerPlaceholder( ) } if (!canSend) { - return ( - lockReason ?? - translate('components.native-chat.composer.locked', 'Input is held by another device.') - ) + return translate('components.native-chat.composer.locked', 'Input is held by another device.') } return translate('components.native-chat.composer.placeholder', 'Send a message…') } diff --git a/src/renderer/src/components/native-chat/native-chat-composer-types.ts b/src/renderer/src/components/native-chat/native-chat-composer-types.ts index 5c92e4a6ef2c..922d16924f3f 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-types.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-types.ts @@ -55,9 +55,6 @@ export type NativeChatComposerProps = { agent: AgentType /** Guard desktop sends while a mobile client owns the terminal input lease. */ canSend?: boolean - /** Locks the composer, its placeholder saying why; `canSend` false alone means another device - * holds the input. */ - lockReason?: string /** True while the hosted TUI reports an in-flight turn; swaps Send to Stop. */ isWorking?: boolean /** Interrupt the hosted agent, usually by sending ESC into the PTY. */ diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts index 66852c4dfda7..5dde72621adb 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts @@ -56,50 +56,6 @@ function texts( } describe('the notice on each message that did not go through', () => { - it('in a read-only chat shortens only the newer-Orca refusal; every failure loses its Retry', () => { - const outbox = [ - entry('newer-orca', { - state: 'rejected', - lastFailure: { - kind: 'refused', - code: 'agent_session_journal_unreadable', - details: { reason: 'journalWrittenByNewerOrca' } - } - }), - entry('provider', { - state: 'rejected', - lastFailure: { kind: 'rejected', reason: 'Prompt is too long' } - }), - entry('expired', { - lastFailure: { kind: 'refused', code: 'agent_session_operation_expired' } - }) - ] - const failedHere = new Set(['newer-orca', 'provider', 'expired']) - const notices = (readOnly: boolean) => - structuredAgentSessionDeliveryNotices( - outbox, - 'Claude', - () => {}, - [], - [], - failedHere, - readOnly - ) - const readOnly = notices(true) - const writable = notices(false) - expect(readOnly.get(agentJournalSubmissionKey('newer-orca'))).toEqual({ - text: 'Your message was not sent.' - }) - for (const id of ['provider', 'expired']) { - const key = agentJournalSubmissionKey(id) - expect(readOnly.get(key)?.text).toBe(writable.get(key)?.text) - } - expect(readOnly.get(agentJournalSubmissionKey('expired'))?.text).toBe( - "Orca couldn't confirm what happened. Check the chat." - ) - expect([...readOnly.values()].some((notice) => notice.onRetry)).toBe(false) - }) - it('gives two failed messages each their own reason and their own Retry', () => { const retry = vi.fn() const notices = structuredAgentSessionDeliveryNotices( diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts index 8fbeffec16d1..6ad655e8f80c 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts @@ -129,16 +129,6 @@ function deliveryNoticeText( ) } -function refusedAsWrittenByNewerOrca( - failure: StructuredAgentSessionOutboxEntry['lastFailure'] -): boolean { - return ( - failure?.kind === 'refused' && - failure.code === 'agent_session_journal_unreadable' && - failure.details?.reason === 'journalWrittenByNewerOrca' - ) -} - /** Keyed by the message id the transcript renders each entry under; `agentName` is the chat's * agent, for the words. */ export function structuredAgentSessionDeliveryNotices( @@ -150,9 +140,7 @@ export function structuredAgentSessionDeliveryNotices( /** What the loaded start-failure rows state, from `structuredAgentSessionStartFailureFacts`. */ startFailures: readonly AgentSessionFailureFact[], /** Ids whose send failed or was refused while this chat was open: only they word their cause. */ - failedHere: ReadonlySet, - /** The host refuses every write: no Retry can land. */ - readOnly = false + failedHere: ReadonlySet ): ReadonlyMap { const admission = admitStructuredAgentSessionOutboxEntry(outbox) const held = admission.state === 'blocked' ? admission.entry.clientMessageId : null @@ -170,18 +158,14 @@ export function structuredAgentSessionDeliveryNotices( entry.clientMessageId === held ) { // Its own Retry is the step, so the words leave out sending again. - const retryControl = !readOnly && (stalledFrom === -1 || index <= stalledFrom) - // The composer says why a newer Orca's chat refuses writes; every other failure keeps its words. - const text = - readOnly && refusedAsWrittenByNewerOrca(entry.lastFailure) - ? agentSessionWriteNoticeText(agentSessionWriteNotDoneParts('send')) - : deliveryNoticeText( - entry, - { agentName, retryControl }, - rejected.get(entry.clientMessageId), - startFailures, - failedHere - ) + const retryControl = stalledFrom === -1 || index <= stalledFrom + const text = deliveryNoticeText( + entry, + { agentName, retryControl }, + rejected.get(entry.clientMessageId), + startFailures, + failedHere + ) notices.set( agentJournalSubmissionKey(entry.clientMessageId), retryControl ? { text, onRetry: () => retry(entry.clientMessageId) } : { text } diff --git a/src/renderer/src/components/native-chat/structured-agent-session-read-owner.ts b/src/renderer/src/components/native-chat/structured-agent-session-read-owner.ts index d540a55a5af0..e3f87a8e95e6 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-read-owner.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-read-owner.ts @@ -224,8 +224,7 @@ function createReadOwner( const transport = startStructuredAgentSessionReadTransport({ applyEvent: (event) => apply({ type: 'event', event }), applyError: (message, refusal) => apply({ type: 'error', message, refusal }), - // A read-only chat asks for a whole page, the only frame that can say it takes writes again. - getCursor: () => (snapshot.state.readOnly ? null : snapshot.state.cursor), + getCursor: () => snapshot.state.cursor, onHistoryReadInvalidated: invalidateOlderPages, hydrate: snapshot.state.epoch === null ? hydrate : undefined, sessionId, diff --git a/src/renderer/src/components/native-chat/structured-agent-session-read-transport.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-read-transport.test.ts index eaee915ac685..3424b39dc3cd 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-read-transport.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-read-transport.test.ts @@ -316,45 +316,49 @@ describe('structured agent-session read transport unattached refusals', () => { } }) - it('hands the pane the refusal a read met, and stops reconnecting only past damage', async () => { - vi.useFakeTimers() - try { - const journalRefusal = (reason: string) => ({ - code: 'runtime_error', - message: 'agent_session_journal_unreadable', - data: { refusal: { code: 'agent_session_journal_unreadable', details: { reason } } } - }) - const applyError = vi.fn() - const transport = startWithHydration(async () => undefined, applyError) - await flushPromises() - expect(attempts).toHaveLength(1) + it.each(['journalCorrupt', 'journalWrittenByNewerOrca'])( + 'hands the pane the refusal a read met, and stops reconnecting only past %s', + async (final) => { + vi.useFakeTimers() + try { + const journalRefusal = (reason: string) => ({ + code: 'runtime_error', + message: 'agent_session_journal_unreadable', + data: { refusal: { code: 'agent_session_journal_unreadable', details: { reason } } } + }) + const applyError = vi.fn() + const transport = startWithHydration(async () => undefined, applyError) + await flushPromises() + expect(attempts).toHaveLength(1) - // An open that can clear keeps reconnecting. - attempts[0].onError(journalRefusal('journalUnavailable')) - attempts[0].closed.resolve({ unsubscribe: attempts[0].unsubscribe }) - await flushPromises() - expect(applyError).toHaveBeenLastCalledWith('agent_session_journal_unreadable', { - code: 'agent_session_journal_unreadable', - details: { reason: 'journalUnavailable' } - }) - await vi.advanceTimersByTimeAsync(750) - expect(attempts).toHaveLength(2) + // An open that can clear keeps reconnecting. + attempts[0].onError(journalRefusal('journalUnavailable')) + attempts[0].closed.resolve({ unsubscribe: attempts[0].unsubscribe }) + await flushPromises() + expect(applyError).toHaveBeenLastCalledWith('agent_session_journal_unreadable', { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalUnavailable' } + }) + await vi.advanceTimersByTimeAsync(750) + expect(attempts).toHaveLength(2) - // Damage no retry reads past: decided from the reason, not the message, which is the same. - attempts[1].onError(journalRefusal('journalCorrupt')) - attempts[1].closed.resolve({ unsubscribe: attempts[1].unsubscribe }) - await flushPromises() - expect(applyError).toHaveBeenLastCalledWith('agent_session_journal_unreadable', { - code: 'agent_session_journal_unreadable', - details: { reason: 'journalCorrupt' } - }) - await vi.advanceTimersByTimeAsync(60_000) - expect(attempts).toHaveLength(2) - transport.dispose() - } finally { - vi.useRealTimers() + // Damage, or a newer Orca's chat, no retry reads past: decided from the reason, not the + // message, which is the same. + attempts[1].onError(journalRefusal(final)) + attempts[1].closed.resolve({ unsubscribe: attempts[1].unsubscribe }) + await flushPromises() + expect(applyError).toHaveBeenLastCalledWith('agent_session_journal_unreadable', { + code: 'agent_session_journal_unreadable', + details: { reason: final } + }) + await vi.advanceTimersByTimeAsync(60_000) + expect(attempts).toHaveLength(2) + transport.dispose() + } finally { + vi.useRealTimers() + } } - }) + ) it('reads a thrown hydrate refusal the same way, and a new run reads again', async () => { vi.useFakeTimers() diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.ts deleted file mode 100644 index db68f0c61ca0..000000000000 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.ts +++ /dev/null @@ -1,50 +0,0 @@ -// The structured chat's failed and unconfirmed sends, as the notices their transcript bubbles show. - -import { useCallback, useEffect, useMemo, useRef } from 'react' -import type { - AgentJournalRenderItem, - AgentJournalSubmission -} from '../../../../shared/agent-session-journal-types' -import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' -import type { NativeChatDeliveryNotice } from './NativeChatMessageRow' -import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices' -import { useStructuredAgentSessionStartFailureFacts } from './use-structured-agent-session-start-failure-facts' - -const NO_SUBMISSIONS: readonly AgentJournalSubmission[] = [] - -export function useStructuredAgentSessionDeliveryNotices(args: { - outbox: readonly StructuredAgentSessionOutboxEntry[] - retry: (clientMessageId: string) => void - submissions: readonly AgentJournalSubmission[] - journalItems: readonly AgentJournalRenderItem[] - failedHere: ReadonlySet - agentLabel: string - readOnly: boolean -}): ReadonlyMap { - const { agentLabel, failedHere, outbox, readOnly } = args - // Read at click time, so the notices stay put while the outbox's Retry is rebuilt each render. - const retryRef = useRef(args.retry) - useEffect(() => { - retryRef.current = args.retry - }) - const retryDelivery = useCallback((clientMessageId: string) => { - retryRef.current(clientMessageId) - }, []) - // Only a rejected message reads the journal's rows, so a new batch of them re-renders no row else. - const hasRejected = outbox.some((entry) => entry.state === 'rejected') - const rejectionRows = hasRejected ? args.submissions : NO_SUBMISSIONS - const startFailures = useStructuredAgentSessionStartFailureFacts(args.journalItems, hasRejected) - return useMemo( - () => - structuredAgentSessionDeliveryNotices( - outbox, - agentLabel, - retryDelivery, - rejectionRows, - startFailures, - failedHere, - readOnly - ), - [outbox, agentLabel, retryDelivery, rejectionRows, startFailures, failedHere, readOnly] - ) -} diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx index fc93f193289c..fa4ae91b1eae 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.resume.test.tsx @@ -35,7 +35,6 @@ function renderController() { queuePause: { reason: 'stopped' }, submissions: [], hasPendingPrompt: false, - writable: true, composerScopeKey: undefined, mutate }) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.test.tsx index 2d4335852be9..5fdfc0a95c7a 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.test.tsx @@ -35,7 +35,6 @@ function createHarness( overrides: { queuedMessages?: AgentSessionQueuedMessage[] enabled?: boolean - writable?: boolean composerScopeKey?: string | undefined mutateResult?: (call: MutateCall) => unknown } = {} @@ -52,7 +51,6 @@ function createHarness( queuePause: null, submissions: [], hasPendingPrompt: false, - writable: overrides.writable ?? true, composerScopeKey: 'composerScopeKey' in overrides ? overrides.composerScopeKey : SCOPE, // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: each scripted answer is the result shape of the one mutate it responds to; generic erasure cannot express that. mutate: mutate as StructuredAgentSessionMutate @@ -94,9 +92,6 @@ describe('queued message actions', () => { expect(empty.result.current.steerNewest()).toBe(false) const disabled = createHarness({ enabled: false }) expect(disabled.result.current.steerNewest()).toBe(false) - const readOnly = createHarness({ writable: false }) - expect(readOnly.result.current.steerNewest()).toBe(false) - expect(readOnly.result.current.writable).toBe(false) }) it('Delete withdraws through queuedMessageDelete without touching the composer', async () => { diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts index d5be28d8894b..4596fb67180f 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts @@ -32,8 +32,6 @@ export type StructuredAgentSessionQueuedMessagesController = { resume: () => Promise /** A Resume is in flight. */ resuming: boolean - /** False while the host refuses every write: no card action or Resume can land. */ - writable: boolean /** Send-now into the running turn; the transcript shows it at delivery position. */ steer: (messageId: string) => Promise remove: (messageId: string) => Promise @@ -56,12 +54,10 @@ export function useStructuredAgentSessionQueuedMessages(args: { queuePause: AgentSessionQueuePause | null submissions: readonly AgentJournalSubmission[] hasPendingPrompt: boolean - writable: boolean composerScopeKey: string | undefined mutate: StructuredAgentSessionMutate }): StructuredAgentSessionQueuedMessagesController { const { composerScopeKey, enabled, hasPendingPrompt, mutate, queuedMessages, submissions } = args - const { writable } = args const pause = args.queuePause const cards = useMemo( @@ -171,7 +167,7 @@ export function useStructuredAgentSessionQueuedMessages(args: { }, [mutate]) const steerNewest = useCallback((): boolean => { - if (!enabled || !writable) { + if (!enabled) { return false } const newest = newestSteerableQueuedMessageCard(cardsRef.current) @@ -180,7 +176,7 @@ export function useStructuredAgentSessionQueuedMessages(args: { } void steer(newest.messageId) return true - }, [enabled, steer, writable]) + }, [enabled, steer]) - return { cards, pause, resume, resuming, writable, steer, remove, edit, steerNewest } + return { cards, pause, resume, resuming, steer, remove, edit, steerNewest } } diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-thread-goal.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-thread-goal.test.tsx index ca59c9aac168..2de4a2564e4e 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-thread-goal.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-thread-goal.test.tsx @@ -58,14 +58,12 @@ function harness(options: { journalItems?: readonly AgentJournalRenderItem[] support?: { current: AgentJournalThreadGoal | null } mutate?: StructuredAgentSessionMutate - writable?: boolean }) { const mutate = options.mutate ?? mutateWith(async () => null).mutate return renderHook(() => useStructuredAgentSessionThreadGoal({ journalItems: options.journalItems ?? [], support: options.support, - writable: options.writable ?? true, mutate }) ) @@ -76,13 +74,6 @@ describe('useStructuredAgentSessionThreadGoal', () => { expect(harness({ journalItems: [goalRow(1, GOAL)] }).result.current).toBeNull() }) - it("holds the goal's actions while the host refuses every write", () => { - expect(harness({ support: { current: GOAL } }).result.current?.pending).toBe(false) - expect(harness({ support: { current: GOAL }, writable: false }).result.current?.pending).toBe( - true - ) - }) - it('reads the goal off the loaded window, and off the host answer only when the window has none', () => { const older = { ...GOAL, objective: 'Older goal' } const fromWindow = harness({ journalItems: [goalRow(1, GOAL)], support: { current: older } }) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-thread-goal.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-thread-goal.ts index 5b884a704299..a963a3b9c9ae 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-thread-goal.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-thread-goal.ts @@ -13,7 +13,6 @@ import type { StructuredAgentSessionMutate } from './use-structured-agent-sessio export type StructuredAgentSessionThreadGoal = { goal: AgentJournalThreadGoal | null - /** A change is in flight, or the host takes none. */ pending: boolean /** Resolves false when the change was refused or not sent; the error surfaces separately. */ change: (change: AgentSessionThreadGoalChange) => Promise @@ -23,10 +22,9 @@ export type StructuredAgentSessionThreadGoal = { export function useStructuredAgentSessionThreadGoal(args: { journalItems: readonly AgentJournalRenderItem[] support: AgentSessionOptionsResult['threadGoal'] - writable: boolean mutate: StructuredAgentSessionMutate }): StructuredAgentSessionThreadGoal | null { - const { journalItems, mutate, support, writable } = args + const { journalItems, mutate, support } = args const [pending, setPending] = useState(false) const pendingRef = useRef(false) // The loaded window reaches the live head, so a goal row in it is newer than the @@ -54,5 +52,5 @@ export function useStructuredAgentSessionThreadGoal(args: { }, [mutate] ) - return support ? { goal, pending: pending || !writable, change } : null + return support ? { goal, pending, change } : null } diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-transport-state.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-transport-state.test.tsx deleted file mode 100644 index 778b2df6f222..000000000000 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-transport-state.test.tsx +++ /dev/null @@ -1,67 +0,0 @@ -// @vitest-environment happy-dom -import { renderHook } from '@testing-library/react' -import { expect, it } from 'vitest' -import type { AgentSessionReadOnlyReason } from '../../../../shared/agent-session-read-only' -import { - EMPTY_STRUCTURED_AGENT_SESSION, - type StructuredAgentSessionState -} from '../../../../shared/structured-agent-session-reducer' -import { useStructuredAgentSessionTransportState } from './use-structured-agent-session-transport-state' - -/** A chat whose turn opened and never settled, and a send never answered, as a read-only host - * leaves them. */ -function openTurn(readOnly?: AgentSessionReadOnlyReason): StructuredAgentSessionState { - return { - ...EMPTY_STRUCTURED_AGENT_SESSION, - epoch: 'epoch-1', - fence: 1, - status: 'ready', - items: [ - { - itemId: 'turn-1', - revision: 1, - sequence: 1, - observedAt: 1, - body: { kind: 'turn', turnId: 'turn-1', state: 'running' } - } - ], - // A send the provider never answered: on a writable chat it alone reads as working. - submissions: [ - { - clientMessageId: 'send-1', - fence: 1, - payloadFingerprint: 'fingerprint', - dispatchState: 'pending', - providerItemId: null, - reason: null, - submittedAt: 1, - resolvedAt: null - } - ], - ...(readOnly ? { readOnly } : {}) - } -} - -function transport(state: StructuredAgentSessionState) { - return renderHook(() => useStructuredAgentSessionTransportState(state, true)).result.current -} - -it('a read-only chat has the words for why, no turn and no work, as its host projects it', () => { - const readOnly = transport(openTurn('written-by-newer-orca')) - expect(readOnly).toMatchObject({ - readOnly: 'Saved by a newer Orca. Update Orca to continue this chat.', - turnId: null, - isWorking: false - }) - expect(transport(openTurn())).toMatchObject({ - readOnly: undefined, - turnId: 'turn-1', - isWorking: true - }) -}) - -it('a reason this client cannot word leaves the chat as the journal says', () => { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: stands in for an arm a newer host could publish, which this build's type cannot name. - const later = transport(openTurn('a-later-reason' as AgentSessionReadOnlyReason)) - expect(later).toMatchObject({ readOnly: undefined, turnId: 'turn-1', isWorking: true }) -}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-transport-state.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-transport-state.ts index 809aa9101331..3b235b4e8ebc 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-transport-state.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-transport-state.ts @@ -6,7 +6,6 @@ import type { StructuredAgentSubagentRoster } from '../../../../shared/structure import { selectStructuredAgentTurnActivity } from '../../../../shared/native-chat-turn-activity' import { structuredSessionBackgroundTasksView } from './structured-session-background-tasks-view' import { useStructuredAgentTurnTiming } from './use-structured-agent-turn-timing' -import { agentSessionReadOnlyText } from './agent-session-write-notice-text' const NO_JOURNAL_ITEMS: StructuredAgentSessionState['items'] = [] const NO_SUBMISSIONS: StructuredAgentSessionState['submissions'] = [] @@ -20,13 +19,9 @@ export function useStructuredAgentSessionTransportState( const submissions = enabled ? state.submissions : NO_SUBMISSIONS const subagentRoster = (enabled ? state.subagentRoster : undefined) ?? NO_SUBAGENT_ROSTER const fence = enabled ? state.fence : null - // The one fact every write control reads: why the host refuses writes here, or none. - const readOnly = enabled ? agentSessionReadOnlyText(state.readOnly) : undefined - // A read-only host projects no turn (structured-agent-session-status-journal-projection.ts). - const turnId = readOnly ? null : activeStructuredAgentSessionTurnId(journalItems) + const turnId = activeStructuredAgentSessionTurnId(journalItems) // The rule the host projects every session list's Working from, so this chat cannot disagree. - const isWorking = - !readOnly && isStructuredAgentSessionMainAgentWorking(turnId, submissions, fence) + const isWorking = isStructuredAgentSessionMainAgentWorking(turnId, submissions, fence) const turnActivity = useMemo( () => selectStructuredAgentTurnActivity(journalItems, turnId, enabled ? state.activity : null), [enabled, journalItems, state.activity, turnId] @@ -40,7 +35,6 @@ export function useStructuredAgentSessionTransportState( turnId ) return { - readOnly, journalItems, subagentRoster, submissions, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index 87731a9939df..8633e6239684 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -137,7 +137,6 @@ export function useStructuredAgentSession(args: { const threadGoal = useStructuredAgentSessionThreadGoal({ journalItems: transportState.journalItems, support: threadGoalSupport, - writable: !transportState.readOnly, mutate }) const contextUsage = useStructuredAgentSessionContextUsage( @@ -158,11 +157,10 @@ export function useStructuredAgentSession(args: { const stopsConversation = useStructuredAgentSessionHostStopsConversation(target) && transportState.fence !== null const canStop = - !transportState.readOnly && - (transportState.turnId !== null || - (stopsConversation && - (transportState.isWorking || - hasUnsentStructuredAgentSessionOutboxEntry(outbox, transportState.submissions)))) + transportState.turnId !== null || + (stopsConversation && + (transportState.isWorking || + hasUnsentStructuredAgentSessionOutboxEntry(outbox, transportState.submissions))) // A queued send is a card, never a transcript bubble. const isWorking = transportState.isWorking const transcriptOutbox = useMemo( @@ -180,7 +178,6 @@ export function useStructuredAgentSession(args: { queuePause: transportState.queuePause, submissions: transportState.submissions, hasPendingPrompt: prompts.length > 0, - writable: !transportState.readOnly, composerScopeKey, mutate }) @@ -213,8 +210,6 @@ export function useStructuredAgentSession(args: { error: outboxController.error, /** The refusal the failed read met, while `status` is `error`. */ readRefusal: transportEnabled ? state.readRefusal : undefined, - /** Why the host refuses every write here, in words; absent while it takes them. */ - readOnly: transportState.readOnly, hasOlder: transportEnabled && state.hasOlder, railOutline: transportEnabled ? railOutline : null, loadingOlder: transportEnabled && loadingOlder, diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 76b69d515739..e203181cd585 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2838,7 +2838,7 @@ "backgroundTasksRunning": "Background tasks are still running.", "capacity": "Orca has received too many requests in the last day.", "chatNotFound": "The Orca running this chat has no record of it.", - "chatSavedByNewerOrca": "Saved by a newer Orca.", + "chatSavedByNewerOrca": "This chat was saved by a newer Orca.", "clearUnfinished": "The last /clear didn't finish.", "commandRunning": "A /compact or /clear is still running.", "conversationCleared": "This conversation has been cleared.", @@ -2877,8 +2877,8 @@ "turnActive": "The agent is still responding.", "unreachable": "Orca couldn't reach the agent.", "unsupported": "The Orca running this chat doesn't support this. Update Orca, then try again.", - "updateOrcaToContinueChat": "Update Orca to continue this chat.", "updateOrcaToKeepUsing": "Update Orca to keep using them.", + "updateOrcaToOpenChat": "Update Orca to open it.", "waitForBackgroundTasks": "Wait for the background tasks to finish.", "waitForCommand": "Wait for the /compact or /clear to finish.", "waitForStart": "Wait for the agent to finish starting.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 4d8cd1b4c8d8..69c0428a3d49 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17589,8 +17589,8 @@ "historyUnavailable": "Orca couldn't open this chat's history right now.", "savedByNewerOrca": "Chats were saved by a newer Orca.", "updateOrcaToKeepUsing": "Update Orca to keep using them.", - "chatSavedByNewerOrca": "Saved by a newer Orca.", - "updateOrcaToContinueChat": "Update Orca to continue this chat.", + "chatSavedByNewerOrca": "This chat was saved by a newer Orca.", + "updateOrcaToOpenChat": "Update Orca to open it.", "unsupported": "The Orca running this chat doesn't support this. Update Orca, then try again.", "unreachable": "Orca couldn't reach the agent.", "recordFailed": "Orca couldn't record it in this chat's history.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 9d3589344752..184b8f3f46a0 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -15021,8 +15021,8 @@ "historyUnavailable": "Orca no pudo abrir el historial de este chat en este momento.", "savedByNewerOrca": "Los chats se guardaron con una versión más reciente de Orca.", "updateOrcaToKeepUsing": "Actualice Orca para seguir usándolos.", - "chatSavedByNewerOrca": "Guardado con una versión más reciente de Orca.", - "updateOrcaToContinueChat": "Actualice Orca para continuar este chat.", + "chatSavedByNewerOrca": "Este chat se guardó con una versión más reciente de Orca.", + "updateOrcaToOpenChat": "Actualice Orca para abrirlo.", "unsupported": "La versión de Orca que ejecuta este chat no lo admite. Actualice Orca y vuelva a intentarlo.", "unreachable": "Orca no pudo comunicarse con el agente.", "recordFailed": "Orca no pudo registrarlo en el historial de este chat.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index e9456a12b765..85fac5b5c1b6 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17487,8 +17487,8 @@ "historyUnavailable": "Orca n'a pas pu ouvrir l'historique de ce chat pour le moment.", "savedByNewerOrca": "Les chats ont été enregistrés par une version plus récente d'Orca.", "updateOrcaToKeepUsing": "Mettez à jour Orca pour continuer à les utiliser.", - "chatSavedByNewerOrca": "Enregistré par une version plus récente d'Orca.", - "updateOrcaToContinueChat": "Mettez à jour Orca pour poursuivre ce chat.", + "chatSavedByNewerOrca": "Ce chat a été enregistré par une version plus récente d'Orca.", + "updateOrcaToOpenChat": "Mettez à jour Orca pour l'ouvrir.", "unsupported": "L'instance d'Orca qui exécute ce chat ne le prend pas en charge. Mettez Orca à jour, puis réessayez.", "unreachable": "Orca n'a pas pu joindre l'agent.", "recordFailed": "Orca n'a pas pu l'enregistrer dans l'historique de ce chat.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 36da019eb754..7f20bbe0544c 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17423,8 +17423,8 @@ "historyUnavailable": "Orca は現在このチャットの履歴を開けませんでした。", "savedByNewerOrca": "チャットは新しいバージョンの Orca で保存されました。", "updateOrcaToKeepUsing": "引き続き使用するには、Orca を更新してください。", - "chatSavedByNewerOrca": "新しいバージョンの Orca で保存されました。", - "updateOrcaToContinueChat": "このチャットを続けるには、Orca を更新してください。", + "chatSavedByNewerOrca": "このチャットは新しいバージョンの Orca で保存されました。", + "updateOrcaToOpenChat": "開くには Orca を更新してください。", "unsupported": "このチャットを実行している Orca はこの操作に対応していません。Orca を更新してから、もう一度お試しください。", "unreachable": "Orca は Agent に接続できませんでした。", "recordFailed": "Orca はこのチャットの履歴に記録できませんでした。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index ae859f79f3a7..02b5b89a87c9 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17423,8 +17423,8 @@ "historyUnavailable": "Orca가 지금 이 채팅의 기록을 열지 못했습니다.", "savedByNewerOrca": "채팅이 최신 버전의 Orca에서 저장되었습니다.", "updateOrcaToKeepUsing": "계속 사용하려면 Orca를 업데이트하세요.", - "chatSavedByNewerOrca": "최신 버전의 Orca에서 저장되었습니다.", - "updateOrcaToContinueChat": "이 채팅을 계속하려면 Orca를 업데이트하세요.", + "chatSavedByNewerOrca": "이 채팅은 최신 버전의 Orca에서 저장되었습니다.", + "updateOrcaToOpenChat": "열려면 Orca를 업데이트하세요.", "unsupported": "이 채팅을 실행 중인 Orca가 이 기능을 지원하지 않습니다. Orca를 업데이트한 후 다시 시도하세요.", "unreachable": "Orca가 에이전트에 연결하지 못했습니다.", "recordFailed": "Orca가 이 채팅의 기록에 저장하지 못했습니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 750b88264f68..5d9ed3931df4 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17388,8 +17388,8 @@ "historyUnavailable": "Orca 目前无法打开此聊天的历史记录。", "savedByNewerOrca": "这些聊天由更新版本的 Orca 保存。", "updateOrcaToKeepUsing": "请更新 Orca 以继续使用这些聊天。", - "chatSavedByNewerOrca": "由更新版本的 Orca 保存。", - "updateOrcaToContinueChat": "请更新 Orca 以继续此聊天。", + "chatSavedByNewerOrca": "此聊天由更新版本的 Orca 保存。", + "updateOrcaToOpenChat": "请更新 Orca 以打开它。", "unsupported": "运行此聊天的 Orca 不支持此操作。请更新 Orca 后重试。", "unreachable": "Orca 无法连接到智能体。", "recordFailed": "Orca 无法将其记录到此聊天的历史记录中。", diff --git a/src/shared/agent-session-read-only.ts b/src/shared/agent-session-read-only.ts deleted file mode 100644 index 81984feb743a..000000000000 --- a/src/shared/agent-session-read-only.ts +++ /dev/null @@ -1,18 +0,0 @@ -// Why a host keeps a chat read-only, as it publishes it beside the chat's history, and the words a -// chat surface shows for it. The host owns the fact; a surface only says it. - -import type { AgentSessionWriteNoticeSentence } from './agent-session-write-notice-copy' - -/** Every latch a host has is a newer Orca's: its database, row version, row kind or content. */ -export const AGENT_SESSION_READ_ONLY_REASONS = ['written-by-newer-orca'] as const -export type AgentSessionReadOnlyReason = (typeof AGENT_SESSION_READ_ONLY_REASONS)[number] - -/** The words for a reason this client knows, shown as the locked composer's placeholder; none - * for one a newer host names, which this client cannot word truthfully. */ -export function agentSessionReadOnlyNoticeParts( - reason: AgentSessionReadOnlyReason | undefined -): AgentSessionWriteNoticeSentence[] | null { - return reason === 'written-by-newer-orca' - ? ['chatSavedByNewerOrca', 'updateOrcaToContinueChat'] - : null -} diff --git a/src/shared/agent-session-refusal-notice.test.ts b/src/shared/agent-session-refusal-notice.test.ts index 9037e219044a..73c975753851 100644 --- a/src/shared/agent-session-refusal-notice.test.ts +++ b/src/shared/agent-session-refusal-notice.test.ts @@ -546,7 +546,12 @@ describe('a chat whose history the host could not open', () => { 'send', "Orca couldn't open this chat's history right now. Your message was not sent. Try again." ], - // Only an update gets past it, so it never says to try again. + // Only an update gets past it, so it never says to try again. A read meets it on one chat. + [ + 'journalWrittenByNewerOrca', + 'read-history', + 'This chat was saved by a newer Orca. Update Orca to open it.' + ], [ 'journalWrittenByNewerOrca', 'send', diff --git a/src/shared/agent-session-refusal-notice.ts b/src/shared/agent-session-refusal-notice.ts index 0f9c47ded03f..cc36f1ab410a 100644 --- a/src/shared/agent-session-refusal-notice.ts +++ b/src/shared/agent-session-refusal-notice.ts @@ -10,22 +10,17 @@ // something beside the words retries it. // Surfaces keep the fact and choose the words when they show it, so nothing saved carries copy. -import type { AgentSessionFailureKind } from './agent-session-failure' import { agentSessionFailureSentence, type AgentSessionFailureWordsContext } from './agent-session-failure-words' -import type { AgentSessionRefusalReason } from './agent-session-refusal-details' import { AGENT_SESSION_HISTORY_UNREAD_CAUSES, AGENT_SESSION_WRITE_NOTICE_COPY, type AgentSessionWriteNoticePart, type AgentSessionWriteNoticeSentence } from './agent-session-write-notice-copy' -import type { - AgentSessionWireRefusal, - AgentSessionWireRefusalCode -} from './agent-session-wire-refusals' +import type { AgentSessionWireRefusal } from './agent-session-wire-refusals' import { agentSessionRefusalFailure, parseAgentSessionWriteFailure, @@ -33,6 +28,13 @@ import { type AgentSessionWriteKind, type AgentSessionWriteRefusal } from './agent-session-write-failure' +import { agentSessionRefusalReasonWords } from './agent-session-refusal-reason-words' + +export { + agentSessionRefusalReasonWords, + type AgentSessionRefusalAction, + type AgentSessionRefusalReasonWords +} from './agent-session-refusal-reason-words' const NOT_DONE: Record = { 'read-history': 'notDoneReadHistory', @@ -56,180 +58,6 @@ export function agentSessionWriteNotDoneParts( return write === 'composer-send' ? ['notDoneSend', 'tryAgainComposerSend'] : [NOT_DONE[write]] } -/** What the person can do about a refusal with this reason. */ -export type AgentSessionRefusalAction = - /** Use the control that sent the write again. */ - | 'retry' - /** Wait for what the notice names; its step says what. */ - | 'wait' - /** Take the step the notice names first. */ - | 'actFirst' - /** Continue somewhere else: the current conversation, or a new chat. */ - | 'goElsewhere' - | 'checkChat' - | 'updateOrca' - /** What the write was for is over: the question it answered moved on. */ - | 'nothingLeft' - /** Orca's own state or fault; nothing the person does gets past it. */ - | 'hostFinding' - -export type AgentSessionRefusalReasonWords = - /** The code's own words are the honest ones for this reason. */ - | { words: 'code'; action: AgentSessionRefusalAction } - /** What stopped the write, and the step past it where the person has one to take. */ - | { - cause: AgentSessionWriteNoticeSentence - step?: AgentSessionWriteNoticeSentence - action: AgentSessionRefusalAction - } - /** A start that failed: the sentence that failure has everywhere, whose next step is a send. */ - | { fact: AgentSessionFailureKind; action: AgentSessionRefusalAction } - -function codeWords(action: AgentSessionRefusalAction): AgentSessionRefusalReasonWords { - return { words: 'code', action } -} - -function causeWords( - cause: AgentSessionWriteNoticeSentence, - action: AgentSessionRefusalAction, - step?: AgentSessionWriteNoticeSentence -): AgentSessionRefusalReasonWords { - return step ? { cause, step, action } : { cause, action } -} - -const AGENT_STARTING = causeWords('agentStarting', 'wait', 'waitForStart') -const OWNER_UNPROVEN = causeWords('ownerUnproven', 'actFirst', 'reopenChat') -// Only a terminal agent an older build recorded holds a claim; quitting it frees the chat. -const TERMINAL_CLAIM = causeWords('terminalAgentHoldsChat', 'actFirst', 'quitTerminalAgent') - -// Every reason of every code, so a reason the host adds does not compile until it has words. -// Reasons only a create, attach, hold or adopted import meets never reach a chat write; they keep -// their code's words. -const REASON_WORDS = { - agent_session_operation_invalid: { - requestMalformed: codeWords('hostFinding'), - operationIdInvalid: codeWords('hostFinding'), - messageIdReused: codeWords('hostFinding'), - // Settled under that id, so the control's retry goes out under a new one. - operationRefusedEarlier: codeWords('retry'), - journalWriteFailed: causeWords('recordFailed', 'retry'), - conversationCleared: causeWords( - 'conversationCleared', - 'goElsewhere', - 'openCurrentConversation' - ), - // Only an older host sends this, and it keeps refusing the chat, so only a new chat continues. - clearUnconfirmed: causeWords('clearUnfinished', 'goElsewhere', 'startNewChat'), - // Only an older host sends this, for a /clear it never settled; only that host resolves it. - conversationCommandUnconfirmed: codeWords('hostFinding'), - conversationCommandInFlight: causeWords('commandRunning', 'wait', 'waitForCommand'), - // The chat's agent process is being replaced, which a start or restart does. - handoffInFlight: AGENT_STARTING, - turnActive: causeWords('turnActive', 'wait', 'waitForTurn'), - promptPending: causeWords('promptPending', 'actFirst', 'answerFirst'), - backgroundTasksRunning: causeWords('backgroundTasksRunning', 'wait', 'waitForBackgroundTasks'), - messagesUnsettled: causeWords('messagesUnsettled', 'actFirst', 'settleEarlierMessage'), - // No chat surface sends a rewind; a replayed one says only that it did not happen. - rewindRefused: codeWords('hostFinding'), - rewindUnconfirmed: codeWords('hostFinding'), - promptGone: causeWords('questionChanged', 'nothingLeft'), - optionRejected: causeWords('optionRejected', 'retry'), - providerStarting: AGENT_STARTING, - goalsUnsupported: causeWords('goalsUnsupported', 'hostFinding'), - providerRejected: causeWords('agentRefused', 'retry'), - providerStartFailed: { fact: 'providerStartFailed', action: 'retry' }, - notSignedIn: { fact: 'notSignedIn', action: 'actFirst' }, - historyTooLarge: { fact: 'historyTooLarge', action: 'goElsewhere' }, - managedAccountEnvOverride: { fact: 'managedAccountEnvOverride', action: 'actFirst' }, - accountSwitchInProgress: { fact: 'accountSwitchInProgress', action: 'wait' }, - managedAccountUnsupported: { fact: 'managedAccountUnsupported', action: 'actFirst' }, - attachFailed: codeWords('retry') - }, - agent_session_ownership_unknown: { - sessionNotAttached: codeWords('retry'), - noLiveOwner: codeWords('retry'), - ownerUnproven: OWNER_UNPROVEN, - claimConflicted: TERMINAL_CLAIM, - recordMissing: codeWords('retry'), - replaySuperseded: codeWords('retry'), - leaseMoved: codeWords('retry'), - spawnIdentityMismatch: codeWords('hostFinding'), - notResumable: codeWords('retry'), - noProviderChild: codeWords('retry'), - conversationHeldElsewhere: codeWords('retry'), - // Trying again retries the stop that could not prove the exit. - previousExitUnverifiable: causeWords('ownerUnproven', 'retry') - }, - agent_session_conflict: { - chatStarting: AGENT_STARTING, - ownerUnproven: OWNER_UNPROVEN, - claimConflicted: TERMINAL_CLAIM, - ownerAlive: codeWords('retry'), - identityMismatch: codeWords('hostFinding'), - sessionExists: codeWords('retry'), - conversationHeldElsewhere: codeWords('retry'), - tabIdTaken: codeWords('retry') - }, - execution_owner_reconciling: { - hostReconciling: causeWords('hostReconciling', 'wait', 'waitMoment'), - recordUnreadable: causeWords('recordUnreadable', 'hostFinding') - }, - agent_session_checkpoint_stale: { - fenceStale: codeWords('retry'), - leaseMoved: codeWords('retry'), - recordMissing: codeWords('retry') - }, - agent_session_identity_required: { - recordMissing: causeWords('chatNotFound', 'goElsewhere', 'startNewChat'), - transcriptNotFound: codeWords('retry'), - transcriptUnreadable: codeWords('hostFinding') - }, - agent_session_operation_conflict: { - fingerprintMismatch: codeWords('hostFinding'), - operationIdReused: codeWords('hostFinding'), - handoffInFlight: codeWords('retry') - }, - agent_session_operation_expired: { operationExpired: codeWords('retry') }, - agent_session_operation_capacity: { operationCapacity: codeWords('wait') }, - agent_session_operation_unknown: { - outcomeUnknown: codeWords('checkChat'), - resultLost: codeWords('checkChat'), - rewindUnconfirmed: codeWords('checkChat'), - tabUnconfirmed: codeWords('checkChat') - }, - agent_session_item_revision_stale: { promptMoved: codeWords('nothingLeft') }, - agent_session_already_resolved: { promptAlreadyResolved: codeWords('nothingLeft') }, - agent_session_journal_unreadable: { - // No retry reads past damage, and the words name no step: it only can't load. - journalCorrupt: causeWords('historyUnusable', 'hostFinding'), - // Says its step despite 'retry' unless a Retry stands beside it: the phone often has none. - journalUnavailable: causeWords('historyUnavailable', 'retry', 'tryAgain'), - journalWrittenByNewerOrca: causeWords('savedByNewerOrca', 'updateOrca', 'updateOrcaToKeepUsing') - }, - // Thrown, so a client meets these only as an RPC error; the code's words stand. - structured_agent_session_unsupported: { - clientCapabilityMissing: codeWords('updateOrca'), - hostDisabled: codeWords('hostFinding'), - hostUnsupported: codeWords('updateOrca') - }, - agent_session_owner_restart_failed: {} -} satisfies { - [C in AgentSessionWireRefusalCode]: Record< - AgentSessionRefusalReason, - AgentSessionRefusalReasonWords - > -} - -/** The words and next step a reason gets; undefined when the refusal names none. */ -export function agentSessionRefusalReasonWords( - failure: AgentSessionWriteRefusal -): AgentSessionRefusalReasonWords | undefined { - const reason = failure.details?.reason - const byReason: Partial> | undefined = - REASON_WORDS[failure.code] - return reason === undefined ? undefined : byReason?.[reason] -} - /** A cause, and that the request did not happen unless the cause already says so. */ function causeParts( cause: AgentSessionWriteNoticeSentence, @@ -256,10 +84,11 @@ function reasonParts( ? [NOT_DONE[write], { failure: { kind: words.fact }, surface: 'rejection', context }] : undefined } - const said = causeParts(words.cause, write) + const { cause, step } = write === 'read-history' && words.history ? words.history : words + const said = causeParts(cause, write) // A Retry beside the words is the step for a reason whose action is to retry. const retried = context.retryControl && words.action === 'retry' - return words.step && !retried ? [...said, words.step] : said + return step && !retried ? [...said, step] : said } /** What stopped a refused start, for a line that already says the chat did not start and shows its diff --git a/src/shared/agent-session-refusal-reason-words.ts b/src/shared/agent-session-refusal-reason-words.ts new file mode 100644 index 000000000000..17accb2d2390 --- /dev/null +++ b/src/shared/agent-session-refusal-reason-words.ts @@ -0,0 +1,189 @@ +// The words and next step each refusal reason gets, for every code: a reason the host adds does not +// compile until it has words here. + +import type { AgentSessionFailureKind } from './agent-session-failure' +import type { AgentSessionRefusalReason } from './agent-session-refusal-details' +import type { AgentSessionWriteNoticeSentence } from './agent-session-write-notice-copy' +import type { AgentSessionWireRefusalCode } from './agent-session-wire-refusals' +import type { AgentSessionWriteRefusal } from './agent-session-write-failure' + +/** What the person can do about a refusal with this reason. */ +export type AgentSessionRefusalAction = + /** Use the control that sent the write again. */ + | 'retry' + /** Wait for what the notice names; its step says what. */ + | 'wait' + /** Take the step the notice names first. */ + | 'actFirst' + /** Continue somewhere else: the current conversation, or a new chat. */ + | 'goElsewhere' + | 'checkChat' + | 'updateOrca' + /** What the write was for is over: the question it answered moved on. */ + | 'nothingLeft' + /** Orca's own state or fault; nothing the person does gets past it. */ + | 'hostFinding' + +export type AgentSessionRefusalReasonWords = + /** The code's own words are the honest ones for this reason. */ + | { words: 'code'; action: AgentSessionRefusalAction } + /** What stopped the write, and the step past it where the person has one to take. */ + | { + cause: AgentSessionWriteNoticeSentence + step?: AgentSessionWriteNoticeSentence + action: AgentSessionRefusalAction + /** Its words when what failed is reading the chat's history. */ + history?: { cause: AgentSessionWriteNoticeSentence; step: AgentSessionWriteNoticeSentence } + } + /** A start that failed: the sentence that failure has everywhere, whose next step is a send. */ + | { fact: AgentSessionFailureKind; action: AgentSessionRefusalAction } + +function codeWords(action: AgentSessionRefusalAction): AgentSessionRefusalReasonWords { + return { words: 'code', action } +} + +function causeWords( + cause: AgentSessionWriteNoticeSentence, + action: AgentSessionRefusalAction, + step?: AgentSessionWriteNoticeSentence +): AgentSessionRefusalReasonWords { + return step ? { cause, step, action } : { cause, action } +} + +const AGENT_STARTING = causeWords('agentStarting', 'wait', 'waitForStart') +const OWNER_UNPROVEN = causeWords('ownerUnproven', 'actFirst', 'reopenChat') +// Only a terminal agent an older build recorded holds a claim; quitting it frees the chat. +const TERMINAL_CLAIM = causeWords('terminalAgentHoldsChat', 'actFirst', 'quitTerminalAgent') + +// Every reason of every code, so a reason the host adds does not compile until it has words. +// Reasons only a create, attach, hold or adopted import meets never reach a chat write; they keep +// their code's words. +const REASON_WORDS = { + agent_session_operation_invalid: { + requestMalformed: codeWords('hostFinding'), + operationIdInvalid: codeWords('hostFinding'), + messageIdReused: codeWords('hostFinding'), + // Settled under that id, so the control's retry goes out under a new one. + operationRefusedEarlier: codeWords('retry'), + journalWriteFailed: causeWords('recordFailed', 'retry'), + conversationCleared: causeWords( + 'conversationCleared', + 'goElsewhere', + 'openCurrentConversation' + ), + // Only an older host sends this, and it keeps refusing the chat, so only a new chat continues. + clearUnconfirmed: causeWords('clearUnfinished', 'goElsewhere', 'startNewChat'), + // Only an older host sends this, for a /clear it never settled; only that host resolves it. + conversationCommandUnconfirmed: codeWords('hostFinding'), + conversationCommandInFlight: causeWords('commandRunning', 'wait', 'waitForCommand'), + // The chat's agent process is being replaced, which a start or restart does. + handoffInFlight: AGENT_STARTING, + turnActive: causeWords('turnActive', 'wait', 'waitForTurn'), + promptPending: causeWords('promptPending', 'actFirst', 'answerFirst'), + backgroundTasksRunning: causeWords('backgroundTasksRunning', 'wait', 'waitForBackgroundTasks'), + messagesUnsettled: causeWords('messagesUnsettled', 'actFirst', 'settleEarlierMessage'), + // No chat surface sends a rewind; a replayed one says only that it did not happen. + rewindRefused: codeWords('hostFinding'), + rewindUnconfirmed: codeWords('hostFinding'), + promptGone: causeWords('questionChanged', 'nothingLeft'), + optionRejected: causeWords('optionRejected', 'retry'), + providerStarting: AGENT_STARTING, + goalsUnsupported: causeWords('goalsUnsupported', 'hostFinding'), + providerRejected: causeWords('agentRefused', 'retry'), + providerStartFailed: { fact: 'providerStartFailed', action: 'retry' }, + notSignedIn: { fact: 'notSignedIn', action: 'actFirst' }, + historyTooLarge: { fact: 'historyTooLarge', action: 'goElsewhere' }, + managedAccountEnvOverride: { fact: 'managedAccountEnvOverride', action: 'actFirst' }, + accountSwitchInProgress: { fact: 'accountSwitchInProgress', action: 'wait' }, + managedAccountUnsupported: { fact: 'managedAccountUnsupported', action: 'actFirst' }, + attachFailed: codeWords('retry') + }, + agent_session_ownership_unknown: { + sessionNotAttached: codeWords('retry'), + noLiveOwner: codeWords('retry'), + ownerUnproven: OWNER_UNPROVEN, + claimConflicted: TERMINAL_CLAIM, + recordMissing: codeWords('retry'), + replaySuperseded: codeWords('retry'), + leaseMoved: codeWords('retry'), + spawnIdentityMismatch: codeWords('hostFinding'), + notResumable: codeWords('retry'), + noProviderChild: codeWords('retry'), + conversationHeldElsewhere: codeWords('retry'), + // Trying again retries the stop that could not prove the exit. + previousExitUnverifiable: causeWords('ownerUnproven', 'retry') + }, + agent_session_conflict: { + chatStarting: AGENT_STARTING, + ownerUnproven: OWNER_UNPROVEN, + claimConflicted: TERMINAL_CLAIM, + ownerAlive: codeWords('retry'), + identityMismatch: codeWords('hostFinding'), + sessionExists: codeWords('retry'), + conversationHeldElsewhere: codeWords('retry'), + tabIdTaken: codeWords('retry') + }, + execution_owner_reconciling: { + hostReconciling: causeWords('hostReconciling', 'wait', 'waitMoment'), + recordUnreadable: causeWords('recordUnreadable', 'hostFinding') + }, + agent_session_checkpoint_stale: { + fenceStale: codeWords('retry'), + leaseMoved: codeWords('retry'), + recordMissing: codeWords('retry') + }, + agent_session_identity_required: { + recordMissing: causeWords('chatNotFound', 'goElsewhere', 'startNewChat'), + transcriptNotFound: codeWords('retry'), + transcriptUnreadable: codeWords('hostFinding') + }, + agent_session_operation_conflict: { + fingerprintMismatch: codeWords('hostFinding'), + operationIdReused: codeWords('hostFinding'), + handoffInFlight: codeWords('retry') + }, + agent_session_operation_expired: { operationExpired: codeWords('retry') }, + agent_session_operation_capacity: { operationCapacity: codeWords('wait') }, + agent_session_operation_unknown: { + outcomeUnknown: codeWords('checkChat'), + resultLost: codeWords('checkChat'), + rewindUnconfirmed: codeWords('checkChat'), + tabUnconfirmed: codeWords('checkChat') + }, + agent_session_item_revision_stale: { promptMoved: codeWords('nothingLeft') }, + agent_session_already_resolved: { promptAlreadyResolved: codeWords('nothingLeft') }, + agent_session_journal_unreadable: { + // No retry reads past damage, and the words name no step: it only can't load. + journalCorrupt: causeWords('historyUnusable', 'hostFinding'), + // Says its step despite 'retry' unless a Retry stands beside it: the phone often has none. + journalUnavailable: causeWords('historyUnavailable', 'retry', 'tryAgain'), + // A write meets it on a newer Orca's database, or from an older host keeping the chat + // read-only; a read meets it on this one chat, which only an update opens. + journalWrittenByNewerOrca: { + ...causeWords('savedByNewerOrca', 'updateOrca', 'updateOrcaToKeepUsing'), + history: { cause: 'chatSavedByNewerOrca', step: 'updateOrcaToOpenChat' } + } + }, + // Thrown, so a client meets these only as an RPC error; the code's words stand. + structured_agent_session_unsupported: { + clientCapabilityMissing: codeWords('updateOrca'), + hostDisabled: codeWords('hostFinding'), + hostUnsupported: codeWords('updateOrca') + }, + agent_session_owner_restart_failed: {} +} satisfies { + [C in AgentSessionWireRefusalCode]: Record< + AgentSessionRefusalReason, + AgentSessionRefusalReasonWords + > +} + +/** The words and next step a reason gets; undefined when the refusal names none. */ +export function agentSessionRefusalReasonWords( + failure: AgentSessionWriteRefusal +): AgentSessionRefusalReasonWords | undefined { + const reason = failure.details?.reason + const byReason: Partial> | undefined = + REASON_WORDS[failure.code] + return reason === undefined ? undefined : byReason?.[reason] +} diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts index 97acd528bffa..3100087faa11 100644 --- a/src/shared/agent-session-wire.ts +++ b/src/shared/agent-session-wire.ts @@ -38,7 +38,6 @@ import { type AgentSessionRecord } from './agent-session-record' import type { AgentProviderSessionMetadata } from './agent-session-resume' -import type { AgentSessionReadOnlyReason } from './agent-session-read-only' import type { NativeChatSubagentEntry } from './native-chat-types' import type { StructuredAgentSessionProjectedStatus } from './structured-agent-session-projection' @@ -134,10 +133,6 @@ export type AgentSessionHistoryPage = { /** Names the subagents with rows on the page whose roster row is older than it; bounded. * Absent from older hosts, and when every such roster row is on the page. */ subagentRoster?: AgentSessionSubagentRosterEntry[] - /** Present while the host keeps this chat read-only, and why: it serves the history and refuses - * every write. Absent on a writable chat and from older hosts, which say nothing until a write - * is refused. */ - readOnly?: AgentSessionReadOnlyReason } export type AgentSessionHistoryResult = diff --git a/src/shared/agent-session-write-notice-copy.ts b/src/shared/agent-session-write-notice-copy.ts index ccc9ea00460f..fe96bf29116b 100644 --- a/src/shared/agent-session-write-notice-copy.ts +++ b/src/shared/agent-session-write-notice-copy.ts @@ -33,8 +33,8 @@ export const AGENT_SESSION_WRITE_NOTICE_COPY = { historyUnavailable: "Orca couldn't open this chat's history right now.", savedByNewerOrca: 'Chats were saved by a newer Orca.', updateOrcaToKeepUsing: 'Update Orca to keep using them.', - chatSavedByNewerOrca: 'Saved by a newer Orca.', - updateOrcaToContinueChat: 'Update Orca to continue this chat.', + chatSavedByNewerOrca: 'This chat was saved by a newer Orca.', + updateOrcaToOpenChat: 'Update Orca to open it.', unsupported: "The Orca running this chat doesn't support this. Update Orca, then try again.", unreachable: "Orca couldn't reach the agent.", recordFailed: "Orca couldn't record it in this chat's history.", @@ -85,7 +85,13 @@ export type AgentSessionWriteNoticePart = /** Causes that already say the history can't be read here, so no sentence after them says it * again. */ export const AGENT_SESSION_HISTORY_UNREAD_CAUSES: ReadonlySet = - new Set(['historyUnusable', 'historyUnavailable', 'historyUnreadable', 'savedByNewerOrca']) + new Set([ + 'historyUnusable', + 'historyUnavailable', + 'historyUnreadable', + 'savedByNewerOrca', + 'chatSavedByNewerOrca' + ]) /** Whether these words already say this chat's history didn't load, so a pane headed by them need * only add that it keeps trying. "Chats were saved by a newer Orca" names no one chat for "it". */ diff --git a/src/shared/structured-agent-session-read-refusal.ts b/src/shared/structured-agent-session-read-refusal.ts index 3f192441cdd1..9312c3d8e5b1 100644 --- a/src/shared/structured-agent-session-read-refusal.ts +++ b/src/shared/structured-agent-session-read-refusal.ts @@ -47,15 +47,17 @@ export function isUnattachedAgentSessionReadRefusal(error: unknown): boolean { } /** - * A read refusal no retry reads past: the chat's history is damaged, as SQLite or its rows show. - * Decided from the reason, never the message, which is the bare code for every journal refusal; a - * journal that failed to open for any other reason can clear, so its read keeps reconnecting. + * A read refusal no retry reads past: the chat's history is damaged, as SQLite or its rows show, or + * a newer Orca saved it, which only an update gets past. Decided from the reason, never the + * message, which is the bare code for every journal refusal; a journal that failed to open for any + * other reason can clear, so its read keeps reconnecting. */ export function isFinalAgentSessionReadRefusal( refusal: AgentSessionRefusalReference | undefined ): boolean { return ( refusal?.code === 'agent_session_journal_unreadable' && - refusal.details?.reason === 'journalCorrupt' + (refusal.details?.reason === 'journalCorrupt' || + refusal.details?.reason === 'journalWrittenByNewerOrca') ) } diff --git a/src/shared/structured-agent-session-reducer.read-only.test.ts b/src/shared/structured-agent-session-reducer.read-only.test.ts deleted file mode 100644 index cb388f73577d..000000000000 --- a/src/shared/structured-agent-session-reducer.read-only.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { expect, it } from 'vitest' -import type { AgentSessionHistoryPage, AgentSessionSubscribeEvent } from './agent-session-wire' -import { - EMPTY_STRUCTURED_AGENT_SESSION, - reduceStructuredAgentSession, - type StructuredAgentSessionState -} from './structured-agent-session-reducer' - -const CURSOR = { epoch: 'epoch-a', sequence: 2 } - -function page(readOnly?: 'written-by-newer-orca'): AgentSessionHistoryPage { - return { - sessionId: 'session-a', - epoch: 'epoch-a', - direction: 'tail', - items: [], - removedItemIds: [], - submissions: [], - window: { oldest: null, newest: null, nextCursor: CURSOR }, - liveCursor: CURSOR, - hasOlder: false, - hasNewer: false, - ...(readOnly ? { readOnly } : {}) - } -} - -function apply(state: StructuredAgentSessionState, event: AgentSessionSubscribeEvent) { - return reduceStructuredAgentSession(state, { type: 'event', event }, 1) -} - -it("keeps the host's read-only reason from a whole page until a later whole page drops it", () => { - const snapshot = apply(EMPTY_STRUCTURED_AGENT_SESSION, { - type: 'snapshot', - sessionId: 'session-a', - page: page('written-by-newer-orca'), - fence: 1 - }) - expect(snapshot.readOnly).toBe('written-by-newer-orca') - - const batch = apply(snapshot, { - type: 'batch', - sessionId: 'session-a', - batch: { - cursor: { epoch: 'epoch-a', sequence: 3 }, - items: [], - removedItemIds: [], - submissions: [] - } - }) - expect(batch.readOnly).toBe('written-by-newer-orca') - - const reset = apply(batch, { - type: 'reset', - sessionId: 'session-a', - reset: 'schema_unreadable', - page: page('written-by-newer-orca'), - fence: 1 - }) - expect(reset.readOnly).toBe('written-by-newer-orca') - - // An updated host restarts and hydrates the client again, writable. - const writable = apply(reset, { - type: 'snapshot', - sessionId: 'session-a', - page: page(), - fence: 2 - }) - expect(writable).not.toHaveProperty('readOnly') - const history = reduceStructuredAgentSession(writable, { - type: 'history-page', - page: page('written-by-newer-orca') - }) - expect(history.readOnly).toBe('written-by-newer-orca') -}) diff --git a/src/shared/structured-agent-session-reducer.ts b/src/shared/structured-agent-session-reducer.ts index 83867c72b36b..a140646f4195 100644 --- a/src/shared/structured-agent-session-reducer.ts +++ b/src/shared/structured-agent-session-reducer.ts @@ -13,7 +13,6 @@ import type { AgentSessionTurnActivity } from './agent-session-wire' import type { AgentSessionRefusalReference } from './agent-session-wire-refusals' -import type { AgentSessionReadOnlyReason } from './agent-session-read-only' import { backgroundTaskStatesEqual } from './agent-session-background-task-state-equality' import { admitAgentSessionBackgroundTaskState } from './agent-session-background-task-state-admission' import { @@ -75,9 +74,6 @@ export type StructuredAgentSessionState = { /** Bumped per live batch that leaves a turn row's newest revision outside the window * (dropped or trimmed), so a whole-journal answer derived from turn rows is asked for again. */ unloadedTurnRevisions?: number - /** Why the host keeps this chat read-only, from its latest whole page. Only a whole page clears - * it, so a client holding it reconnects for one. */ - readOnly?: AgentSessionReadOnlyReason } export type StructuredAgentSessionAction = @@ -140,7 +136,6 @@ function replacePage( status: 'ready', subagentRoster: foldStructuredAgentSubagentRosterPage(undefined, page), activity: activity ?? null, - ...(page.readOnly !== undefined ? { readOnly: page.readOnly } : {}), ...(backgroundTasks !== undefined ? { backgroundTasks } : page.backgroundTasks !== undefined diff --git a/tests/e2e/cross-version-wire/agent-session-read-only.unit.test.ts b/tests/e2e/cross-version-wire/agent-session-newer-chat-refusal.unit.test.ts similarity index 50% rename from tests/e2e/cross-version-wire/agent-session-read-only.unit.test.ts rename to tests/e2e/cross-version-wire/agent-session-newer-chat-refusal.unit.test.ts index c96b86183778..42f5856e35c1 100644 --- a/tests/e2e/cross-version-wire/agent-session-read-only.unit.test.ts +++ b/tests/e2e/cross-version-wire/agent-session-newer-chat-refusal.unit.test.ts @@ -7,26 +7,16 @@ import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION, type AgentSessionJournalIdentity } from '../../../src/shared/agent-session-journal-types' -import type { - AgentSessionHistoryPage, - AgentSessionSubscribeEvent -} from '../../../src/shared/agent-session-wire' -import { agentSessionReadOnlyNoticeParts } from '../../../src/shared/agent-session-read-only' -import { - EMPTY_STRUCTURED_AGENT_SESSION, - reduceStructuredAgentSession -} from '../../../src/shared/structured-agent-session-reducer' +import type { AgentSessionRefusalReference } from '../../../src/shared/agent-session-wire-refusals' import { closeTestJournalHostDatabase, createTrackedJournalOpener, insertTestJournalRowJson, liveTestJournalRows, - openTestJournalHostDatabase + openTestJournalHostDatabase, + SAVED_BY_NEWER_ORCA } from '../../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' -import { - readAgentSessionHistory, - readAgentSessionHydrationPage -} from '../../../src/main/native-chat/agent-session-wire/agent-session-history-page' +import { journalOpenRefusal } from '../../../src/main/native-chat/agent-session-journal/journal-open-failure' import { importReleaseCheckoutModule, materializeReleaseCheckout, @@ -34,32 +24,37 @@ import { } from './release-checkout' /** - * A host that keeps a chat read-only now says why on every whole page (`page.readOnly`), and this + * A chat a newer Orca saved now fails its load here with the newer-Orca reason, where this host used + * to serve it read-only (Rule 3: an existing read answers differently). An older client gets that + * refusal instead of pages: it must word it with what it has and never call it damage. And this * build keeps a body of a kind it does not know (or a plan subject of one), writable, instead of - * deleting from it. Both meet builds that predate them: an older client is sent the new field - * (Rule 1), and an older host opens a journal holding such a body. + * deleting from it, which an older host meets when it opens a journal holding such a body. */ const SUITE_TIMEOUT_MS = 180_000 +// The last release that does not know the newer-Orca reason: its words fall back to the code's. +const BEFORE_NEWER_REASON_REF = 'v1.4.218' // A main build that shares this one's host database and schema version, so a downgrade to it opens // the journal writable. No release tag has that database yet; move to the first one that does. const WRITABLE_BASELINE_REF = '3727100cc9dbcea6201f8a3e506676a3c4b53b18' const JOURNAL = 'src/main/native-chat/agent-session-journal' const IDENTITY: AgentSessionJournalIdentity = { - sessionId: 'session-read-only', + sessionId: 'session-newer-chat', workspaceId: 'ws-1', hostId: 'host-1', agent: 'codex', providerHandle: { kind: 'codex', threadId: 'thread-1' } } -type Reduce = (state: unknown, action: unknown, receivedAt?: number) => unknown +type ReadHistoryParts = (code: string, details?: unknown, context?: unknown) => unknown[] +type NoticeEnglish = (parts: unknown[]) => string +type IsFinal = (refusal: AgentSessionRefusalReference | undefined) => boolean let directory: string const journals = createTrackedJournalOpener() beforeAll(() => { - directory = mkdtempSync(join(tmpdir(), 'orca-read-only-xv-')) + directory = mkdtempSync(join(tmpdir(), 'orca-newer-chat-xv-')) }) afterAll(async () => { @@ -87,7 +82,7 @@ const NEWER_NESTED_LITERAL = { subject: { kind: 'diff', path: 'a.ts' } } -/** A row of a kind this build does not know: the journal opens read-only. */ +/** A row of a kind this build does not know: the journal's open is refused as a newer Orca's. */ const NEWER_ROW_KIND = { kind: 'future-mark' } /** This build's journal of two items, then a row as a newer build wrote it: an item holding @@ -132,116 +127,67 @@ function storedRows(): string[] { return rows.map((row) => row.rowJson) } -function frames(page: AgentSessionHistoryPage): AgentSessionSubscribeEvent[] { - return [ - { type: 'snapshot', sessionId: IDENTITY.sessionId, page, fence: 1 }, - { type: 'reset', sessionId: IDENTITY.sessionId, reset: 'schema_unreadable', page, fence: 1 } - ] +/** This host's refusal of a newer Orca's chat, as `history` and `subscribe` raise it. */ +async function newerChatRefusal(): Promise { + await journalWithNewerRow() + const failure = await journals.open({ identity: IDENTITY, stateDirectory: directory }).then( + () => null, + (error: unknown) => error + ) + expect(failure).toMatchObject(SAVED_BY_NEWER_ORCA) + await journals.closeAll() + return journalOpenRefusal(failure) +} + +/** How a released client words a read refusal, and whether it stops retrying on it. */ +async function releasedReader(ref: string) { + const checkout = await materializeReleaseCheckout(ref) + const notice = await importReleaseCheckoutModule( + checkout, + 'src/shared/agent-session-refusal-notice.ts' + ) + const readRefusal = await importReleaseCheckoutModule( + checkout, + 'src/shared/structured-agent-session-read-refusal.ts' + ) + const parts = releaseExport(notice, 'agentSessionReadHistoryRefusalParts') + const english = releaseExport(notice, 'agentSessionWriteNoticeEnglish') + const isFinal = releaseExport(readRefusal, 'isFinalAgentSessionReadRefusal') + // The pane's own Retry stands beside the words, as on desktop. + const words = (refusal: Pick) => + english(parts(refusal.code, refusal.details, { retryControl: true })) + return { words, isFinal } } describe('a chat a newer Orca saved, across versions', () => { it( - 'old client against new host: the read-only page reduces exactly as the same page without the field', + 'old client against new host: a release before the reason words it as an unreadable history and keeps retrying', async () => { - await journalWithNewerRow() - const journal = await journals.open({ identity: IDENTITY, stateDirectory: directory }) - expect(journal.isReadOnly).toBe(true) - const page = readAgentSessionHydrationPage(journal, 1) - // The payload under test carries the field, or this compares nothing. - expect(page.readOnly).toBe('written-by-newer-orca') - const { readOnly: _field, ...withoutField } = page - await journals.closeAll() - - const checkout = await materializeReleaseCheckout(resolveBaselineReleaseRef()) - const reducer = await importReleaseCheckoutModule( - checkout, - 'src/shared/structured-agent-session-reducer.ts' - ) - const reduce = releaseExport(reducer, 'reduceStructuredAgentSession') - const empty = releaseExport(reducer, 'EMPTY_STRUCTURED_AGENT_SESSION') - for (const [index, frame] of frames(page).entries()) { - const reference = frames(withoutField)[index] - expect(reduce(empty, { type: 'event', event: frame }, 1), frame.type).toEqual( - reduce(empty, { type: 'event', event: reference }, 1) - ) - } + const refusal = await newerChatRefusal() + const released = await releasedReader(BEFORE_NEWER_REASON_REF) + const code = { code: refusal.code } + const damage = { code: refusal.code, details: { reason: 'journalCorrupt' } } + expect(released.words(refusal)).toBe(released.words(code)) + expect(released.words(refusal)).not.toBe(released.words(damage)) + expect(released.isFinal(refusal)).toBe(false) }, SUITE_TIMEOUT_MS ) it( - 'old client against new host: it hydrates a read-only chat and scrolls back through it as through a writable one', + 'old client against new host: the newest release words it as its own, never as damage', async () => { rmSync(directory, { recursive: true, force: true }) - directory = mkdtempSync(join(tmpdir(), 'orca-read-only-xv-')) - await journalWithNewerRow() - const journal = await journals.open({ identity: IDENTITY, stateDirectory: directory }) - expect(journal.isReadOnly).toBe(true) - const request = { sessionId: IDENTITY.sessionId, limit: 1 } - const tail = readAgentSessionHistory(journal, { ...request, direction: 'tail' }) - const requestedCursor = tail.page.window.nextCursor - const older = readAgentSessionHistory(journal, { - ...request, - direction: 'before', - cursor: requestedCursor - }) - await journals.closeAll() - // Both are pages, not resets, and carry the field, or this compares nothing. - expect([tail.ok, older.ok, tail.page.readOnly, older.page.readOnly]).toEqual([ - true, - true, - 'written-by-newer-orca', - 'written-by-newer-orca' - ]) - const { readOnly: _tailField, ...tailWithout } = tail.page - const { readOnly: _olderField, ...olderWithout } = older.page - - const checkout = await materializeReleaseCheckout(resolveBaselineReleaseRef()) - const reducer = await importReleaseCheckoutModule( - checkout, - 'src/shared/structured-agent-session-reducer.ts' - ) - const reduce = releaseExport(reducer, 'reduceStructuredAgentSession') - const empty = releaseExport(reducer, 'EMPTY_STRUCTURED_AGENT_SESSION') - const scrolledBack = (first: AgentSessionHistoryPage, next: AgentSessionHistoryPage) => - reduce( - reduce(empty, { type: 'history-page', page: first }, 1), - { type: 'older-page', requestedCursor, page: next }, - 1 - ) - const state = scrolledBack(tail.page, older.page) - expect(state).toEqual(scrolledBack(tailWithout, olderWithout)) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the released reducer's state keeps its loaded items under `items`, as every release has. - const items = (state as { items: { body: unknown }[] }).items.map((item) => item.body) - expect(items).toEqual([ - { kind: 'status', text: 'before' }, - { kind: 'status', text: 'also before' } - ]) + directory = mkdtempSync(join(tmpdir(), 'orca-newer-chat-xv-')) + const refusal = await newerChatRefusal() + const released = await releasedReader(resolveBaselineReleaseRef()) + const damage = { code: refusal.code, details: { reason: 'journalCorrupt' } } + expect(released.words(refusal)).not.toBe('') + expect(released.words(refusal)).not.toBe(released.words(damage)) }, SUITE_TIMEOUT_MS ) - it('new client against old host: a page with no field says nothing', () => { - const page: AgentSessionHistoryPage = { - sessionId: IDENTITY.sessionId, - epoch: 'epoch-1', - direction: 'tail', - items: [], - removedItemIds: [], - submissions: [], - window: { oldest: null, newest: null, nextCursor: { epoch: 'epoch-1', sequence: 0 } }, - hasOlder: false, - hasNewer: false - } - for (const frame of frames(page)) { - const state = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, { - type: 'event', - event: frame - }) - expect(agentSessionReadOnlyNoticeParts(state.readOnly), frame.type).toBeNull() - } - }) - // Why a new body kind ships its reader first, or rides a bumped `v`: this build keeps one and // stays writable, but a build from before deletes the journal from it. Move the pinned build to // the first release with this rule, and the older build keeps the row too. @@ -252,7 +198,7 @@ describe('a chat a newer Orca saved, across versions', () => { "this build keeps a newer build's %s and stays writable; a build before it deletes it", async (_value, body) => { rmSync(directory, { recursive: true, force: true }) - directory = mkdtempSync(join(tmpdir(), 'orca-read-only-xv-')) + directory = mkdtempSync(join(tmpdir(), 'orca-newer-chat-xv-')) const { rows, newer } = await journalWithNewerRow({ kind: 'item', itemId: 'codex:thread-1:turn-1:1', @@ -260,7 +206,7 @@ describe('a chat a newer Orca saved, across versions', () => { body }) const reopened = await journals.open({ identity: IDENTITY, stateDirectory: directory }) - expect(reopened.isReadOnly).toBe(false) + expect(reopened.snapshot().items).toHaveLength(3) await journals.closeAll() expect(storedRows()).toEqual(rows) diff --git a/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts index 8c8784495478..3fc06f9d2dc4 100644 --- a/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts +++ b/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts @@ -15,7 +15,8 @@ import { createTrackedJournalOpener, insertTestJournalRowJson, liveTestJournalRows, - openTestJournalHostDatabase + openTestJournalHostDatabase, + SAVED_BY_NEWER_ORCA } from '../../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' import type { JournalRow } from '../../../src/main/native-chat/agent-session-journal/journal-row-schema' import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout' @@ -217,7 +218,6 @@ test("an older build opens this build's journal writable and appends to it; the // Upgraded again: the older build's row folds, and the person's Stop still pauses the queue. const upgraded = await journals.open({ identity: IDENTITY, stateDirectory: directory }) - expect(upgraded.isReadOnly).toBe(false) expect(upgraded.cursor().sequence).toBe(wrote.cursor.sequence + 1) expect(itemIds(upgraded)).toEqual([...wrote.items, 'codex:thread-1:turn-1:2']) expect(upgraded.queuedMessages.pauses('host-a').map((pause) => pause.reason)).toEqual([ @@ -230,11 +230,12 @@ test("an older build opens this build's journal writable and appends to it; the }, 120_000) // Why a Stop's event cannot have a row kind of its own yet: this build keeps a kind it does not know -// and goes read-only, but a build from before that deletes the journal from it. So a Stop kind ships +// and refuses the load as a newer Orca's chat, but a build from before that deletes the journal from +// it. So a Stop kind ships // its reader first and is written once no supported build lacks that reader, or is written at a // bumped `v`. Move the baseline to the first release with this rule, and the older build keeps the // row too. -test("this build keeps a newer build's row kind and goes read-only; a build before it deletes it", async () => { +test("this build keeps a newer build's row kind and refuses the load; a build before it deletes it", async () => { const directory = mkdtempSync(join(tmpdir(), 'orca-newer-kind-downgrade-')) const journals = createTrackedJournalOpener() const newerKinds = () => storedRows(directory).filter((row) => row.includes('"future-mark"')) @@ -262,11 +263,9 @@ test("this build keeps a newer build's row kind and goes read-only; a build befo closeTestJournalHostDatabase(directory) const rowsBefore = storedRows(directory) - const reopened = await journals.open({ identity: IDENTITY, stateDirectory: directory }) - expect(reopened.isReadOnly).toBe(true) await expect( - reopened.appendItem(item(1), { kind: 'status', text: 'after' }, scope) - ).rejects.toMatchObject({ code: 'journal_read_only' }) + journals.open({ identity: IDENTITY, stateDirectory: directory }) + ).rejects.toMatchObject(SAVED_BY_NEWER_ORCA) await journals.closeAll() expect(storedRows(directory)).toEqual(rowsBefore) expect(newerKinds()).toEqual([newer]) diff --git a/tests/e2e/structured-agent-session-read-only-reconnect.unit.test.ts b/tests/e2e/structured-agent-session-read-only-reconnect.unit.test.ts deleted file mode 100644 index 72e4649aab0c..000000000000 --- a/tests/e2e/structured-agent-session-read-only-reconnect.unit.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import type { - AgentSessionHistoryRequest, - AgentSessionSubscribeEvent -} from '../../src/shared/agent-session-wire' -import type { AgentJournalCursor } from '../../src/shared/agent-session-journal-types' -import Database from '../../src/main/sqlite/sync-database' -import { - closeTestJournalHostDatabase, - createTrackedJournalOpener -} from '../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' -import { JOURNAL_DB_SCHEMA_VERSION } from '../../src/main/native-chat/agent-session-journal/journal-database-schema' -import type { AgentSessionJournal } from '../../src/main/native-chat/agent-session-journal/journal-store' -import { readAgentSessionHistory } from '../../src/main/native-chat/agent-session-wire/agent-session-history-page' -import { AgentSessionSubscribers } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-subscribers' - -const mocks = vi.hoisted(() => ({ call: vi.fn(), subscribe: vi.fn() })) -vi.mock('@/runtime/structured-agent-session-client', () => ({ - callStructuredAgentSession: mocks.call, - subscribeStructuredAgentSession: mocks.subscribe -})) - -import { - getStructuredAgentSessionReadOwner, - resetStructuredAgentSessionReadOwnersForTests -} from '../../src/renderer/src/components/native-chat/structured-agent-session-read-owner' - -const SESSION = 'read-only-reconnect' -const target = { kind: 'local' } as const -const journals = createTrackedJournalOpener() -let root: string - -beforeEach(async () => { - vi.resetAllMocks() - root = await mkdtemp(join(tmpdir(), 'orca-read-only-reconnect-')) -}) -afterEach(async () => { - resetStructuredAgentSessionReadOwnersForTests() - await journals.closeAll() - await rm(root, { recursive: true, force: true }) -}) - -function open(): Promise { - return journals.open({ - identity: { - sessionId: SESSION, - workspaceId: 'folder-workspace', - hostId: 'local', - agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } - }, - stateDirectory: root - }) -} - -/** Reopens the host's journal on a database stamped `version`, as a host restarting would. */ -async function reopenAt(version: number): Promise { - await journals.closeAll() - closeTestJournalHostDatabase(root) - const db = new Database(join(root, 'agent-session-journal.db')) - db.pragma(`user_version = ${version}`) - db.close() - return open() -} - -/** Serves the client from whichever journal the host holds now. */ -function serve(host: { journal: AgentSessionJournal }): void { - mocks.call.mockImplementation((_target, _method, request: AgentSessionHistoryRequest) => - Promise.resolve(structuredClone(readAgentSessionHistory(host.journal, request))) - ) - mocks.subscribe.mockImplementation( - ( - _target, - request: { cursor?: AgentJournalCursor }, - onEvent: (event: AgentSessionSubscribeEvent) => void - ) => - Promise.resolve({ - unsubscribe: new AgentSessionSubscribers().open({ - id: 'pane', - sessionId: SESSION, - journal: host.journal, - fence: 1, - cursor: request.cursor, - emit: (event) => onEvent(structuredClone(event)) - }) - }) - ) -} - -it('reconnects a read-only chat for a whole page, so an updated host unlocks it', async () => { - const first = await open() - await first.appendItem( - { provider: 'orca', clientMessageId: 'hello' }, - { kind: 'status', text: 'hello' }, - { fence: 1 } - ) - const host = { journal: await reopenAt(JOURNAL_DB_SCHEMA_VERSION + 1) } - expect(host.journal.isReadOnly).toBe(true) - serve(host) - const owner = getStructuredAgentSessionReadOwner(SESSION, target) - const unlisten = owner.subscribe(() => {}) - const deactivate = owner.activate() - await vi.waitFor(() => expect(owner.getSnapshot().state.readOnly).toBe('written-by-newer-orca')) - await vi.waitFor(() => expect(mocks.subscribe).toHaveBeenCalledTimes(1)) - deactivate() - - host.journal = await reopenAt(JOURNAL_DB_SCHEMA_VERSION) - expect(host.journal.isReadOnly).toBe(false) - const stop = owner.activate() - await vi.waitFor(() => expect(mocks.subscribe).toHaveBeenCalledTimes(2)) - expect(mocks.subscribe.mock.calls[1]?.[1]).toEqual({ sessionId: SESSION }) - await vi.waitFor(() => expect(owner.getSnapshot().state).not.toHaveProperty('readOnly')) - stop() - unlisten() -}) From 096953f31557483b3c1ecc67e3c7a4e02f8712cf Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:52:27 -0700 Subject: [PATCH 41/52] test(native-chat): a newer Orca's records open no chat, and a status fake carries its submissions --- ...-session-startup-reconcile-failure.test.ts | 4 ++-- ...ructured-agent-session-rpc.test-fixture.ts | 2 +- .../methods/structured-chat-tab-table.test.ts | 4 ++-- ...-agent-session-startup-tab-restore.test.ts | 20 +++++++++++++------ 4 files changed, 19 insertions(+), 11 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts index b71022b92c70..caabf348133c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts @@ -189,14 +189,14 @@ it('refuses a send over records a newer Orca wrote with the update words', async }) }) -it('restores a chat for reading from records a newer Orca wrote', async () => { +it('opens no chat from records a newer Orca wrote, and writes nothing trying', async () => { const { host, stateDirectory, leaseReconcileLogged } = await relaunch(true) const path = journalDatabasePath(stateDirectory) const bytes = await readFile(path) await expect(host.restoreReadableSessions([SESSION])).resolves.toBeUndefined() - expect(host.hasSession(SESSION)).toBe(true) + expect(host.hasSession(SESSION)).toBe(false) expect(leaseReconcileLogged).not.toHaveBeenCalled() expect(await readFile(path)).toEqual(bytes) }) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts index f896297e15e2..455998a22a71 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts @@ -104,7 +104,7 @@ function statusFeed(): StructuredAgentSessionStatusFeed { journal: { cursor: () => ({ epoch: 'epoch-status', sequence: 2 }), lastActivityAt: () => 2, - snapshot: () => ({ items: STATUS_ITEMS }) + snapshot: () => ({ items: STATUS_ITEMS, submissions: [] }) } as unknown as AgentSessionJournal, params: { location: { diff --git a/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts b/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts index c97672269eb8..f35e010d0015 100644 --- a/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts +++ b/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts @@ -525,7 +525,7 @@ describe('a create that reserves its tab', () => { }) describe('a chat tab over records a newer Orca wrote', () => { - it('opens a closed chat from history for reading', async () => { + it("opens a closed chat's tab from history, whose chat does not load", async () => { await createChat(HOST_TEST_SESSION) await host.close(HOST_TEST_SESSION, 'user-close') await host.setSessionTabVisibility(HOST_TEST_SESSION, false) @@ -537,7 +537,7 @@ describe('a chat tab over records a newer Orca wrote', () => { expect(await call('agentSession.reveal', { sessionId: HOST_TEST_SESSION })).toMatchObject({ ok: true, - result: { ok: true, readable: true } + result: { ok: true, readable: false } }) expect((await snapshot()).activeTabId).toBe(`agent-session:${HOST_TEST_SESSION}`) diff --git a/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts b/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts index ac6df4c4b2b3..e0bfa5bbdd3f 100644 --- a/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts +++ b/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts @@ -14,7 +14,10 @@ import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' import { journalDatabasePath } from '../native-chat/agent-session-journal/journal-host-database' -import { closeTestJournalHostDatabases } from '../native-chat/agent-session-journal/journal-host-database-test-support' +import { + closeTestJournalHostDatabases, + SAVED_BY_NEWER_ORCA +} from '../native-chat/agent-session-journal/journal-host-database-test-support' import { openAgentSessionJournal } from '../native-chat/agent-session-journal/journal-store-factory' import { journalIdentityFor } from '../native-chat/agent-session-wire/structured-agent-session-attach' import { attachParamsForRecord } from '../native-chat/agent-session-wire/structured-agent-session-conversation-open' @@ -269,7 +272,7 @@ describe('restoring the chat tabs open at quit', () => { { store: 'records a newer Orca wrote', newer: true, writesFail: false }, { store: 'a store whose writes keep failing', newer: false, writesFail: true } ])( - 'lists and reads every chat from $store, and writes nothing', + 'lists every chat from $store and writes nothing; it reads them unless a newer Orca saved them', async ({ newer, writesFail }) => { const records = [ chatRecord(CHAT_A), @@ -298,12 +301,17 @@ describe('restoring the chat tabs open at quit', () => { `agent-session:${CHAT_B}` ]) expect(published()[0]).toMatchObject({ replacesSessionId: CLEARED }) - await expectHistory(CHAT_A) - await expectHistory(CHAT_B) - // A newer Orca's leases are adjudicated in memory, so nothing fails there. + // A newer Orca's leases are adjudicated in memory, so nothing fails there; its chats do not open. if (newer) { - expect(log.entries).toEqual([]) + for (const sessionId of [CHAT_A, CHAT_B]) { + await expect( + getStructuredAgentSessionHost()!.journalSnapshot(sessionId) + ).rejects.toMatchObject(SAVED_BY_NEWER_ORCA) + } + expect(log.entries.map((entry) => entry.fields.scope)).not.toContain('lease-reconcile') } else { + await expectHistory(CHAT_A) + await expectHistory(CHAT_B) expect(log.entries).toEqual([ expect.objectContaining({ fields: { From 6ab0c2e9c042c51fe44472a1e02893c2b4ea71a4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:55:53 -0700 Subject: [PATCH 42/52] test(native-chat): the status fake's cast says what the feed reads --- .../rpc/methods/structured-agent-session-rpc.test-fixture.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts index 455998a22a71..227654cb0221 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts @@ -101,6 +101,7 @@ function statusFeed(): StructuredAgentSessionStatusFeed { [ STATUS_SESSION, { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the status feed reads only cursor(), lastActivityAt() and snapshot() of a journal. journal: { cursor: () => ({ epoch: 'epoch-status', sequence: 2 }), lastActivityAt: () => 2, From 26429a595bceba5df50d15211771bec6dd5db98c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 19:13:48 -0700 Subject: [PATCH 43/52] fix(native-chat): a chat's read that failed for good takes the whole pane, over a loaded transcript too --- .../src/session/MobileNativeChatView.test.ts | 27 +- mobile/src/session/MobileNativeChatView.tsx | 89 +++--- .../mobile-structured-agent-session-rpc.ts | 6 +- ...ucturedSession.final-read-failure.test.tsx | 115 ++++++++ .../NativeChatStructuredSession.tsx | 265 ++++++++---------- .../native-chat/native-chat-view-state.ts | 5 +- ...ructured-agent-session-delivery-notices.ts | 48 ++++ 7 files changed, 360 insertions(+), 195 deletions(-) create mode 100644 src/renderer/src/components/native-chat/NativeChatStructuredSession.final-read-failure.test.tsx create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.ts diff --git a/mobile/src/session/MobileNativeChatView.test.ts b/mobile/src/session/MobileNativeChatView.test.ts index 37c6d226d292..c8a8bc8aa794 100644 --- a/mobile/src/session/MobileNativeChatView.test.ts +++ b/mobile/src/session/MobileNativeChatView.test.ts @@ -217,13 +217,32 @@ describe('MobileNativeChatView', () => { }) } - // A read that failed for good says why in the error state, once: there is nothing to send into. - it('offers no composer under a read that failed for good, and one under any other', async () => { + // A read no retry gets past takes the whole pane, even over a transcript already on screen: its + // words alone, and nothing that could only be refused again. + it('leaves only the words of a read that failed for good, over a loaded transcript', async () => { + const loaded = [assistantTurn('m1', 'earlier reply')] + const words = 'This chat was saved by a newer Orca. Update Orca to open it.' + const shown = (text: string) => + renderer!.root.findAll((node) => node.type === 'Text' && node.props.children === text) const composers = () => renderer!.root.findAll((node) => node.type === 'Composer') - await render({ status: 'error', error: 'Unable to load this chat.', readFailedFinally: true }) + const lists = () => renderer!.root.findAll((node) => node.type === 'FlatList') + const failure = { + messages: loaded, + folded: loaded, + status: 'error' as const, + error: words, + canStop: true, + permission: { title: 'Approve?', options: [{ label: 'Allow', send: '1' }] } + } + await render({ ...failure, readFailedFinally: true }) + expect(shown(words)).toHaveLength(1) expect(composers()).toHaveLength(0) + expect(lists()).toHaveLength(0) + expect(renderer!.root.findAllByProps({ accessibilityLabel: 'Stop the agent' })).toHaveLength(0) - await update({ status: 'error', error: "Orca couldn't open this chat's history right now." }) + // A failure that can clear keeps the transcript and the composer. + await update({ ...failure, error: "Orca couldn't open this chat's history right now." }) + expect(listIds()).toEqual(['m1']) expect(composers()).toHaveLength(1) }) diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index cabb30889601..8e729e5e46b3 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -53,7 +53,7 @@ type Props = MobileQueuedSlotProps & { folded: NativeChatMessage[] status: MobileNativeChatStatus error?: string - /** The read failed for good: the error state says why, and no composer is offered under it. */ + /** The read failed for good (damage, a newer Orca's chat): its error takes the whole pane. */ readFailedFinally?: boolean /** Resolved agent for this chat; names the empty-state copy (desktop parity). */ agent?: string | null @@ -310,6 +310,18 @@ export function MobileNativeChatView({ const showLoading = status === 'loading' && messages.length === 0 const lockReason = useSettledMobileNativeChatInputLock(inputLockReason) + const emptyStateView = emptyState ? ( + + {emptyState.title} + {emptyState.subtitle} + + ) : null + + // Whatever was already on screen: nothing here can act on a chat that cannot load, and its words + // say why once, as a fresh open's do. + if (readFailedFinally && emptyStateView) { + return {emptyStateView} + } return ( @@ -357,14 +369,7 @@ export function MobileNativeChatView({ turns.waitingRows, renderItem )} - ListEmptyComponent={ - emptyState ? ( - - {emptyState.title} - {emptyState.subtitle} - - ) : null - } + ListEmptyComponent={emptyStateView} /> {/* Jump-to-latest control. */} @@ -430,41 +435,37 @@ export function MobileNativeChatView({ {sendErrorMessage} ) : null} - {readFailedFinally ? null : ( - - )} + ) } diff --git a/mobile/src/session/mobile-structured-agent-session-rpc.ts b/mobile/src/session/mobile-structured-agent-session-rpc.ts index c2506ba9ed8d..b5d7959eb460 100644 --- a/mobile/src/session/mobile-structured-agent-session-rpc.ts +++ b/mobile/src/session/mobile-structured-agent-session-rpc.ts @@ -55,9 +55,6 @@ class AgentSessionRpcResponseError extends Error { } } -/** A failed read of a chat's history as the pane shows it, from a thrown error or a stream's error - * frame (`{ message, error }`): a thrown refusal's message is its bare code, so its words come - * from the refusal in the error's data. */ /** The refusal a failed read met, from a thrown error or a stream's error frame. */ export function agentSessionReadFailureRefusal(failure: unknown) { return readAgentSessionErrorRefusal( @@ -65,6 +62,9 @@ export function agentSessionReadFailureRefusal(failure: unknown) { ) } +/** A failed read of a chat's history as the pane shows it, from a thrown error or a stream's error + * frame (`{ message, error }`): a thrown refusal's message is its bare code, so its words come + * from the refusal in the error's data. */ export function agentSessionReadFailureText(failure: unknown): string { const refusal = agentSessionReadFailureRefusal(failure) if (refusal) { diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.final-read-failure.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.final-read-failure.test.tsx new file mode 100644 index 000000000000..4a045ba39caf --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.final-read-failure.test.tsx @@ -0,0 +1,115 @@ +// @vitest-environment happy-dom + +// A read no retry gets past (damage, a newer Orca's chat) takes the whole pane, even after the +// transcript loaded: its words alone, and nothing that could only be refused again. + +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import { TooltipProvider } from '@/components/ui/tooltip' +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' + +const { mocks, moduleFactories, resetStructuredSessionMocks } = await vi.hoisted(async () => + (await import('./NativeChatStructuredSession.test-harness')).createStructuredSessionMocks() +) +vi.mock('@/lib/structured-agent-session-launch', () => + moduleFactories.structuredAgentSessionLaunch() +) +vi.mock('@/runtime/structured-agent-session-client', () => + moduleFactories.structuredAgentSessionClient() +) +vi.mock('./use-structured-agent-session', () => moduleFactories.useStructuredAgentSession()) +vi.mock('./use-native-chat-font-scale', () => moduleFactories.useNativeChatFontScale()) +vi.mock('./use-native-chat-file-link-context', () => moduleFactories.useNativeChatFileLinkContext()) +vi.mock('./use-native-chat-file-link-click', () => moduleFactories.useNativeChatFileLinkClick()) +vi.mock('./NativeChatMessageList', () => moduleFactories.nativeChatMessageList()) +vi.mock('./NativeChatComposer', () => moduleFactories.nativeChatComposer()) +vi.mock('./NativeChatApprovalCard', () => moduleFactories.nativeChatApprovalCard()) +vi.mock('./NativeChatQuestionCard', () => moduleFactories.nativeChatQuestionCard()) + +import { NativeChatStructuredSession } from './NativeChatStructuredSession' + +afterEach(() => { + cleanup() + localStorage.clear() + resetStructuredSessionMocks() +}) + +const APPROVAL: AgentJournalRenderItem = JSON.parse( + JSON.stringify({ + itemId: 'approval-item', + revision: 3, + sequence: 1, + observedAt: 1, + body: { + kind: 'approval', + title: 'Run the plan?', + detail: null, + subject: { kind: 'plan', text: 'x' }, + options: [{ id: 'allow', label: 'Approve' }], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + } + }) +) + +/** A chat with its transcript on screen, a pending approval, a goal, a queued card and a running + * background task, whose read then fails with `reason`. */ +function renderLoadedChatThenFailedRead(reason: string): void { + mocks.status = 'error' + mocks.readRefusal = { code: 'agent_session_journal_unreadable', details: { reason } } + mocks.turnId = 'turn-1' + mocks.promptItems = [APPROVAL] + mocks.threadGoal = { + goal: { + objective: 'Ship', + status: 'active', + tokenBudget: null, + tokensUsed: 0, + timeUsedSeconds: 1, + createdAt: 1, + updatedAt: 1 + }, + pending: false, + change: vi.fn() + } + mocks.queuedCards = [ + { messageId: 'draft-1', position: 1, text: 'queued text', state: 'waiting', hold: 'turn' } + ] + mocks.monitoringBackgroundTasks = true + mocks.backgroundTasks = [{ id: 'task-agent', kind: 'agent' }] + render( + + + + ) +} + +it.each([ + ['journalCorrupt', 'Unable to load this chat.'], + ['journalWrittenByNewerOrca', 'This chat was saved by a newer Orca. Update Orca to open it.'] +])('a %s read after the transcript loaded leaves only its words', (reason, words) => { + renderLoadedChatThenFailedRead(reason) + + expect(screen.getAllByText(words)).toHaveLength(1) + expect(screen.queryByTestId('message-list')).toBeNull() + expect(mocks.composerProps).toBeNull() + expect(mocks.approvalCardProps).toBeNull() + expect(document.querySelector('[data-native-chat-thread-goal]')).toBeNull() + expect(document.querySelector('[data-queued-message-id="draft-1"]')).toBeNull() + expect(document.querySelector('[data-native-chat-background-tasks]')).toBeNull() +}) + +it('a failed read that can clear keeps the transcript and every control', () => { + renderLoadedChatThenFailedRead('journalUnavailable') + + expect(screen.getByTestId('message-list')).toBeTruthy() + expect(mocks.approvalCardProps).not.toBeNull() + expect(document.querySelector('[data-queued-message-id="draft-1"]')).not.toBeNull() + expect(document.querySelector('[data-native-chat-background-tasks]')).not.toBeNull() +}) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 2829d6b8a29b..19149b69aa49 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useMemo, useRef, useState } from 'react' import { agentSessionPromptQuestions } from '../../../../shared/agent-session-question-answer' import { dispatchStructuredAgentSessionComposerCommand } from '../../../../shared/structured-agent-session-composer' import { structuredAgentSessionPaneKey } from '../../../../shared/structured-agent-session-projection' @@ -28,13 +28,9 @@ import { useAppStore } from '../../store' import { structuredAgentLabel } from '@/lib/structured-agent-session-launch-label' import { NativeChatThreadGoalBanner } from './NativeChatThreadGoalBanner' import { structuredAgentSessionReadFailureNotice } from './structured-agent-session-read-failure-notice' -import { useStructuredAgentSessionStartFailureFacts } from './use-structured-agent-session-start-failure-facts' -import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices' -import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' +import { useStructuredAgentSessionDeliveryNotices } from './use-structured-agent-session-delivery-notices' import { pendingPromptsAllUnanswerableHere } from '../../../../shared/agent-session-approval-subject' -const NO_SUBMISSIONS: readonly AgentJournalSubmission[] = [] - export function NativeChatStructuredSession( props: Omit ): React.JSX.Element { @@ -114,46 +110,23 @@ export function NativeChatStructuredSession( }), [controller, props.agent, props.sessionId] ) - // Read at click time, so the notices stay put while the outbox's Retry is rebuilt each render. - const retryRef = useRef(controller.retry) - useEffect(() => { - retryRef.current = controller.retry - }) - const retryDelivery = useCallback((clientMessageId: string) => { - retryRef.current(clientMessageId) - }, []) const agentLabel = structuredAgentLabel(props.agent === 'codex' ? 'codex' : 'claude') - // Only a rejected message reads the journal's rows, so a new batch of them re-renders no row else. - const hasRejected = controller.outbox.some((entry) => entry.state === 'rejected') - const rejectionRows = hasRejected ? controller.submissions : NO_SUBMISSIONS - const startFailures = useStructuredAgentSessionStartFailureFacts( - controller.journalItems, - hasRejected - ) - const deliveryNotices = useMemo( - () => - structuredAgentSessionDeliveryNotices( - controller.outbox, - agentLabel, - retryDelivery, - rejectionRows, - startFailures, - controller.failedHere - ), - [ - controller.outbox, - agentLabel, - retryDelivery, - rejectionRows, - startFailures, - controller.failedHere - ] - ) - const viewState = selectNativeChatViewState(session, { readRetries: true }) + const deliveryNotices = useStructuredAgentSessionDeliveryNotices({ + outbox: controller.outbox, + retry: controller.retry, + submissions: controller.submissions, + journalItems: controller.journalItems, + failedHere: controller.failedHere, + agentLabel + }) const readFailure = controller.status === 'error' ? structuredAgentSessionReadFailureNotice(controller.readRefusal) : null + // A read no retry gets past (damage, a newer Orca's chat) takes the whole pane, whatever was + // already on screen: nothing in it can act, and its words say why once. + const readFailedFinally = readFailure?.final === true + const viewState = selectNativeChatViewState(session, { readRetries: !readFailedFinally }) const fontScale = useNativeChatFontScale(viewState.kind === 'ready') const imageRuntimeContext = useNativeChatImageRuntimeContext(props.tabId) const { onLinkClick, linkActionRequest, closeLinkActions } = useNativeChatLinkActions( @@ -163,9 +136,9 @@ export function NativeChatStructuredSession( ) const prompt = controller.prompts[0] ?? null // Prompts this build cannot answer leave the composer open: a send starts a turn, whose card - // cancel then works. A chat that cannot load says so once, in the pane, and offers no composer. + // cancel then works. const promptsUnanswerable = pendingPromptsAllUnanswerableHere(controller.prompts) - const composerShown = (prompt === null || promptsUnanswerable) && !readFailure?.final + const composerShown = (prompt === null || promptsUnanswerable) && !readFailedFinally const approvalBody = prompt?.body.kind === 'approval' ? prompt.body : null const approval = approvalBody ? { @@ -305,106 +278,114 @@ export function NativeChatStructuredSession( /> )}
- - {/* Host-held drafts, never transcript rows. Above the status area, so running shells and agents sit next to the composer. */} - { - composerRef.current?.focus() - }} - /> - - {!prompt && controller.threadGoal?.goal ? ( - void controller.threadGoal?.change(change)} - /> - ) : null} - {/* Prompt cards take the composer's slot, below the background-task dock. */} - {prompt && approval ? ( - void controller.respond(prompt, { kind: 'option', optionId })} - onCancel={cancelPrompt} - shouldFocus={!promptsUnanswerable && props.isVisible && props.isFocusedGroup} - onLinkClick={onLinkClick} - allowFileUriLinks={onLinkClick !== undefined} - /> - ) : null} - {prompt && questionBody ? ( - ({ - question: question.question, - ...(question.header ? { header: question.header } : {}), - multiSelect: question.multiSelect, - options: question.options.map((option) => ({ - label: option.label, - ...(option.description ? { description: option.description } : {}) - })) - })) - }} - allowOther={questions.map((question) => Boolean(question.freeTextQuestionId))} - onAnswer={(answers) => { - const chosen = questions.map((question, questionIndex) => { - const answer = answers[questionIndex] - const other = answer?.other?.trim() - const optionIds = (answer?.indices ?? []).flatMap((optionIndex) => { - const optionId = question.options[optionIndex]?.id - return optionId ? [optionId] : [] - }) - return { questionId: question.id, optionIds, ...(other ? { other } : {}) } - }) - if (chosen.every((answer) => answer.optionIds.length > 0 || answer.other)) { - void controller.respond(prompt, { kind: 'answers', answers: chosen }) + {readFailedFinally ? null : ( + <> + + {/* Host-held drafts, never transcript rows. Above the status area, so running shells and agents sit next to the composer. */} + { + composerRef.current?.focus() + }} + /> + - ) : null} - {composerShown ? ( - void controller.stop()} - steerQueued={controller.queuedMessages.steerNewest} - structuredTransport={structuredTransport} - launchSeed={{ ...launchDraftSignal, ownsTabWideLaunchDraft: true }} - /> - ) : null} + reconnecting={viewState.kind !== 'error' && readFailure !== null && !readFailure.named} + composerError={composerError} + isVisible={props.isVisible} + backgroundTasks={controller.backgroundTasks} + stopBackgroundTask={controller.stopBackgroundTask} + /> + {!prompt && controller.threadGoal?.goal ? ( + void controller.threadGoal?.change(change)} + /> + ) : null} + {/* Prompt cards take the composer's slot, below the background-task dock. */} + {prompt && approval ? ( + void controller.respond(prompt, { kind: 'option', optionId })} + onCancel={cancelPrompt} + shouldFocus={!promptsUnanswerable && props.isVisible && props.isFocusedGroup} + onLinkClick={onLinkClick} + allowFileUriLinks={onLinkClick !== undefined} + /> + ) : null} + {prompt && questionBody ? ( + ({ + question: question.question, + ...(question.header ? { header: question.header } : {}), + multiSelect: question.multiSelect, + options: question.options.map((option) => ({ + label: option.label, + ...(option.description ? { description: option.description } : {}) + })) + })) + }} + allowOther={questions.map((question) => Boolean(question.freeTextQuestionId))} + onAnswer={(answers) => { + const chosen = questions.map((question, questionIndex) => { + const answer = answers[questionIndex] + const other = answer?.other?.trim() + const optionIds = (answer?.indices ?? []).flatMap((optionIndex) => { + const optionId = question.options[optionIndex]?.id + return optionId ? [optionId] : [] + }) + return { questionId: question.id, optionIds, ...(other ? { other } : {}) } + }) + if (chosen.every((answer) => answer.optionIds.length > 0 || answer.other)) { + void controller.respond(prompt, { kind: 'answers', answers: chosen }) + } + }} + onCancel={cancelPrompt} + /> + ) : null} + {composerShown ? ( + void controller.stop()} + steerQueued={controller.queuedMessages.steerNewest} + structuredTransport={structuredTransport} + launchSeed={{ ...launchDraftSignal, ownsTabWideLaunchDraft: true }} + /> + ) : null} + + )} {paneCommands.menu} diff --git a/src/renderer/src/components/native-chat/native-chat-view-state.ts b/src/renderer/src/components/native-chat/native-chat-view-state.ts index 13a7e8a5afe6..ce01a7ca422b 100644 --- a/src/renderer/src/components/native-chat/native-chat-view-state.ts +++ b/src/renderer/src/components/native-chat/native-chat-view-state.ts @@ -19,8 +19,9 @@ export type NativeChatViewState = * Decide which surface to render. Any renderable message wins over loading/empty so optimistic * first sends never get replaced while transcript discovery catches up. Where the read retries on * its own (the structured chat), messages win over an error too: a read that fails after the - * transcript loaded keeps it, and the error reaches the composer's error line. The terminal-backed - * read does not retry, and its only messages on error are local echoes, so its error takes the pane. + * transcript loaded keeps it, and the error reaches the composer's error line. A read that does not + * retry takes the pane: the terminal-backed one, whose only messages on error are local echoes, and + * a structured read that failed for good. */ export function selectNativeChatViewState( session: NativeChatSession, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.ts new file mode 100644 index 000000000000..729463df9504 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.ts @@ -0,0 +1,48 @@ +// The structured chat's failed and unconfirmed sends, as the notices their transcript bubbles show. + +import { useCallback, useEffect, useMemo, useRef } from 'react' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' +import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' +import type { NativeChatDeliveryNotice } from './NativeChatMessageRow' +import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices' +import { useStructuredAgentSessionStartFailureFacts } from './use-structured-agent-session-start-failure-facts' + +const NO_SUBMISSIONS: readonly AgentJournalSubmission[] = [] + +export function useStructuredAgentSessionDeliveryNotices(args: { + outbox: readonly StructuredAgentSessionOutboxEntry[] + retry: (clientMessageId: string) => void + submissions: readonly AgentJournalSubmission[] + journalItems: readonly AgentJournalRenderItem[] + failedHere: ReadonlySet + agentLabel: string +}): ReadonlyMap { + const { agentLabel, failedHere, outbox } = args + // Read at click time, so the notices stay put while the outbox's Retry is rebuilt each render. + const retryRef = useRef(args.retry) + useEffect(() => { + retryRef.current = args.retry + }) + const retryDelivery = useCallback((clientMessageId: string) => { + retryRef.current(clientMessageId) + }, []) + // Only a rejected message reads the journal's rows, so a new batch of them re-renders no row else. + const hasRejected = outbox.some((entry) => entry.state === 'rejected') + const rejectionRows = hasRejected ? args.submissions : NO_SUBMISSIONS + const startFailures = useStructuredAgentSessionStartFailureFacts(args.journalItems, hasRejected) + return useMemo( + () => + structuredAgentSessionDeliveryNotices( + outbox, + agentLabel, + retryDelivery, + rejectionRows, + startFailures, + failedHere + ), + [outbox, agentLabel, retryDelivery, rejectionRows, startFailures, failedHere] + ) +} From 2440311a3804cd3b0089b8db7979b76c5c2d8e51 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 19:16:31 -0700 Subject: [PATCH 44/52] test(native-chat): the final-read-failure test names its reasons --- .../NativeChatStructuredSession.final-read-failure.test.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.final-read-failure.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.final-read-failure.test.tsx index 4a045ba39caf..f9f9dc60f7ce 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.final-read-failure.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.final-read-failure.test.tsx @@ -53,7 +53,9 @@ const APPROVAL: AgentJournalRenderItem = JSON.parse( /** A chat with its transcript on screen, a pending approval, a goal, a queued card and a running * background task, whose read then fails with `reason`. */ -function renderLoadedChatThenFailedRead(reason: string): void { +function renderLoadedChatThenFailedRead( + reason: 'journalCorrupt' | 'journalUnavailable' | 'journalWrittenByNewerOrca' +): void { mocks.status = 'error' mocks.readRefusal = { code: 'agent_session_journal_unreadable', details: { reason } } mocks.turnId = 'turn-1' @@ -93,7 +95,7 @@ function renderLoadedChatThenFailedRead(reason: string): void { it.each([ ['journalCorrupt', 'Unable to load this chat.'], ['journalWrittenByNewerOrca', 'This chat was saved by a newer Orca. Update Orca to open it.'] -])('a %s read after the transcript loaded leaves only its words', (reason, words) => { +] as const)('a %s read after the transcript loaded leaves only its words', (reason, words) => { renderLoadedChatThenFailedRead(reason) expect(screen.getAllByText(words)).toHaveLength(1) From fcf216cbe3f190b1051a7fffb131ab428862a833 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 19:21:40 -0700 Subject: [PATCH 45/52] fix(native-chat): a newer Orca's chat keeps its restart offer, and its refused load is logged once --- .../journal-open-failure.test.ts | 27 +++++++++ .../journal-open-failure.ts | 44 +++++++++++---- ...ed-agent-session-restart-failure-ledger.ts | 27 ++++++--- ...t-session-restart-offer-newer-orca.test.ts | 55 ++++++++++++++----- ...tured-agent-session-restart-resume-host.ts | 30 ++++++---- ...ctured-agent-session-restart-resume-set.ts | 9 ++- ...tured-agent-session-restart-resume.test.ts | 15 ++++- ...uctured-agent-session-unopened-tab.test.ts | 13 ++++- 8 files changed, 170 insertions(+), 50 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/journal-open-failure.test.ts b/src/main/native-chat/agent-session-journal/journal-open-failure.test.ts index 787b1aa6491b..5672d6e9db07 100644 --- a/src/main/native-chat/agent-session-journal/journal-open-failure.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-open-failure.test.ts @@ -169,6 +169,33 @@ describe('createJournalOpenReadRefusals', () => { 'open-for-read' ]) }) + + // A load refused on its rows arrives already classified, and is logged once per session too. + it("logs a load refused as a newer Orca's chat, or as damage, once per session until it opens", () => { + const log = recordingStructuredAgentSessionLogger() + const refusals = createJournalOpenReadRefusals(log.logger) + const newer = journalOpenRefusalError( + new AgentSessionJournalError('journal_read_only', 'a newer Orca wrote session-1') + ) + for (let attempt = 0; attempt < 3; attempt += 1) { + expect(refusals.refusal('session-1', newer)).toBe(newer) + } + expect(log.entries.map((entry) => entry.fields.sessionId)).toEqual(['session-1']) + refusals.refusal('session-2', newer) + refusals.forget('session-1') + refusals.refusal('session-1', newer) + expect(log.entries.map((entry) => entry.fields.sessionId)).toEqual([ + 'session-1', + 'session-2', + 'session-1' + ]) + // A refusal that is not about loading the history is not this door's to log. + refusals.refusal( + 'session-3', + agentSessionRefusalError('agent_session_identity_required', { reason: 'recordMissing' }) + ) + expect(log.entries).toHaveLength(3) + }) }) // Only an update gets past a journal a newer Orca wrote, so it has a reason of its own: a client diff --git a/src/main/native-chat/agent-session-journal/journal-open-failure.ts b/src/main/native-chat/agent-session-journal/journal-open-failure.ts index 98626d3051a9..6a69cd174f7b 100644 --- a/src/main/native-chat/agent-session-journal/journal-open-failure.ts +++ b/src/main/native-chat/agent-session-journal/journal-open-failure.ts @@ -155,19 +155,33 @@ const MAX_LOGGED_SESSIONS = 256 */ export function createJournalOpenReadRefusals(logger: StructuredAgentSessionLogger) { const logged = new Map() + /** Whether this failure is news for the session, noting it if so. */ + const firstSeen = (sessionId: string, failure: string): boolean => { + const repeat = logged.get(sessionId) === failure + // Past the cap a new session logs every failure rather than evict another's. + if (!repeat && (logged.has(sessionId) || logged.size < MAX_LOGGED_SESSIONS)) { + logged.set(sessionId, failure) + } + return !repeat + } return { refusal: (sessionId: string, error: unknown): AgentSessionRefusalError => { if (isAgentSessionRefusalError(error)) { + // A load refused on its rows (damage, a newer Orca's chat) arrives classified. + const { refusal } = error + const cause = error.cause instanceof Error ? error.cause.message : String(error.cause) + if ( + refusal.code === 'agent_session_journal_unreadable' && + firstSeen(sessionId, `${refusal.code}:${cause}`) + ) { + logOpenForReadFailure(logger, sessionId, error) + } return error } const reason = journalRefusalReason(error) const failure = `${reason}:${error instanceof Error ? error.message : String(error)}` - const repeat = logged.get(sessionId) === failure - // Past the cap a new session logs every failure rather than evict another's. - if (!repeat && (logged.has(sessionId) || logged.size < MAX_LOGGED_SESSIONS)) { - logged.set(sessionId, failure) - } - return unreadableRefusal(error, reason, repeat ? null : { logger, sessionId }) + const log = firstSeen(sessionId, failure) ? { logger, sessionId } : null + return unreadableRefusal(error, reason, log) }, /** The session opened or closed: its next failure is news. */ forget: (sessionId: string): void => { @@ -181,11 +195,21 @@ function unreadableRefusal( reason: JournalRefusalReason, log: { logger: StructuredAgentSessionLogger; sessionId: string } | null ): AgentSessionRefusalError { - log?.logger.warn('opening the conversation for a read failed', { + if (log) { + logOpenForReadFailure(log.logger, log.sessionId, error) + } + const code = 'agent_session_journal_unreadable' + return new AgentSessionRefusalError(refuse(code, { reason }, code), { cause: error }) +} + +function logOpenForReadFailure( + logger: StructuredAgentSessionLogger, + sessionId: string, + error: unknown +): void { + logger.warn('opening the conversation for a read failed', { scope: 'open-for-read', - sessionId: log.sessionId, + sessionId, error }) - const code = 'agent_session_journal_unreadable' - return new AgentSessionRefusalError(refuse(code, { reason }, code), { cause: error }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts index c922810d08a4..6dead863af8c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts @@ -78,6 +78,9 @@ export function createStructuredAgentSessionRestartFailureLedger(deps: { adapter: StructuredAgentSessionAdapter /** The predicate a retry applies to the failure's marker. */ retryable: (marker: AgentSessionResumeMarker) => boolean + /** Whether a newer Orca saved the chat: its failure is kept for that Orca but not shown here, + * where no retry can land. */ + savedByNewerOrca: (sessionId: string) => boolean /** Makes the failed chats readable here, so `retryable` reads each one's journal. */ reveal: (markers: readonly AgentSessionResumeMarker[]) => Promise logger: StructuredAgentSessionLogger @@ -101,7 +104,11 @@ export function createStructuredAgentSessionRestartFailureLedger(deps: { failure: AgentSessionResumeFailureRecord ): StructuredAgentSessionResumeFailure[] => { const record = deps.getRecord(failure.marker.sessionId) - if (!record || !adapterSupportsRecord(deps.adapter, record)) { + if ( + !record || + !adapterSupportsRecord(deps.adapter, record) || + deps.savedByNewerOrca(failure.marker.sessionId) + ) { return [] } const model = normalizeOptionalField(record.options?.model, AGENT_MODEL_MAX_LENGTH) @@ -153,15 +160,21 @@ export function createStructuredAgentSessionRestartFailureLedger(deps: { ]) for (const outcome of outcomes) { const resumed = outcome.outcome === 'resumed' - // Ineligible means the offer no longer applies (record gone, conversation forked), superseded - // means the user's own message came first, and a newer Orca's chat is one nothing here can - // continue: nothing to retry, and the offer is spent without a failure. + // A newer Orca's refusal spends nothing: the rollback below reopens the offer for the Orca + // that can continue it, and nothing is filed for a chat no retry here could continue. + if ( + !resumed && + outcome.reason === 'agent_session_journal_unreadable' && + outcome.details?.reason === 'journalWrittenByNewerOrca' + ) { + continue + } + // Ineligible means the offer no longer applies (record gone, conversation forked), and + // superseded means the user's own message came first: nothing to retry, and the offer is spent. const failure = resumed ? action.failureAfterResume(outcome.sessionId) : outcome.reason === STRUCTURED_AGENT_SESSION_RESUME_NOT_ELIGIBLE || - outcome.reason === RESTART_CONTINUATION_SUPERSEDED || - (outcome.reason === 'agent_session_journal_unreadable' && - outcome.details?.reason === 'journalWrittenByNewerOrca') + outcome.reason === RESTART_CONTINUATION_SUPERSEDED ? null : 'refused' if (failure === null) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-newer-orca.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-newer-orca.test.ts index 3fc34803b6ab..efc7b1e231d5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-newer-orca.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-newer-orca.test.ts @@ -1,5 +1,10 @@ +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' import { afterEach, expect, it, vi } from 'vitest' -import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' +import { + AGENT_SESSION_RECOVERY_CAPSULE_FILE, + AgentSessionRecoveryCapsule +} from '../../runtime/agent-session-recovery-capsule' import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types' import { insertTestJournalRowJson, @@ -15,9 +20,9 @@ import { HOST_TEST_SESSION as SESSION } from './structured-agent-session-host-test-data' -// Nothing here can continue a chat a newer Orca saved: its restart offer is spent without a word, -// with no failure filed and nothing counted. Once Orca is updated, the user opens the chat and -// carries on by hand. +// Nothing here can continue a chat a newer Orca saved, so it is not offered after a restart and +// nothing is counted. Its offer is kept: the offers file is shared by every Orca on the host, and +// the newer one can still act on it. Nothing here writes to that file for such a chat. afterEach(() => vi.restoreAllMocks()) @@ -47,38 +52,46 @@ function newerOrcaRow(root: string): void { ) } -async function spentWithoutAWord( +function offersFile(root: string): Promise { + return readFile(join(root, AGENT_SESSION_RECOVERY_CAPSULE_FILE)) +} + +/** Lists and acts with nothing offered, run or counted, and the offers file left byte-identical. */ +async function keptWithoutAWord( host: Awaited>['host'], root: string, acquire: ReturnType ) { + const before = await offersFile(root) expect(await host.restartResume.list()).toEqual([]) + expect(await offersFile(root)).toEqual(before) expect((await host.restartResume.continueAfterRestart([SESSION], 'modal')).continued).toEqual([]) expect(acquire).not.toHaveBeenCalled() expect(await host.restartResume.listFailures()).toEqual([]) + expect(await offersFile(root)).toEqual(before) const capsule = new AgentSessionRecoveryCapsule(root) - expect(await capsule.list(NOW)).toEqual([]) + expect((await capsule.list(NOW)).map((marker) => marker.sessionId)).toEqual([SESSION]) expect(await capsule.listFailed(NOW)).toEqual([]) } -it('spends the offer of a chat whose journal a newer Orca wrote, and runs and counts nothing', async () => { +it('keeps the offer of a chat whose journal a newer Orca wrote, and offers, runs and counts nothing', async () => { const { host, root, acquire } = await interruptedRestart() newerOrcaRow(root) - await spentWithoutAWord(host, root, acquire) + await keptWithoutAWord(host, root, acquire) }) // A newer Orca's whole database: every chat in it is a newer Orca's, even one whose tables changed. -it('spends the offer of every chat in a newer Orca database, and runs and counts nothing', async () => { +it('keeps the offer of every chat in a newer Orca database, and offers, runs and counts nothing', async () => { const { host, root, acquire } = await interruptedRestart() const database = openTestJournalHostDatabase(root) Object.defineProperty(database, 'readOnly', { value: true }) database.db.exec('ALTER TABLE journal_sessions RENAME TO journal_sessions_newer') - await spentWithoutAWord(host, root, acquire) + await keptWithoutAWord(host, root, acquire) }) // However a newer Orca's refusal reaches a send (a journal first opened when sending), it files -// nothing and spends the offer. -it('spends the offer and files nothing when the send is refused as a newer Orca chat', async () => { +// nothing and the offer is reopened. +it('keeps the offer and files nothing when the send is refused as a newer Orca chat', async () => { const { host, root } = await interruptedRestart() await host.restartResume.list() vi.spyOn(AgentSessionJournal.prototype, 'appendSubmission').mockRejectedValue( @@ -96,8 +109,24 @@ it('spends the offer and files nothing when the send is refused as a newer Orca ]) const capsule = new AgentSessionRecoveryCapsule(root) expect(await capsule.listFailed(NOW)).toEqual([]) - expect(await capsule.list(NOW)).toEqual([]) + expect((await capsule.list(NOW)).map((marker) => marker.sessionId)).toEqual([SESSION]) + expect(await host.restartResume.listFailures()).toEqual([]) +}) + +// A failure filed before the chat became a newer Orca's stays for that Orca, and is not shown here. +it('keeps a failure already filed for a chat a newer Orca saved since, but does not show it', async () => { + const { host, root, acquire } = await interruptedRestart() + await host.restartResume.list() + acquire.mockRejectedValueOnce(new Error('provider could not reconnect')) + await host.restartResume.continueAfterRestart([SESSION], 'modal') + expect(await host.restartResume.listFailures()).toMatchObject([{ sessionId: SESSION }]) + await host.close(SESSION, 'evict') + newerOrcaRow(root) + expect(await host.restartResume.listFailures()).toEqual([]) + expect( + (await new AgentSessionRecoveryCapsule(root).listFailed(NOW)).map((f) => f.marker.sessionId) + ).toEqual([SESSION]) }) // What listing does with a journal that throws for another reason: a damaged chat stays offered, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts index 706a15752074..155ee67b7bb1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts @@ -104,23 +104,29 @@ export function createStructuredAgentSessionRestartResume( now: surfaces.now, enqueue: enqueueRecoveryOperation }) - // Chats the latest reveal found saved by a newer Orca. Each reveal re-derives its chat's entry, - // and the derive that reads one spends that chat's offer. + // Chats the latest reveal found saved by a newer Orca: skipped, never spent. Each reveal + // re-derives its chat's entry; nothing is stored. const newerOrcaChats = new Set() const savedByNewerOrca = (sessionId: string) => deps.journalDatabase.readOnly || newerOrcaChats.has(sessionId) - const derive = createStructuredAgentSessionRestartCandidateReader({ - sessions, - getRecord: deps.store.getRecord, - adapter: deps.adapter, - movedOn: withdrawal.movedOn, - savedByNewerOrca - }) + const reader = (skipNewerOrca: (sessionId: string) => boolean) => + createStructuredAgentSessionRestartCandidateReader({ + sessions, + getRecord: deps.store.getRecord, + adapter: deps.adapter, + movedOn: withdrawal.movedOn, + savedByNewerOrca: skipNewerOrca + }) + const derive = reader(savedByNewerOrca) + // The check right before sending skips nothing for a newer Orca's chat: turning it away there + // would spend its offer. Its send is refused instead, and settling keeps the offer. + const deriveAtSend = reader(() => false) const failures = createStructuredAgentSessionRestartFailureLedger({ ...(deps.recoveryCapsule ? { capsule: deps.recoveryCapsule } : {}), getRecord: deps.store.getRecord, adapter: deps.adapter, retryable: (marker) => derive([marker], 'may-be-held').candidates.length === 1, + savedByNewerOrca, reveal: (markers) => revealMarkers(markers), logger: deps.logger, now: surfaces.now, @@ -163,7 +169,7 @@ export function createStructuredAgentSessionRestartResume( deps.store.getRecord(sessionId) ? structuredAgentSessionConversationFence(deps.store, sessionId) : null, - stillResumable: (marker) => derive([marker], 'may-be-held').candidates.length === 1 + stillResumable: (marker) => deriveAtSend([marker], 'may-be-held').candidates.length === 1 } /** One explicit action: reserve the offers, then continue each through `continueOne`, a few at @@ -207,7 +213,9 @@ export function createStructuredAgentSessionRestartResume( admission, consumeMarker: async (sessionId) => { const marker = markersBySession.get(sessionId) - return marker !== undefined && derive([marker], 'may-be-held').candidates.length === 1 + return ( + marker !== undefined && deriveAtSend([marker], 'may-be-held').candidates.length === 1 + ) }, resume: async (sessionId) => { const marker = markersBySession.get(sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-set.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-set.ts index 5732da1e1b43..9df4e392bc3c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-set.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-set.ts @@ -117,12 +117,15 @@ export function structuredAgentSessionResumableSet( superseded.push(marker) continue } - // Nothing here can continue a chat a newer Orca saved, so its offer is spent without a word: - // once Orca is updated, the user opens the chat and carries on by hand. - if (input.movedOn(marker) || input.savedByNewerOrca(marker.sessionId)) { + if (input.movedOn(marker)) { superseded.push(marker) continue } + // Nothing here can continue a chat a newer Orca saved, so it is not offered. Its offer is kept, + // not spent: the offers file is shared by every Orca on the host, and the newer one can act on it. + if (input.savedByNewerOrca(marker.sessionId)) { + continue + } const model = normalizeOptionalField(record.options?.model, AGENT_MODEL_MAX_LENGTH) candidates.push({ sessionId: marker.sessionId, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts index dd14ef1fea7c..57c4a03acd16 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts @@ -475,16 +475,25 @@ describe('the resumable set', () => { expect(set.superseded).toEqual([forked]) }) - // Nothing here can continue a chat a newer Orca saved: its offer is spent without a word. - it("spends a newer Orca's chat's offer without offering it", () => { + // Nothing here can continue a chat a newer Orca saved: it is not offered, and its offer is kept + // for the Orca that can act on it. + it("neither offers nor spends a newer Orca's chat, and offers it once this build can open it", () => { const offer = marker() expect(resumableSet({ markers: [offer], savedByNewerOrca: [SESSION] })).toEqual({ candidates: [], - superseded: [offer] + superseded: [] }) expect(resumableSet({ markers: [offer] }).candidates).toHaveLength(1) }) + // Being a newer Orca's chat does not keep a forked chat's offer alive: the fork still ends it. + it("still withdraws a forked chat a newer Orca saved", () => { + const forked = marker({ providerHandleRoot: 'codex:"other-thread"' }) + expect( + resumableSet({ markers: [forked], savedByNewerOrca: [SESSION] }).superseded + ).toEqual([forked]) + }) + // An offer has no expiry, however old it is. it('still offers a months-old marker', () => { const monthsAgo = NOW - 90 * 24 * 60 * 60 * 1000 diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unopened-tab.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unopened-tab.test.ts index 21a06671eac3..170f58eba2fe 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unopened-tab.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unopened-tab.test.ts @@ -93,9 +93,16 @@ it.each([ expect(host.listSessionTabs()).toEqual([ { sessionId: SESSION, workspaceId, agent: expect.any(String) } ]) - await expect(host.history({ sessionId: SESSION, direction: 'tail' })).rejects.toMatchObject({ - refusal - }) + // Every read is refused with why, and the host logs it once for the chat. + for (let read = 0; read < 3; read += 1) { + await expect(host.history({ sessionId: SESSION, direction: 'tail' })).rejects.toMatchObject({ + refusal + }) + } + const readFailureLogs = vi + .mocked(console.warn) + .mock.calls.filter(([line]) => String(line).includes('open-for-read')) + expect(readFailureLogs).toHaveLength(1) const body = hostTestMessage('sent to a chat that cannot load') await expect( host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) From 732e1db0f925b0a26083ec2a09cf517d2cd7b279 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 19:22:37 -0700 Subject: [PATCH 46/52] refactor(native-chat): the newer-Orca chats a restart listing skips live beside the candidate reader --- ...ctured-agent-session-restart-candidates.ts | 17 +++++++++++++++++ ...tured-agent-session-restart-resume-host.ts | 19 +++++++------------ ...tured-agent-session-restart-resume.test.ts | 8 ++++---- 3 files changed, 28 insertions(+), 16 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-candidates.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-candidates.ts index a8b199932d03..77f2e0c8c1c0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-candidates.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-candidates.ts @@ -5,6 +5,7 @@ // ask it. import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' import type { AgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' import { latestStructuredAgentSessionPrompt } from '../../../shared/structured-agent-session-latest-request' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' @@ -47,3 +48,19 @@ export function createStructuredAgentSessionRestartCandidateReader(deps: { leaseState }) } + +/** Chats the latest reveal found saved by a newer Orca: listing skips them and never spends their + * offers. Each reveal re-derives its chat's entry; nothing is stored. */ +export function createNewerOrcaChats(databaseIsNewer: () => boolean) { + const chats = new Set() + return { + has: (sessionId: string): boolean => databaseIsNewer() || chats.has(sessionId), + note: (sessionId: string, revealed: { openRefusal?: AgentSessionWireRefusal } | null): void => { + if (revealed?.openRefusal?.details?.reason === 'journalWrittenByNewerOrca') { + chats.add(sessionId) + } else { + chats.delete(sessionId) + } + } + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts index 155ee67b7bb1..9ac87059801f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts @@ -14,7 +14,10 @@ import type { AgentSessionResumeTrigger } from '../../../shared/agent-session-resume-marker' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { createStructuredAgentSessionRestartCandidateReader } from './structured-agent-session-restart-candidates' +import { + createNewerOrcaChats, + createStructuredAgentSessionRestartCandidateReader +} from './structured-agent-session-restart-candidates' import { continuationFailureOutcome, createStructuredAgentSessionRestartFailureLedger @@ -104,11 +107,8 @@ export function createStructuredAgentSessionRestartResume( now: surfaces.now, enqueue: enqueueRecoveryOperation }) - // Chats the latest reveal found saved by a newer Orca: skipped, never spent. Each reveal - // re-derives its chat's entry; nothing is stored. - const newerOrcaChats = new Set() - const savedByNewerOrca = (sessionId: string) => - deps.journalDatabase.readOnly || newerOrcaChats.has(sessionId) + const newerOrca = createNewerOrcaChats(() => deps.journalDatabase.readOnly) + const savedByNewerOrca = newerOrca.has const reader = (skipNewerOrca: (sessionId: string) => boolean) => createStructuredAgentSessionRestartCandidateReader({ sessions, @@ -139,12 +139,7 @@ export function createStructuredAgentSessionRestartResume( readFailedMarkers: async () => (await failures.read()).map((failure) => failure.marker), hasSession: (sessionId) => sessions.has(sessionId), reveal: async (sessionId) => { - const revealed = await surfaces.revealSession(sessionId).catch(() => null) - if (revealed?.openRefusal?.details?.reason === 'journalWrittenByNewerOrca') { - newerOrcaChats.add(sessionId) - } else { - newerOrcaChats.delete(sessionId) - } + newerOrca.note(sessionId, await surfaces.revealSession(sessionId).catch(() => null)) }, logger: deps.logger, now: surfaces.now, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts index 57c4a03acd16..b16c7277fc10 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts @@ -487,11 +487,11 @@ describe('the resumable set', () => { }) // Being a newer Orca's chat does not keep a forked chat's offer alive: the fork still ends it. - it("still withdraws a forked chat a newer Orca saved", () => { + it('still withdraws a forked chat a newer Orca saved', () => { const forked = marker({ providerHandleRoot: 'codex:"other-thread"' }) - expect( - resumableSet({ markers: [forked], savedByNewerOrca: [SESSION] }).superseded - ).toEqual([forked]) + expect(resumableSet({ markers: [forked], savedByNewerOrca: [SESSION] }).superseded).toEqual([ + forked + ]) }) // An offer has no expiry, however old it is. From fa2c9ad7c9ffe754f013daba69bcbd1782a61054 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 19:33:24 -0700 Subject: [PATCH 47/52] fix(native-chat): a refused chat load keeps where it failed, so the log names it and a new reason logs again --- .../journal-open-failure.test.ts | 39 ++++++++++++++++++- .../journal-open-failure.ts | 11 ++++-- 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/journal-open-failure.test.ts b/src/main/native-chat/agent-session-journal/journal-open-failure.test.ts index 5672d6e9db07..c61057ac1b90 100644 --- a/src/main/native-chat/agent-session-journal/journal-open-failure.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-open-failure.test.ts @@ -9,11 +9,13 @@ import { createJournalOpenReadRefusals, journalOpenReadRefusal, journalOpenRefusal, - journalOpenRefusalError + journalOpenRefusalError, + failLoadOnUnloadableJournal } from './journal-open-failure' import { AgentSessionJournalError } from './journal-write-guards' import { journalDatabasePath } from './journal-host-database' import { replayJournal } from './journal-open' +import { createJournalReducerState } from './journal-reducer' import { recordingStructuredAgentSessionLogger } from '../agent-session-wire/structured-agent-session-logger-test-support' let root: string @@ -170,6 +172,41 @@ describe('createJournalOpenReadRefusals', () => { ]) }) + // Where it failed rides as the refusal's cause, so a chat whose reason changes is logged again + // and the log names the row. + it('logs a chat again when its load fails for another reason, naming where each failed', () => { + const log = recordingStructuredAgentSessionLogger() + const refusals = createJournalOpenReadRefusals(log.logger) + const state = createJournalReducerState('session-1', 'epoch-1') + const refusedFor = (load: Parameters[1]) => { + try { + failLoadOnUnloadableJournal('session-1', load) + } catch (error) { + return error + } + throw new Error('the load was not refused') + } + const newer = refusedFor({ state, newer: { sequence: 7 }, damage: null }) + const damaged = refusedFor({ + state, + newer: null, + damage: { sequence: 3, cause: 'sequence-gap' } + }) + + for (const error of [newer, newer, damaged, damaged]) { + refusals.refusal('session-1', error) + } + + const causes = log.entries.map((entry) => { + const error = entry.fields.error + return error instanceof Error && error.cause instanceof Error ? error.cause.message : '' + }) + expect(causes).toEqual([ + expect.stringContaining("newer Orca's row at sequence 7"), + expect.stringContaining('damaged at sequence 3: sequence-gap') + ]) + }) + // A load refused on its rows arrives already classified, and is logged once per session too. it("logs a load refused as a newer Orca's chat, or as damage, once per session until it opens", () => { const log = recordingStructuredAgentSessionLogger() diff --git a/src/main/native-chat/agent-session-journal/journal-open-failure.ts b/src/main/native-chat/agent-session-journal/journal-open-failure.ts index 6a69cd174f7b..8ae45ca4a311 100644 --- a/src/main/native-chat/agent-session-journal/journal-open-failure.ts +++ b/src/main/native-chat/agent-session-journal/journal-open-failure.ts @@ -6,7 +6,6 @@ import type { AgentSessionRefusalReason } from '../../../shared/agent-session-re import { agentSessionWriteNoticeEnglish } from '../../../shared/agent-session-refusal-notice' import { AgentSessionRefusalError, - agentSessionRefusalError, isAgentSessionRefusalError, refuse, type AgentSessionWireRefusal @@ -122,13 +121,17 @@ export function journalOpenRefusal(error: unknown): AgentSessionWireRefusal { return refuse('agent_session_journal_unreadable', { reason }, message) } -/** The same refusal, thrown: for a host that could not open the journal at all. */ +/** The same refusal, thrown: for a host that could not open the journal at all. It keeps what + * failed as its cause, for the log. */ export function journalOpenRefusalError(error: unknown): AgentSessionRefusalError { if (isAgentSessionRefusalError(error)) { return error } const { reason, message } = journalOpenFailureWords(error) - return agentSessionRefusalError('agent_session_journal_unreadable', { reason }, message) + return new AgentSessionRefusalError( + refuse('agent_session_journal_unreadable', { reason }, message), + { cause: error } + ) } /** @@ -172,7 +175,7 @@ export function createJournalOpenReadRefusals(logger: StructuredAgentSessionLogg const cause = error.cause instanceof Error ? error.cause.message : String(error.cause) if ( refusal.code === 'agent_session_journal_unreadable' && - firstSeen(sessionId, `${refusal.code}:${cause}`) + firstSeen(sessionId, `${refusal.details?.reason}:${cause}`) ) { logOpenForReadFailure(logger, sessionId, error) } From cfccc5fbd6cbf6ceb511eaf16cf1a9e2c0cf0ade Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 19:43:28 -0700 Subject: [PATCH 48/52] chore(native-chat): the reducer's window merge back where main has it, and comments say a newer Orca's chat fails to load --- config/reliability-gates.jsonc | 2 +- .../claude-background-wake-retention.test.ts | 2 +- .../journal-database-schema.ts | 4 +- .../journal-row-schema.test.ts | 6 +- .../journal-row-schema.ts | 8 +-- .../queued-message-store.test.ts | 2 +- .../agent-session-journal-body-admission.ts | 3 +- src/shared/agent-session-journal-types.ts | 4 +- .../structured-agent-session-reducer.ts | 69 +++++++++++++++++-- .../structured-agent-session-window-merge.ts | 69 ------------------- 10 files changed, 80 insertions(+), 89 deletions(-) delete mode 100644 src/shared/structured-agent-session-window-merge.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 4ea9b9714fe6..10ef711ed7e4 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -698,7 +698,7 @@ "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/main/native-chat/agent-session-journal src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts src/main/native-chat/agent-session-wire/agent-session-history-forward-read-budget.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts", "result": "passed", "durationSeconds": 5.56, - "summary": "450 tests passed across 42 files, run as the vitest invocation the test script makes. Damage fails the load with every row kept; the retained-heap, latching and cursor-cleanup assertions passed." + "summary": "450 tests passed across 42 files, run as the vitest invocation the test script makes. Damage fails the load with every row kept; the retained-heap, newer-row-past-a-gap and cursor-cleanup assertions passed." } ], "runtimeBudget": { diff --git a/src/main/claude/claude-background-wake-retention.test.ts b/src/main/claude/claude-background-wake-retention.test.ts index d3a32a2bb81a..7eb09546e723 100644 --- a/src/main/claude/claude-background-wake-retention.test.ts +++ b/src/main/claude/claude-background-wake-retention.test.ts @@ -45,7 +45,7 @@ async function wiredSession() { [ parent.sessionId, { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the feed reads only the cursor, read-only flag, activity clock and snapshot served here. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the feed reads only the cursor, activity clock and snapshot served here. journal: journal as unknown as Journal, params: { location: { diff --git a/src/main/native-chat/agent-session-journal/journal-database-schema.ts b/src/main/native-chat/agent-session-journal/journal-database-schema.ts index fd529586f4ea..00538fe75f9b 100644 --- a/src/main/native-chat/agent-session-journal/journal-database-schema.ts +++ b/src/main/native-chat/agent-session-journal/journal-database-schema.ts @@ -10,8 +10,8 @@ // retired claim keys and the chat tab index (agent-session-record-rows.ts). /** DB shape version, carried in `PRAGMA user_version`. Independent of the row body version - * (`JournalRow.v`): a newer build can change either alone. A newer version opens read-only here, - * so every change stays additive. */ + * (`JournalRow.v`): a newer build can change either alone. A newer version is opened read-only here + * and its chats are refused as a newer Orca's, so every change stays additive. */ export const JOURNAL_DB_SCHEMA_VERSION = 4 /** The first version a release wrote; 1 and 2 only ever came from development builds. */ diff --git a/src/main/native-chat/agent-session-journal/journal-row-schema.test.ts b/src/main/native-chat/agent-session-journal/journal-row-schema.test.ts index 966304e60a37..b72a24d70bb3 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-schema.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-schema.test.ts @@ -290,8 +290,8 @@ describe('producer linkage on the persisted row', () => { it('writes and reads the bundle back without bumping the schema version', () => { const row = roundTrip(true) expect(row).toMatchObject(linkage) - // Deliberately NOT a version bump: an unknown `v` is unreadable and latches - // the host read-only, while an unknown KEY is simply ignored by an older host. + // Deliberately NOT a version bump: an unknown `v` is unreadable and costs an older + // host the chat, while an unknown KEY is simply ignored by an older host. expect(row?.v).toBe(AGENT_SESSION_JOURNAL_SCHEMA_VERSION) }) @@ -373,7 +373,7 @@ describe('producer linkage on the persisted row', () => { expect(mutations[0]).toMatchObject(linkage) expect(mutations[1] && 'agentId' in mutations[1]).toBe(false) // The same batch without the stamp writes the same version: an older host - // ignores the unknown keys rather than latching the journal read-only. + // ignores the unknown keys rather than losing the chat to an unknown `v`. expect(row.v).toBe( build({ kind: 'item', identity, body, turnScope: AGENT_JOURNAL_THREAD_SCOPE }).v ) diff --git a/src/main/native-chat/agent-session-journal/journal-row-schema.ts b/src/main/native-chat/agent-session-journal/journal-row-schema.ts index b145477c69a7..f0ebac8622bf 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-schema.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-schema.ts @@ -2,8 +2,8 @@ // // The journal is append-only, so migration is upcasting on read and never an // in-place rewrite. A row whose version this build does not understand is -// UNREADABLE, not skippable: the caller must degrade to read-only rather than -// render a partial timeline or compact past a row it cannot interpret. +// UNREADABLE, not skippable: the caller fails the load as a newer Orca's chat rather +// than render a partial timeline or compact past a row it cannot interpret. // // A row whose KIND this build does not know is UNREADABLE the same way, and kept // on disk, when it has the envelope every row keeps: a non-empty `epoch`, an @@ -43,8 +43,8 @@ import { dropUnusableRowAnnotations } from './journal-row-unusable-annotations' /** Producer linkage rides the row BASE rather than the body: older builds read * the two nested prompt shapes strictly, so an unknown key there made the whole * row parse as malformed. It is also deliberately not a `v` bump — an - * unknown `v` makes a row unreadable and latches the host read-only, while an - * unknown KEY is ignored below, so an older host reads a stamped row and + * unknown `v` makes a row unreadable (released older hosts keep the chat read-only; + * this build fails its load), while an unknown KEY is ignored below, so an older host reads a stamped row and * behaves exactly as it does today. */ type JournalRowBase = AgentJournalProducerLinkage & { /** Schema version of THIS row. */ diff --git a/src/main/native-chat/agent-session-journal/queued-message-store.test.ts b/src/main/native-chat/agent-session-journal/queued-message-store.test.ts index 848aea9817e4..6c609853d690 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-store.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-store.test.ts @@ -125,7 +125,7 @@ describe('draft rows', () => { try { const version = Number(db.pragma('user_version', { simple: true })) // An old build compares stored == supported and keeps writing; a bump - // would latch it read-only after downgrade. + // would cost it every chat after a downgrade. expect(version).toBe(JOURNAL_DB_SCHEMA_VERSION) const table = db .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?") diff --git a/src/shared/agent-session-journal-body-admission.ts b/src/shared/agent-session-journal-body-admission.ts index a5462cfb87c8..42d0b8f63768 100644 --- a/src/shared/agent-session-journal-body-admission.ts +++ b/src/shared/agent-session-journal-body-admission.ts @@ -10,7 +10,8 @@ import { } from './agent-session-journal-schemas' import { isJournalTag } from './agent-session-journal-open-union' -/** `unreadable` is never damage: the caller keeps the row and stops writing, as for a newer `v`. */ +/** `unreadable` is never damage: the caller keeps the row and fails the load as a newer Orca's + * chat, as for a newer `v`. */ export type AgentJournalContentVerdict = 'readable' | 'unreadable' | 'malformed' export function readAgentJournalItemBody(body: unknown): AgentJournalContentVerdict { diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts index 0f0a9f751bd8..9613afa8a849 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -21,8 +21,8 @@ export { type AgentType } /** Bump only alongside a read-time upcaster in `journal-row-schema.ts`. */ /** v3 introduced the `turn` item. A row without one is still written at v2 so - * an older host keeps reading it; the first v3 row latches that host read-only - * instead of truncating the epoch. */ + * an older host keeps reading it; the first v3 row stops that host writing the chat + * (a released one keeps it read-only, this build fails its load) instead of truncating. */ export const AGENT_SESSION_JOURNAL_SCHEMA_VERSION = 3 export const AGENT_SESSION_JOURNAL_TURN_ITEM_SCHEMA_VERSION = 3 const AGENT_SESSION_JOURNAL_PRE_TURN_SCHEMA_VERSION = 2 diff --git a/src/shared/structured-agent-session-reducer.ts b/src/shared/structured-agent-session-reducer.ts index a140646f4195..dde31b09418c 100644 --- a/src/shared/structured-agent-session-reducer.ts +++ b/src/shared/structured-agent-session-reducer.ts @@ -15,6 +15,7 @@ import type { import type { AgentSessionRefusalReference } from './agent-session-wire-refusals' import { backgroundTaskStatesEqual } from './agent-session-background-task-state-equality' import { admitAgentSessionBackgroundTaskState } from './agent-session-background-task-state-admission' +import { agentJournalSubmissionKey } from './agent-session-journal-item-key' import { MAX_RETAINED_ITEMS, MAX_RETAINED_OWN_ITEMS, @@ -22,11 +23,6 @@ import { trimRetainedItems } from './structured-agent-session-item-retention' import { compareAgentJournalItems } from './agent-session-journal-position' -import { - liveItemsWithinWindow, - mergeItems, - mergeSubmissions -} from './structured-agent-session-window-merge' import { readAgentJournalTurn } from './agent-session-turn-record' import { foldStructuredAgentSubagentRoster, @@ -83,6 +79,8 @@ export type StructuredAgentSessionAction = | { type: 'history-page'; page: AgentSessionHistoryPage } | { type: 'older-page'; requestedCursor: AgentJournalCursor; page: AgentSessionHistoryPage } +const MAX_RETAINED_SUBMISSIONS = 256 + export const EMPTY_STRUCTURED_AGENT_SESSION: StructuredAgentSessionState = { epoch: null, cursor: null, @@ -144,6 +142,67 @@ function replacePage( } } +function mergeItems( + current: readonly AgentJournalRenderItem[], + incoming: readonly AgentJournalRenderItem[], + removedIds: readonly string[] +): AgentJournalRenderItem[] { + const removed = new Set(removedIds) + const byId = new Map( + current.filter((item) => !removed.has(item.itemId)).map((item) => [item.itemId, item]) + ) + for (const item of incoming) { + const prior = byId.get(item.itemId) + if (!prior || item.revision >= prior.revision) { + byId.set(item.itemId, item) + } + } + return [...byId.values()].sort(compareAgentJournalItems) +} + +/** + * Live rows the loaded window can take. The window is a contiguous suffix of the + * journal, and its oldest row is the load-older anchor. A revision of a row older + * than the window keeps that row's original sequence, so admitting it would move + * the anchor below the window and paging `before` it would skip every row between. + * The journal keeps the revision; the page reader serves it once the window + * reaches the row. With nothing older on the host the window is the whole journal + * and a row below the head (a revived tombstone) leaves no hole, so it is admitted. + */ +function liveItemsWithinWindow( + state: StructuredAgentSessionState, + incoming: readonly AgentJournalRenderItem[] +): readonly AgentJournalRenderItem[] { + const head = state.items[0] + if (!head || !state.hasOlder) { + return incoming + } + return incoming.filter((item) => item.sequence >= head.sequence) +} + +function mergeSubmissions( + current: readonly AgentJournalSubmission[], + incoming: readonly AgentJournalSubmission[], + items: readonly AgentJournalRenderItem[] +): AgentJournalSubmission[] { + const byId = new Map(current.map((submission) => [submission.clientMessageId, submission])) + for (const submission of incoming) { + byId.set(submission.clientMessageId, submission) + } + const sorted = [...byId.values()].sort((left, right) => left.submittedAt - right.submittedAt) + const itemIds = new Set( + items + .filter((item) => item.body.kind === 'message' && item.body.role === 'user') + .map((item) => item.itemId) + ) + // Loaded user messages need their provider alias for durable turn attribution. + return sorted.filter( + (submission, index) => + index >= sorted.length - MAX_RETAINED_SUBMISSIONS || + itemIds.has(agentJournalSubmissionKey(submission.clientMessageId)) + ) +} + /** `receivedAt` is the client clock at apply time; callers pass it so the reducer stays pure. */ export function reduceStructuredAgentSession( state: StructuredAgentSessionState, diff --git a/src/shared/structured-agent-session-window-merge.ts b/src/shared/structured-agent-session-window-merge.ts deleted file mode 100644 index 8c8770c1377e..000000000000 --- a/src/shared/structured-agent-session-window-merge.ts +++ /dev/null @@ -1,69 +0,0 @@ -// How a page or live batch merges into the items and submissions a client retains. - -import type { AgentJournalRenderItem, AgentJournalSubmission } from './agent-session-journal-types' -import { agentJournalSubmissionKey } from './agent-session-journal-item-key' -import { compareAgentJournalItems } from './agent-session-journal-position' -import type { StructuredAgentSessionState } from './structured-agent-session-reducer' - -const MAX_RETAINED_SUBMISSIONS = 256 - -export function mergeItems( - current: readonly AgentJournalRenderItem[], - incoming: readonly AgentJournalRenderItem[], - removedIds: readonly string[] -): AgentJournalRenderItem[] { - const removed = new Set(removedIds) - const byId = new Map( - current.filter((item) => !removed.has(item.itemId)).map((item) => [item.itemId, item]) - ) - for (const item of incoming) { - const prior = byId.get(item.itemId) - if (!prior || item.revision >= prior.revision) { - byId.set(item.itemId, item) - } - } - return [...byId.values()].sort(compareAgentJournalItems) -} - -/** - * Live rows the loaded window can take. The window is a contiguous suffix of the - * journal, and its oldest row is the load-older anchor. A revision of a row older - * than the window keeps that row's original sequence, so admitting it would move - * the anchor below the window and paging `before` it would skip every row between. - * The journal keeps the revision; the page reader serves it once the window - * reaches the row. With nothing older on the host the window is the whole journal - * and a row below the head (a revived tombstone) leaves no hole, so it is admitted. - */ -export function liveItemsWithinWindow( - state: StructuredAgentSessionState, - incoming: readonly AgentJournalRenderItem[] -): readonly AgentJournalRenderItem[] { - const head = state.items[0] - if (!head || !state.hasOlder) { - return incoming - } - return incoming.filter((item) => item.sequence >= head.sequence) -} - -export function mergeSubmissions( - current: readonly AgentJournalSubmission[], - incoming: readonly AgentJournalSubmission[], - items: readonly AgentJournalRenderItem[] -): AgentJournalSubmission[] { - const byId = new Map(current.map((submission) => [submission.clientMessageId, submission])) - for (const submission of incoming) { - byId.set(submission.clientMessageId, submission) - } - const sorted = [...byId.values()].sort((left, right) => left.submittedAt - right.submittedAt) - const itemIds = new Set( - items - .filter((item) => item.body.kind === 'message' && item.body.role === 'user') - .map((item) => item.itemId) - ) - // Loaded user messages need their provider alias for durable turn attribution. - return sorted.filter( - (submission, index) => - index >= sorted.length - MAX_RETAINED_SUBMISSIONS || - itemIds.has(agentJournalSubmissionKey(submission.clientMessageId)) - ) -} From 2cbfe20b4c5a639c8bcbe604074f151cfae2e88c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 05:39:19 -0700 Subject: [PATCH 49/52] test(agent-hooks): the rename test's journal fakes carry submissions, as a journal snapshot does --- src/main/agent-hooks/first-work-branch-rename.test.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/main/agent-hooks/first-work-branch-rename.test.ts b/src/main/agent-hooks/first-work-branch-rename.test.ts index 0e17feb98747..7d48faed731e 100644 --- a/src/main/agent-hooks/first-work-branch-rename.test.ts +++ b/src/main/agent-hooks/first-work-branch-rename.test.ts @@ -105,9 +105,8 @@ describe('maybeAutoRenameBranchOnFirstWork', () => { // cache must miss on every publish here: this test is about the rename. let sequence = 0 const journal = { - snapshot: () => ({ items }), + snapshot: () => ({ items, submissions: [] }), lastActivityAt: () => 1, - isReadOnly: false, cursor: () => ({ epoch: 1, sequence: (sequence += 1) }) } as unknown as AgentSessionJournal const pending: Promise[] = [] @@ -193,7 +192,6 @@ describe('maybeAutoRenameBranchOnFirstWork', () => { getRepo: () => ({ id: REPO_ID, kind: 'folder', path: '/workspace/platform' }) as Repo }) const journal = { - isReadOnly: false, lastActivityAt: () => 1, cursor: () => ({ epoch: 1, sequence: 1 }), snapshot: () => ({ @@ -206,7 +204,8 @@ describe('maybeAutoRenameBranchOnFirstWork', () => { turnLifecycle: { turnId: 'turn-1', state: 'running' } } } - ] + ], + submissions: [] }) } as unknown as AgentSessionJournal const location = { From d7281e3708455726c63ce46e8e54c3087076cd5f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 05:45:46 -0700 Subject: [PATCH 50/52] test(agent-hooks): the rename test's journal fakes say what the status feed reads --- src/main/agent-hooks/first-work-branch-rename.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/agent-hooks/first-work-branch-rename.test.ts b/src/main/agent-hooks/first-work-branch-rename.test.ts index 7d48faed731e..101297a6cbbb 100644 --- a/src/main/agent-hooks/first-work-branch-rename.test.ts +++ b/src/main/agent-hooks/first-work-branch-rename.test.ts @@ -104,6 +104,7 @@ describe('maybeAutoRenameBranchOnFirstWork', () => { // A real journal's sequence only ever advances, so the feed's projection // cache must miss on every publish here: this test is about the rename. let sequence = 0 + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the status feed reads only cursor(), lastActivityAt() and snapshot() of a journal. const journal = { snapshot: () => ({ items, submissions: [] }), lastActivityAt: () => 1, @@ -191,6 +192,7 @@ describe('maybeAutoRenameBranchOnFirstWork', () => { const { deps, setDisplayName, setRenameError } = makeDeps({ getRepo: () => ({ id: REPO_ID, kind: 'folder', path: '/workspace/platform' }) as Repo }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the status feed reads only cursor(), lastActivityAt() and snapshot() of a journal. const journal = { lastActivityAt: () => 1, cursor: () => ({ epoch: 1, sequence: 1 }), From 5504adc2452aa69c76949047e283804827d7719e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 07:18:42 -0700 Subject: [PATCH 51/52] fix(native-chat): Dismiss all ends the restart offers this Orca lists, and keeps a newer Orca's hidden ones --- ...ctured-agent-session-restart-candidates.ts | 18 ++++++ ...ed-agent-session-restart-failure-ledger.ts | 11 ++-- ...t-session-restart-offer-newer-orca.test.ts | 55 +++++++++++++++++-- ...red-agent-session-restart-offer-records.ts | 19 ++++--- ...tured-agent-session-restart-resume-host.ts | 36 ++++++------ .../agent-session-recovery-capsule-entries.ts | 17 ++++++ .../runtime/agent-session-recovery-capsule.ts | 21 ++++--- 7 files changed, 135 insertions(+), 42 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-candidates.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-candidates.ts index 77f2e0c8c1c0..93986d5988b4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-candidates.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-candidates.ts @@ -49,6 +49,24 @@ export function createStructuredAgentSessionRestartCandidateReader(deps: { }) } +/** The listing's reader, which skips a newer Orca's chats, and the one right before sending, which + * skips nothing for them: turning one away there would spend its offer, so its send is refused + * instead and settling keeps the offer. */ +export function createStructuredAgentSessionRestartCandidateReaders( + deps: Parameters[0] +): { + derive: StructuredAgentSessionRestartCandidateReader + deriveAtSend: StructuredAgentSessionRestartCandidateReader +} { + return { + derive: createStructuredAgentSessionRestartCandidateReader(deps), + deriveAtSend: createStructuredAgentSessionRestartCandidateReader({ + ...deps, + savedByNewerOrca: () => false + }) + } +} + /** Chats the latest reveal found saved by a newer Orca: listing skips them and never spends their * offers. Each reveal re-derives its chat's entry; nothing is stored. */ export function createNewerOrcaChats(databaseIsNewer: () => boolean) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts index 6dead863af8c..f69e9ced6dc4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts @@ -58,10 +58,11 @@ export type StructuredAgentSessionRestartFailureLedger = { failureReason: (sessionId: string) => string } ) => Promise - /** Named sessions forget their offer or failure; unnamed, every durable record goes. */ + /** Named sessions forget their offer or failure; unnamed, every record this host + * lists goes (a newer Orca's stay). */ dismiss: ( sessionIds: readonly string[] | undefined, - beforeClearAll: () => void + beforeClearAll: () => void | Promise ) => Promise } @@ -232,8 +233,10 @@ export function createStructuredAgentSessionRestartFailureLedger(deps: { if (sessionIds !== undefined) { return (await deps.capsule?.dismiss(sessionIds, deps.now())) ?? 0 } - beforeClearAll() - return (await deps.capsule?.clearAll(deps.now())) ?? 0 + await beforeClearAll() + // A newer Orca's offers and failures were never shown here, so "dismiss all" keeps them. + const keep = (marker: AgentSessionResumeMarker) => deps.savedByNewerOrca(marker.sessionId) + return (await deps.capsule?.clearAll(deps.now(), keep)) ?? 0 }) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-newer-orca.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-newer-orca.test.ts index efc7b1e231d5..e2f2087d4429 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-newer-orca.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-newer-orca.test.ts @@ -1,4 +1,4 @@ -import { readFile } from 'node:fs/promises' +import { readFile, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { afterEach, expect, it, vi } from 'vitest' import { @@ -15,6 +15,7 @@ import { import { AgentSessionJournal } from '../agent-session-journal/journal-store' import { AgentSessionJournalError } from '../agent-session-journal/journal-write-guards' import { interruptedRestart } from './structured-agent-session-restart-interruption-test-harness' +import { attachParams, CALLER, hostTestState } from './structured-agent-session-host-test-harness' import { HOST_TEST_NOW as NOW, HOST_TEST_SESSION as SESSION @@ -27,9 +28,9 @@ import { afterEach(() => vi.restoreAllMocks()) /** Writes a row of a kind this build does not know into the chat's journal, as a newer Orca would. */ -function newerOrcaRow(root: string): void { +function newerOrcaRow(root: string, sessionId: string = SESSION): void { const { db } = openTestJournalHostDatabase(root) - const last = liveTestJournalRows(db, SESSION).at(-1) + const last = liveTestJournalRows(db, sessionId).at(-1) const parsed: unknown = last ? JSON.parse(last.rowJson) : null if (!last || typeof parsed !== 'object' || parsed === null || !('epoch' in parsed)) { throw new Error('the chat has no journal row to follow') @@ -38,7 +39,7 @@ function newerOrcaRow(root: string): void { const seq = last.seq + 1 insertTestJournalRowJson( db, - SESSION, + sessionId, seq, JSON.stringify({ v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, @@ -149,3 +150,49 @@ it('still offers a damaged chat, and files its failure when acting on it', async } ]) }) + +// "Dismiss all" ends what the person was shown. A newer Orca's offer was never shown here, so it +// stays, byte for byte, for the Orca that can act on it. +it.each([ + ['after the dialog listed the offers', true], + ['on a host that has not listed them yet', false] +])( + "dismisses every listed offer and leaves a newer Orca's hidden one as it was, %s", + async (_when, listFirst) => { + const NEWER = 'session-newer-orca' + // A second chat, made before the restart; a newer Orca then saves it with a row this build + // can't place. + const created = await hostTestState().host.attach( + CALLER, + attachParams({ envelope: { ...attachParams().envelope, sessionId: NEWER } }) + ) + expect(created.ok).toBe(true) + hostTestState().acquire.mockClear() + const { host, root, marker } = await interruptedRestart() + newerOrcaRow(root, NEWER) + const path = join(root, AGENT_SESSION_RECOVERY_CAPSULE_FILE) + const capsule = JSON.parse(await readFile(path, 'utf8')) + capsule.entries.push({ state: 'pending', marker: { ...marker, sessionId: NEWER } }) + await writeFile(path, JSON.stringify(capsule)) + const newerEntry = () => + readFile(path, 'utf8').then((text) => + JSON.stringify( + JSON.parse(text).entries.find( + (entry: { marker: { sessionId: string } }) => entry.marker.sessionId === NEWER + ) + ) + ) + const before = await newerEntry() + + if (listFirst) { + expect((await host.restartResume.list()).map((candidate) => candidate.sessionId)).toEqual([ + SESSION + ]) + } + await host.restartResume.dismiss() + + const left = await new AgentSessionRecoveryCapsule(root).list(NOW) + expect(left.map((offer) => offer.sessionId)).toEqual([NEWER]) + expect(await newerEntry()).toBe(before) + } +) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-records.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-records.ts index 95c97be63a92..4b458002ab90 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-records.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-records.ts @@ -14,6 +14,8 @@ export type StructuredAgentSessionRestartOfferRecords = { sessionIds: readonly string[] | undefined ) => Promise revealMarkers: (markers: readonly AgentSessionResumeMarker[]) => Promise + /** Reveals the chat of every offer and every recorded failure. */ + revealEvery: () => Promise /** A user's newer message ended these offers; delete them rather than re-filter forever. * Advisory: a failed prune must never fail the read that noticed it. */ retireSuperseded: (superseded: readonly AgentSessionResumeMarker[]) => void @@ -41,6 +43,13 @@ export function createStructuredAgentSessionRestartOfferRecords(deps: { return [] } } + const revealMarkers = async (markers: readonly AgentSessionResumeMarker[]): Promise => { + for (const marker of markers) { + if (!deps.hasSession(marker.sessionId)) { + await deps.reveal(marker.sessionId) + } + } + } return { readMarkers, readActionMarkers: async (sessionIds) => { @@ -54,13 +63,9 @@ export function createStructuredAgentSessionRestartOfferRecords(deps: { ) return [...pending, ...retried] }, - revealMarkers: async (markers) => { - for (const marker of markers) { - if (!deps.hasSession(marker.sessionId)) { - await deps.reveal(marker.sessionId) - } - } - }, + revealMarkers, + revealEvery: async () => + revealMarkers([...(await readMarkers()), ...(await deps.readFailedMarkers())]), retireSuperseded: (superseded) => { const capsule = deps.capsule if (!capsule || superseded.length === 0) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts index 9ac87059801f..83dedb1709d3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts @@ -16,7 +16,7 @@ import type { import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { createNewerOrcaChats, - createStructuredAgentSessionRestartCandidateReader + createStructuredAgentSessionRestartCandidateReaders } from './structured-agent-session-restart-candidates' import { continuationFailureOutcome, @@ -71,7 +71,8 @@ export type StructuredAgentSessionRestartResume = { sessions?: StructuredAgentSessionResumeCandidate[] failed?: StructuredAgentSessionResumeFailure[] }> - /** Named sessions forget their offer or failure; unnamed, every durable record goes. */ + /** Named sessions forget their offer or failure; unnamed, every record this host + * lists goes (a newer Orca's stay). */ dismiss: (sessionIds?: readonly string[]) => Promise /** The chat's agent proved a start: its offer ends unless the start is a resume's own. */ onAgentStarted: (sessionId: string) => void @@ -108,32 +109,26 @@ export function createStructuredAgentSessionRestartResume( enqueue: enqueueRecoveryOperation }) const newerOrca = createNewerOrcaChats(() => deps.journalDatabase.readOnly) - const savedByNewerOrca = newerOrca.has - const reader = (skipNewerOrca: (sessionId: string) => boolean) => - createStructuredAgentSessionRestartCandidateReader({ - sessions, - getRecord: deps.store.getRecord, - adapter: deps.adapter, - movedOn: withdrawal.movedOn, - savedByNewerOrca: skipNewerOrca - }) - const derive = reader(savedByNewerOrca) - // The check right before sending skips nothing for a newer Orca's chat: turning it away there - // would spend its offer. Its send is refused instead, and settling keeps the offer. - const deriveAtSend = reader(() => false) + const { derive, deriveAtSend } = createStructuredAgentSessionRestartCandidateReaders({ + sessions, + getRecord: deps.store.getRecord, + adapter: deps.adapter, + movedOn: withdrawal.movedOn, + savedByNewerOrca: newerOrca.has + }) const failures = createStructuredAgentSessionRestartFailureLedger({ ...(deps.recoveryCapsule ? { capsule: deps.recoveryCapsule } : {}), getRecord: deps.store.getRecord, adapter: deps.adapter, retryable: (marker) => derive([marker], 'may-be-held').candidates.length === 1, - savedByNewerOrca, + savedByNewerOrca: newerOrca.has, reveal: (markers) => revealMarkers(markers), logger: deps.logger, now: surfaces.now, enqueue: enqueueRecoveryOperation }) - const { readMarkers, readActionMarkers, revealMarkers, retireSuperseded } = + const { readMarkers, readActionMarkers, revealMarkers, revealEvery, retireSuperseded } = createStructuredAgentSessionRestartOfferRecords({ ...(deps.recoveryCapsule ? { capsule: deps.recoveryCapsule } : {}), readFailedMarkers: async () => (await failures.read()).map((failure) => failure.marker), @@ -326,7 +321,12 @@ export function createStructuredAgentSessionRestartResume( listFailures: failures.list, // Do not let a teardown witness already captured in this host republish after explicit // dismissal. A later capture is a new interruption and may create a fresh offer normally. - dismiss: (sessionIds) => failures.dismiss(sessionIds, witnesses.clear), + // "Dismiss all" keeps what this host does not list, so it reveals every record's chat first. + dismiss: (sessionIds) => + failures.dismiss(sessionIds, async () => { + witnesses.clear() + await revealEvery() + }), continueAfterRestart, onAgentStarted: withdrawal.onAgentStarted } diff --git a/src/main/runtime/agent-session-recovery-capsule-entries.ts b/src/main/runtime/agent-session-recovery-capsule-entries.ts index a5d1d899723a..9deb2e20fe67 100644 --- a/src/main/runtime/agent-session-recovery-capsule-entries.ts +++ b/src/main/runtime/agent-session-recovery-capsule-entries.ts @@ -211,3 +211,20 @@ export function shouldReplaceMarker( // regress a newer witness from another host. return incoming.teardownId === current.teardownId } + +/** Records "dismiss all" leaves as they were: this host does not list them (a newer Orca's chats). */ +export type KeepRecord = (marker: AgentSessionResumeMarker) => boolean + +/** What a "dismiss all" keeps, and how many pending offers it ends. */ +export function splitDismissedAll( + state: Pick, + keep: KeepRecord +): { kept: Pick; dismissedPending: number } { + const entries = state.entries.filter((entry) => keep(entry.marker)) + return { + kept: { entries, failed: state.failed.filter((failure) => keep(failure.marker)) }, + dismissedPending: state.entries.filter( + (entry) => entry.state === 'pending' && !keep(entry.marker) + ).length + } +} diff --git a/src/main/runtime/agent-session-recovery-capsule.ts b/src/main/runtime/agent-session-recovery-capsule.ts index 80ed0bb726e2..54b579a1caff 100644 --- a/src/main/runtime/agent-session-recovery-capsule.ts +++ b/src/main/runtime/agent-session-recovery-capsule.ts @@ -15,8 +15,10 @@ import { normalizeState, parseState, shouldReplaceMarker, + splitDismissedAll, type AgentSessionResumeFailureInput, type AgentSessionResumeFailureRecord, + type KeepRecord, type RecoveryCapsuleState, type RecoveryEntry } from './agent-session-recovery-capsule-entries' @@ -277,23 +279,24 @@ export class AgentSessionRecoveryCapsule { }) } - clearAll(now: number): Promise { + /** `keep` names records this host does not list; they stay for the Orca that can act on them. */ + clearAll(now: number, keep: KeepRecord = () => false): Promise { return withFileTransactionLock(this.filePath, async () => { - let entries: RecoveryEntry[] + let state: Pick try { - entries = normalizeState(await this.readState(), now).entries + state = normalizeState(await this.readState(), now) } catch { // Dismiss is an explicit request to forget this advisory file. Replace unreadable bytes // with an empty, fenced capsule so a late teardown writer cannot resurrect the offer. await this.publish({ entries: [], failed: [] }, now, now) return 0 } - const pending = entries.filter((entry) => entry.state === 'pending') - // Dismiss is the explicit user request to forget every recovery record. An in-flight - // action may still finish, but its later complete/rollback becomes a no-op and cannot - // resurrect a row the user dismissed. - await this.publish({ entries: [], failed: [] }, now, now) - return pending.length + const { kept, dismissedPending } = splitDismissedAll(state, keep) + // Dismiss is the explicit user request to forget every recovery record it was shown. An + // in-flight action may still finish, but its later complete/rollback becomes a no-op and + // cannot resurrect a row the user dismissed. + await this.publish(kept, now, now) + return dismissedPending }) } From 5d9f109970abb580ea1b084804db688c1550a48b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 00:01:33 -0700 Subject: [PATCH 52/52] test(native-chat): a resend against a newer Orca's store answers unknown, and the phone says nothing beside the read's words This build never opens a chat a newer Orca's database holds, so a resent send id there cannot be answered from its journal: it answers "outcome unknown", never a refusal and never a made-up record, with no second delivery and no write. On the phone, a send error left beside a read that failed for good is not shown; the pane keeps only the read's words. --- mobile/src/session/MobileNativeChatView.test.ts | 6 +++++- .../structured-agent-session-resend-answer.test.ts | 11 +++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/mobile/src/session/MobileNativeChatView.test.ts b/mobile/src/session/MobileNativeChatView.test.ts index c8a8bc8aa794..ff2637f8a456 100644 --- a/mobile/src/session/MobileNativeChatView.test.ts +++ b/mobile/src/session/MobileNativeChatView.test.ts @@ -232,18 +232,22 @@ describe('MobileNativeChatView', () => { status: 'error' as const, error: words, canStop: true, - permission: { title: 'Approve?', options: [{ label: 'Allow', send: '1' }] } + permission: { title: 'Approve?', options: [{ label: 'Allow', send: '1' }] }, + // A resend answered unknown says nothing beside the read's words. + sendErrorMessage: 'Message unconfirmed — check chat before retrying' } await render({ ...failure, readFailedFinally: true }) expect(shown(words)).toHaveLength(1) expect(composers()).toHaveLength(0) expect(lists()).toHaveLength(0) + expect(banners()).toHaveLength(0) expect(renderer!.root.findAllByProps({ accessibilityLabel: 'Stop the agent' })).toHaveLength(0) // A failure that can clear keeps the transcript and the composer. await update({ ...failure, error: "Orca couldn't open this chat's history right now." }) expect(listIds()).toEqual(['m1']) expect(composers()).toHaveLength(1) + expect(banners()).toHaveLength(1) }) it('renders the route-reported failure verbatim', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-resend-answer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-resend-answer.test.ts index 74e4cfb4815d..bc40f51c4661 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-resend-answer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-resend-answer.test.ts @@ -198,21 +198,24 @@ describe('a resent send id', () => { expect(store.getRecord(SESSION)?.rewind?.phase).toBe('prepared') }) - it('is answered from a store a newer Orca wrote, which takes no write', async () => { + it('answers unknown, never a refusal, from a store a newer Orca wrote, which takes no write', async () => { await attach() const params = sendParams('sent, then a newer Orca wrote the store') await host.send(CALLER, params) await deliveredOnce() await host.close(SESSION, 'evict') + const rowsBefore = store.listOperationRows() Object.defineProperty(openTestJournalHostDatabase(root), 'readOnly', { value: true }) expect(store.readOnly).toBe(true) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + // This build never reads a newer Orca's chat, so the answer its journal holds stays unknown. await expect(host.send(CALLER, params)).resolves.toMatchObject({ - ok: true, - replayed: true, - value: { submission: { clientMessageId: params.envelope.clientOperationId } } + ok: false, + refusal: { code: 'agent_session_operation_unknown', details: { reason: 'outcomeUnknown' } } }) expect(dispatch).toHaveBeenCalledTimes(1) + expect(store.listOperationRows()).toEqual(rowsBefore) }) })