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
{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({
)}
confirm()}
className={cn(
'shrink-0 whitespace-nowrap rounded-md px-3 py-1 text-xs font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-default disabled:opacity-50',
diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx
index 29432e643304..274485be6bcc 100644
--- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx
@@ -106,6 +106,7 @@ export function queuedMessageCardSendNow(card: QueuedMessageCard): {
export function NativeChatQueuedMessageCard({
card,
showsSteerShortcut,
+ disabled = false,
onSteer,
onDelete,
onEdit,
@@ -114,6 +115,8 @@ export function NativeChatQueuedMessageCard({
card: QueuedMessageCard
/** Only the newest card answers Cmd/Ctrl+Enter; only it may show the chord. */
showsSteerShortcut: boolean
+ /** The host refuses every write: Send now, Delete and Edit cannot land. */
+ disabled?: boolean
onSteer: () => void
onDelete: () => void
onEdit: () => void
@@ -152,7 +155,7 @@ export function NativeChatQueuedMessageCard({
-
+
{sendNow.steers ? : }
{sendNow.label}
@@ -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}
-
+
{translate('components.native-chat.queuedMessages.resume', 'Resume')}
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({
setIndex(i)}
className={cn(
'flex shrink-0 items-center gap-1 rounded-md px-2 py-1 text-xs font-medium disabled:pointer-events-none',
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
new file mode 100644
index 000000000000..4be95170248a
--- /dev/null
+++ b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.read-only.test.tsx
@@ -0,0 +1,92 @@
+// @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 }) =>
0 ? (
@@ -113,41 +109,18 @@ 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
- ]
- )
+ // 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
+ })
const viewState = selectNativeChatViewState(session, { readRetries: true })
const readFailure =
controller.status === 'error'
@@ -162,7 +135,7 @@ export function NativeChatStructuredSession(
)
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 composerShown = prompt === null || readOnly
const approvalBody = prompt?.body.kind === 'approval' ? prompt.body : null
const approval = approvalBody
? {
@@ -351,8 +324,8 @@ export function NativeChatStructuredSession(
approval={approval}
onChoose={(optionId) => void controller.respond(prompt, { kind: 'option', optionId })}
onCancel={cancelPrompt}
- disabled={controller.readOnly !== undefined}
- shouldFocus={!controller.readOnly && props.isVisible && props.isFocusedGroup}
+ disabled={readOnly}
+ shouldFocus={!readOnly && props.isVisible && props.isFocusedGroup}
onLinkClick={onLinkClick}
allowFileUriLinks={onLinkClick !== undefined}
/>
@@ -387,7 +360,7 @@ export function NativeChatStructuredSession(
}
}}
onCancel={cancelPrompt}
- disabled={controller.readOnly !== undefined}
+ disabled={readOnly}
/>
) : null}
{composerShown ? (
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 5dde72621adb..3f5c84f0c0a1 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,6 +56,27 @@ function texts(
}
describe('the notice on each message that did not go through', () => {
+ it('in a read-only chat says only that the message was not sent, with no Retry that cannot land', () => {
+ const outbox = [
+ entry('refused', {
+ state: 'rejected',
+ lastFailure: { kind: 'rejected', reason: 'Chats were saved by a newer Orca.' }
+ })
+ ]
+ const notices = structuredAgentSessionDeliveryNotices(
+ outbox,
+ 'Claude',
+ () => {},
+ [],
+ [],
+ new Set(['refused']),
+ true
+ )
+ expect(notices.get(agentJournalSubmissionKey('refused'))).toEqual({
+ text: 'Your message was not sent.'
+ })
+ })
+
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 6ad655e8f80c..7076ac44538d 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
@@ -140,7 +140,9 @@ 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
+ failedHere: ReadonlySet,
+ /** The host refuses every write: the composer already says why, and no Retry can land. */
+ readOnly = false
): ReadonlyMap {
const admission = admitStructuredAgentSessionOutboxEntry(outbox)
const held = admission.state === 'blocked' ? admission.entry.clientMessageId : null
@@ -158,14 +160,17 @@ export function structuredAgentSessionDeliveryNotices(
entry.clientMessageId === held
) {
// Its own Retry is the step, so the words leave out sending again.
- const retryControl = stalledFrom === -1 || index <= stalledFrom
- const text = deliveryNoticeText(
- entry,
- { agentName, retryControl },
- rejected.get(entry.clientMessageId),
- startFailures,
- failedHere
- )
+ const retryControl = !readOnly && (stalledFrom === -1 || index <= stalledFrom)
+ const text =
+ readOnly && entry.lastFailure
+ ? agentSessionWriteNoticeText(agentSessionWriteNotDoneParts('send'))
+ : 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/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..db68f0c61ca0
--- /dev/null
+++ b/src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.ts
@@ -0,0 +1,50 @@
+// 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-transport-state.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-transport-state.test.tsx
index 1caba1713dda..778b2df6f222 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
@@ -38,7 +38,6 @@ function openTurn(readOnly?: AgentSessionReadOnlyReason): StructuredAgentSession
resolvedAt: null
}
],
- backgroundTasks: { state: 'monitoring', tasks: [], supportsTaskStop: true },
...(readOnly ? { readOnly } : {})
}
}
@@ -52,14 +51,12 @@ it('a read-only chat has the words for why, no turn and no work, as its host pro
expect(readOnly).toMatchObject({
readOnly: 'Saved by a newer Orca. Update Orca to continue this chat.',
turnId: null,
- isWorking: false,
- backgroundTasks: { supportsStop: false, supportsStopAll: false }
+ isWorking: false
})
expect(transport(openTurn())).toMatchObject({
readOnly: undefined,
turnId: 'turn-1',
- isWorking: true,
- backgroundTasks: { supportsStop: true }
+ 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 c9b3f560918a..809aa9101331 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
@@ -39,10 +39,6 @@ export function useStructuredAgentSessionTransportState(
},
turnId
)
- const backgroundTasks = structuredSessionBackgroundTasksView(
- enabled ? state.backgroundTasks : null,
- turnId
- )
return {
readOnly,
journalItems,
@@ -56,8 +52,9 @@ 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: readOnly
- ? { ...backgroundTasks, supportsStop: false, supportsStopAll: false }
- : backgroundTasks
+ backgroundTasks: structuredSessionBackgroundTasksView(
+ enabled ? state.backgroundTasks : null,
+ turnId
+ )
}
}
diff --git a/src/shared/agent-session-journal-schemas.ts b/src/shared/agent-session-journal-schemas.ts
index 2be720b928dd..86ac93ffd362 100644
--- a/src/shared/agent-session-journal-schemas.ts
+++ b/src/shared/agent-session-journal-schemas.ts
@@ -19,9 +19,10 @@
// 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.
+// is no newer build's: older builds read it as-is, except that their rewind replaces an unknown
+// turn, tool or resolution state with a placeholder row and turns an unknown block into a text
+// block of its raw JSON (structured-rewind-journal-body.ts); a goal state is kept. 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'
From 6e96ad151b20a3d2177671489f61e7f9fdec88e4 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Fri, 2 Oct 2026 02:11:07 -0700
Subject: [PATCH 17/52] test(native-chat): count picker mounts without an
effect
---
.../NativeChatSessionOptionPickers.read-only.test.tsx | 5 ++---
1 file changed, 2 insertions(+), 3 deletions(-)
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
index 4be95170248a..639706c04e58 100644
--- a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.read-only.test.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.read-only.test.tsx
@@ -23,9 +23,8 @@ vi.mock('@/components/ui/dropdown-menu', async () => {
children: React.ReactNode
defaultOpen?: boolean
}) => {
- React.useEffect(() => {
- mounts.push(Boolean(defaultOpen))
- }, [])
+ // A lazy initial state runs once per mount.
+ React.useState(() => mounts.push(Boolean(defaultOpen)))
return
{children}
},
DropdownMenuTrigger: Pass,
From eeb87aa421348590fc8d48bf10e1b5840f86be08 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Fri, 2 Oct 2026 02:21:51 -0700
Subject: [PATCH 18/52] fix(native-chat): read-only keeps other failures' words
and shuts open option menus
- In a read-only chat, only a send the newer-Orca refusal stopped is shortened to "Your message
was not sent."; any other saved failure keeps its own words, and none offers a Retry.
- A model or option menu open when the lock lands has its items disabled, so it cannot send a
change the host would refuse.
---
...atSessionOptionPickers.lock-lands.test.tsx | 62 +++++++++++++++++++
.../NativeChatSessionOptionPickers.tsx | 4 +-
...red-agent-session-delivery-notices.test.ts | 49 +++++++++++----
...ructured-agent-session-delivery-notices.ts | 15 ++++-
4 files changed, 113 insertions(+), 17 deletions(-)
create mode 100644 src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.lock-lands.test.tsx
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
new file mode 100644
index 000000000000..881d2dfbdfb3
--- /dev/null
+++ b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.lock-lands.test.tsx
@@ -0,0 +1,62 @@
+// @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(async () => ({ ok: true })),
+ 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.tsx b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.tsx
index 063ef1073e55..d2ad0496cc06 100644
--- a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.tsx
@@ -287,7 +287,7 @@ function NativeChatSessionOptionPickersInner({
) : null}
setOption(model, value)}
invokeAction={() => invokeAction(model)}
/>
@@ -321,7 +321,7 @@ function NativeChatSessionOptionPickersInner({
) : null}
setOption(descriptor, value)}
invokeAction={() => invokeAction(descriptor)}
/>
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 3f5c84f0c0a1..66852c4dfda7 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,25 +56,48 @@ function texts(
}
describe('the notice on each message that did not go through', () => {
- it('in a read-only chat says only that the message was not sent, with no Retry that cannot land', () => {
+ it('in a read-only chat shortens only the newer-Orca refusal; every failure loses its Retry', () => {
const outbox = [
- entry('refused', {
+ entry('newer-orca', {
state: 'rejected',
- lastFailure: { kind: 'rejected', reason: 'Chats were saved by a newer Orca.' }
+ 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 notices = structuredAgentSessionDeliveryNotices(
- outbox,
- 'Claude',
- () => {},
- [],
- [],
- new Set(['refused']),
- true
- )
- expect(notices.get(agentJournalSubmissionKey('refused'))).toEqual({
+ 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', () => {
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 7076ac44538d..8fbeffec16d1 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,6 +129,16 @@ 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(
@@ -141,7 +151,7 @@ export function structuredAgentSessionDeliveryNotices(
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: the composer already says why, and no Retry can land. */
+ /** The host refuses every write: no Retry can land. */
readOnly = false
): ReadonlyMap {
const admission = admitStructuredAgentSessionOutboxEntry(outbox)
@@ -161,8 +171,9 @@ export function structuredAgentSessionDeliveryNotices(
) {
// 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 && entry.lastFailure
+ readOnly && refusedAsWrittenByNewerOrca(entry.lastFailure)
? agentSessionWriteNoticeText(agentSessionWriteNotDoneParts('send'))
: deliveryNoticeText(
entry,
From 76013aa5ae89044d538ae5d6e246d2e504bac2d4 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Fri, 2 Oct 2026 02:22:34 -0700
Subject: [PATCH 19/52] test(native-chat): lock-lands picker test uses a typed
surface
---
.../NativeChatSessionOptionPickers.lock-lands.test.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
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
index 881d2dfbdfb3..4044f66a4c8f 100644
--- a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.lock-lands.test.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.lock-lands.test.tsx
@@ -10,7 +10,7 @@ import { NativeChatSessionOptionPickers } from './NativeChatSessionOptionPickers
const surface = {
getSnapshot: vi.fn(() => []),
- setOption: vi.fn(async () => ({ ok: true })),
+ setOption: vi.fn(),
invokeAction: vi.fn(),
subscribe: vi.fn(() => vi.fn())
}
From 7963cc9ce191e063699bc55aca20b0cb088fd282 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Fri, 2 Oct 2026 02:50:56 -0700
Subject: [PATCH 20/52] fix(native-chat): a locked composer draws its reason
The editor's placeholder used the default that draws nothing while the editor is not editable, so
a read-only chat's composer was empty and its reason lived only in the aria-label. The placeholder
now draws while disabled, and its words are empty unless the composer is locked with a reason, so
every other disabled composer (a pending prompt, no terminal) looks as before.
---
...tiveChatComposerField.placeholder.test.tsx | 91 +++++++++++++++++++
.../native-chat/NativeChatComposerField.tsx | 1 +
.../native-chat/NativeChatPromptEditor.tsx | 12 ++-
3 files changed, 103 insertions(+), 1 deletion(-)
create mode 100644 src/renderer/src/components/native-chat/NativeChatComposerField.placeholder.test.tsx
diff --git a/src/renderer/src/components/native-chat/NativeChatComposerField.placeholder.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposerField.placeholder.test.tsx
new file mode 100644
index 000000000000..192a5256d139
--- /dev/null
+++ b/src/renderer/src/components/native-chat/NativeChatComposerField.placeholder.test.tsx
@@ -0,0 +1,91 @@
+// @vitest-environment happy-dom
+// The composer's placeholder as the real editor draws it: a lock with a reason shows the reason,
+// and every other state draws what it drew before.
+
+import { createRef } from 'react'
+import { cleanup, render } from '@testing-library/react'
+import { afterEach, expect, it, vi } from 'vitest'
+import { useImeEnterGestureOwnership } from '@/lib/ime-composition-keyboard-event'
+import type { NativeChatComposerInput } from './native-chat-composer-input'
+import {
+ NativeChatComposerField,
+ type NativeChatComposerFieldProps
+} from './NativeChatComposerField'
+
+vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
+vi.mock('./NativeChatComposerActions', () => ({ NativeChatComposerActions: () => null }))
+vi.mock('./NativeChatAutocompleteMenus', () => ({
+ NativeChatMentionHint: () => null,
+ NativeChatPickerMenu: () => null
+}))
+
+afterEach(cleanup)
+
+const REASON = 'Saved by a newer Orca. Update Orca to continue this chat.'
+
+type State = Pick
+
+function Field(state: State): React.JSX.Element {
+ const imeEnterGesture = useImeEnterGestureOwnership()
+ return (
+ ()}
+ draft=""
+ autocomplete={{ mode: 'none' }}
+ activeSuggestion={0}
+ notice={null}
+ imageAttachments={[]}
+ sendButtonDisabled
+ isWorking={false}
+ attachDisabled={state.disabled}
+ dictationDisabled
+ isDictating={false}
+ isDictationHoldMode={false}
+ imeEnterGesture={imeEnterGesture}
+ onDraftChange={vi.fn()}
+ onTextareaSelect={vi.fn()}
+ onKeyDown={vi.fn()}
+ onImeSettled={vi.fn()}
+ onPaste={vi.fn()}
+ pickerListboxId="picker"
+ onChoosePickerItem={vi.fn()}
+ onRetrySkills={vi.fn()}
+ onAcceptMention={vi.fn()}
+ onRemoveImageAttachment={vi.fn()}
+ onAttach={vi.fn()}
+ onDictationToggle={vi.fn()}
+ onDictationHoldStart={vi.fn()}
+ onDictationHoldEnd={vi.fn()}
+ onSend={vi.fn()}
+ sessionOptionsSurface={null}
+ sessionOptionsSnapshot={[]}
+ />
+ )
+}
+
+/** What the empty line draws: the editor's `::before` reads this attribute. */
+function drawnPlaceholder(): string {
+ return document.querySelector('p.is-editor-empty')?.getAttribute('data-placeholder') ?? ''
+}
+
+const WRITABLE: State = { disabled: false, hasPty: true, canSend: true }
+const LOCKED: State = { disabled: true, hasPty: true, canSend: false, lockReason: REASON }
+
+it('draws the lock reason while the composer is locked, and the usual words once it unlocks', () => {
+ const { rerender } = render()
+ expect(drawnPlaceholder()).toBe(REASON)
+ rerender()
+ expect(drawnPlaceholder()).toBe('Send a message…')
+ rerender()
+ expect(drawnPlaceholder()).toBe(REASON)
+})
+
+it('draws nothing in a composer disabled for any other reason, as before', () => {
+ render()
+ expect(drawnPlaceholder()).toBe('')
+ cleanup()
+ render()
+ expect(drawnPlaceholder()).toBe('')
+})
diff --git a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx
index dfc427f86f26..dd78ec4b5dd3 100644
--- a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx
@@ -265,6 +265,7 @@ export function NativeChatComposerField({
? `${pickerListboxId}-option-${Math.min(activeSuggestion, autocomplete.items.length - 1)}`
: undefined
}
+ placeholderWhenDisabled={lockReason !== undefined}
placeholder={
goalMode?.active
? translate(
diff --git a/src/renderer/src/components/native-chat/NativeChatPromptEditor.tsx b/src/renderer/src/components/native-chat/NativeChatPromptEditor.tsx
index ceb812e6291c..674e16634008 100644
--- a/src/renderer/src/components/native-chat/NativeChatPromptEditor.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatPromptEditor.tsx
@@ -29,6 +29,8 @@ type Props = Omit, 'onChange' | 'onSelect'> & {
initialValue: string
disabled: boolean
placeholder: string
+ /** Draws the placeholder while disabled too: it then says why the editor is locked. */
+ placeholderWhenDisabled?: boolean
onChange: (input: NativeChatComposerInput) => void
onSelect: (input: NativeChatComposerInput) => void
}
@@ -39,6 +41,7 @@ export function NativeChatPromptEditor({
initialValue,
disabled,
placeholder,
+ placeholderWhenDisabled = false,
onChange,
onSelect,
className,
@@ -46,6 +49,8 @@ export function NativeChatPromptEditor({
}: Props): React.JSX.Element {
const placeholderRef = useRef(placeholder)
placeholderRef.current = placeholder
+ const placeholderWhenDisabledRef = useRef(placeholderWhenDisabled)
+ placeholderWhenDisabledRef.current = placeholderWhenDisabled
const editor = useEditor(
{
extensions: [
@@ -68,7 +73,12 @@ export function NativeChatPromptEditor({
trailingNode: false
}),
NativeChatSkill,
- Placeholder.configure({ placeholder: () => placeholderRef.current })
+ // Every other disabled editor keeps drawing no placeholder, as the default option would.
+ Placeholder.configure({
+ showOnlyWhenEditable: false,
+ placeholder: ({ editor: current }) =>
+ current.isEditable || placeholderWhenDisabledRef.current ? placeholderRef.current : ''
+ })
],
content:
(scopeKey && readNativeChatDraftDocument(scopeKey, initialValue)) ||
From 7c17fc75e9c9cedb0c2b4ef7bbba1a1f33c995da Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Fri, 2 Oct 2026 02:53:32 -0700
Subject: [PATCH 21/52] fix(native-chat): sync the locked-placeholder flag in
an effect, not during render
---
.../NativeChatComposerField.placeholder.test.tsx | 9 ++++++---
.../components/native-chat/NativeChatPromptEditor.tsx | 5 +++--
2 files changed, 9 insertions(+), 5 deletions(-)
diff --git a/src/renderer/src/components/native-chat/NativeChatComposerField.placeholder.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposerField.placeholder.test.tsx
index 192a5256d139..85eac2abb3c6 100644
--- a/src/renderer/src/components/native-chat/NativeChatComposerField.placeholder.test.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatComposerField.placeholder.test.tsx
@@ -2,7 +2,7 @@
// The composer's placeholder as the real editor draws it: a lock with a reason shows the reason,
// and every other state draws what it drew before.
-import { createRef } from 'react'
+import { useRef } from 'react'
import { cleanup, render } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import { useImeEnterGestureOwnership } from '@/lib/ime-composition-keyboard-event'
@@ -27,11 +27,12 @@ type State = Pick(null)
return (
()}
+ textareaRef={textareaRef}
draft=""
autocomplete={{ mode: 'none' }}
activeSuggestion={0}
@@ -74,7 +75,9 @@ const WRITABLE: State = { disabled: false, hasPty: true, canSend: true }
const LOCKED: State = { disabled: true, hasPty: true, canSend: false, lockReason: REASON }
it('draws the lock reason while the composer is locked, and the usual words once it unlocks', () => {
- const { rerender } = render()
+ const { rerender } = render()
+ expect(drawnPlaceholder()).toBe('Send a message…')
+ rerender()
expect(drawnPlaceholder()).toBe(REASON)
rerender()
expect(drawnPlaceholder()).toBe('Send a message…')
diff --git a/src/renderer/src/components/native-chat/NativeChatPromptEditor.tsx b/src/renderer/src/components/native-chat/NativeChatPromptEditor.tsx
index 674e16634008..bc736a7bd92b 100644
--- a/src/renderer/src/components/native-chat/NativeChatPromptEditor.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatPromptEditor.tsx
@@ -50,7 +50,6 @@ export function NativeChatPromptEditor({
const placeholderRef = useRef(placeholder)
placeholderRef.current = placeholder
const placeholderWhenDisabledRef = useRef(placeholderWhenDisabled)
- placeholderWhenDisabledRef.current = placeholderWhenDisabled
const editor = useEditor(
{
extensions: [
@@ -137,8 +136,10 @@ export function NativeChatPromptEditor({
)
useLayoutEffect(() => {
+ placeholderWhenDisabledRef.current = placeholderWhenDisabled
+ // Also redraws the placeholder, which reads the ref.
editor?.setEditable(!disabled, false)
- }, [disabled, editor])
+ }, [disabled, editor, placeholderWhenDisabled])
const input = useMemo(
() =>
From 3c2f7b270e8d114320be93586c3ff71fe81dcd80 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Fri, 2 Oct 2026 04:04:43 -0700
Subject: [PATCH 22/52] fix(native-chat): a read-only chat is not offered for
resume or counted as failed to resume
After a relaunch, a chat whose journal this build keeps read-only (a newer Orca's) was listed in
"Resume interrupted chats?", failed with "Orca couldn't resume this chat. Open it to continue
manually.", and left a status-bar "N chats failed to resume" that never cleared, since nothing can
continue a chat this build cannot write. The resume set now skips such a chat after the checks
that end an offer (fork, moved on), so the offer is kept, not spent, and an updated Orca offers it
again; the failure ledger keeps an already-filed failure for it but does not show it. Both read
the chat's open journal, which listing and acting open first.
---
...ctured-agent-session-restart-candidates.ts | 3 ++
...ed-agent-session-restart-failure-ledger.ts | 9 +++-
...nt-session-restart-offer-read-only.test.ts | 51 +++++++++++++++++++
...tured-agent-session-restart-resume-host.ts | 6 ++-
...ctured-agent-session-restart-resume-set.ts | 7 +++
...ent-session-restart-resume-test-harness.ts | 5 +-
...tured-agent-session-restart-resume.test.ts | 22 +++++++-
7 files changed, 98 insertions(+), 5 deletions(-)
create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-read-only.test.ts
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 b16f76c1633b..5eb0fe5b42e8 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
@@ -30,6 +30,8 @@ export function createStructuredAgentSessionRestartCandidateReader(deps: {
adapter: StructuredAgentSessionAdapter
/** Whether the chat moved on since the offer was taken; see the offer withdrawal. */
movedOn: (marker: AgentSessionResumeMarker) => boolean
+ /** Whether the chat's open journal is read-only here. */
+ readOnly: (sessionId: string) => boolean
}): StructuredAgentSessionRestartCandidateReader {
return (markers, leaseState) =>
structuredAgentSessionResumableSet({
@@ -37,6 +39,7 @@ export function createStructuredAgentSessionRestartCandidateReader(deps: {
getRecord: deps.getRecord,
supportsRecord: (record) => adapterSupportsRecord(deps.adapter, record),
movedOn: deps.movedOn,
+ readOnly: deps.readOnly,
latestPrompt: (sessionId) =>
latestStructuredAgentSessionPrompt(
deps.sessions.get(sessionId)?.journal.snapshot().items ?? []
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 63722d1737cc..e0a80c657f50 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 the chat's open journal is read-only here: its failure is kept but not shown, since
+ * no retry can land until Orca is updated. */
+ readOnly: (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.readOnly(failure.marker.sessionId)
+ ) {
return []
}
const model = normalizeOptionalField(record.options?.model, AGENT_MODEL_MAX_LENGTH)
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-read-only.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-read-only.test.ts
new file mode 100644
index 000000000000..a9c9be3eb409
--- /dev/null
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-read-only.test.ts
@@ -0,0 +1,51 @@
+import { afterEach, expect, it, vi } from 'vitest'
+import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule'
+import { AgentSessionJournal } from '../agent-session-journal/journal-store'
+import { interruptedRestart } from './structured-agent-session-restart-interruption-test-harness'
+import {
+ HOST_TEST_NOW as NOW,
+ HOST_TEST_SESSION as SESSION
+} from './structured-agent-session-host-test-data'
+
+// A chat whose journal this build keeps read-only (a newer Orca's) is not offered for resume and
+// not counted as failed, and its offer is kept for an updated Orca to act on.
+
+afterEach(() => vi.restoreAllMocks())
+
+function journalsReadOnly() {
+ return vi.spyOn(AgentSessionJournal.prototype, 'isReadOnly', 'get').mockReturnValue(true)
+}
+
+it('offers and runs nothing for a read-only chat, keeps its offer, and offers it again once writable', async () => {
+ const { host, root, acquire } = await interruptedRestart()
+ const readOnly = journalsReadOnly()
+
+ expect(await host.restartResume.list()).toEqual([])
+ const action = await host.restartResume.continueAfterRestart([SESSION], 'modal')
+ expect(action.continued).toEqual([])
+ expect(acquire).not.toHaveBeenCalled()
+ expect(await host.restartResume.listFailures()).toEqual([])
+ expect((await new AgentSessionRecoveryCapsule(root).list(NOW)).map((m) => m.sessionId)).toEqual([
+ SESSION
+ ])
+
+ readOnly.mockRestore()
+ expect((await host.restartResume.list()).map((candidate) => candidate.sessionId)).toEqual([
+ SESSION
+ ])
+})
+
+it('keeps a failure already filed for a chat that is now read-only, but does not show it', async () => {
+ const { host, 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 }])
+
+ const readOnly = journalsReadOnly()
+ expect(await host.restartResume.listFailures()).toEqual([])
+ readOnly.mockRestore()
+ expect(await host.restartResume.listFailures()).toMatchObject([
+ { sessionId: SESSION, retryable: true }
+ ])
+})
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 ef8481e2b1f5..f6a9440a0903 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
@@ -102,17 +102,21 @@ export function createStructuredAgentSessionRestartResume(
now: surfaces.now,
enqueue: enqueueRecoveryOperation
})
+ // Read once the chat is revealed: listing and acting open each marker's journal first.
+ const readOnly = (sessionId: string) => sessions.get(sessionId)?.journal.isReadOnly === true
const derive = createStructuredAgentSessionRestartCandidateReader({
sessions,
getRecord: deps.store.getRecord,
adapter: deps.adapter,
- movedOn: withdrawal.movedOn
+ movedOn: withdrawal.movedOn,
+ readOnly
})
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,
+ readOnly,
reveal: (markers) => revealMarkers(markers),
logger: deps.logger,
now: surfaces.now,
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 3c91a9732b9b..af996be852e1 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
@@ -79,6 +79,8 @@ export type StructuredAgentSessionResumeSetInput = {
latestPrompt: (sessionId: string) => string
/** Whether the chat moved on since the offer was taken; false when its journal is not open here. */
movedOn: (marker: AgentSessionResumeMarker) => boolean
+ /** Whether the chat's open journal is read-only here (a newer Orca's); false when not open. */
+ readOnly: (sessionId: string) => boolean
/**
* Whether the lease must be free.
*
@@ -119,6 +121,11 @@ export function structuredAgentSessionResumableSet(
superseded.push(marker)
continue
}
+ // Nothing can continue a chat this build cannot write. The offer is kept, not spent: an updated
+ // Orca can still act on it.
+ if (input.readOnly(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-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-test-harness.ts
index 93c33146743b..a2631563073a 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-test-harness.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-test-harness.ts
@@ -219,12 +219,15 @@ export function resumableSet(input: {
markers: AgentSessionResumeMarker[]
items?: AgentJournalRenderItem[]
chain?: AgentSessionRecord['providerHandleChain']
+ /** Sessions whose journal is read-only here. */
+ readOnly?: string[]
}) {
return structuredAgentSessionResumableSet({
markers: input.markers,
getRecord: () => record(input.chain === undefined ? {} : { chain: input.chain }),
supportsRecord: () => true,
latestPrompt: () => 'fix the auth bug',
- movedOn: () => false
+ movedOn: () => false,
+ readOnly: (sessionId) => input.readOnly?.includes(sessionId) === true
})
}
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 a2ed07db2916..ff203587bad0 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
@@ -455,7 +455,8 @@ describe('the resumable set', () => {
getRecord: () => claudeRecord('5aed93d6-advanced-leaf'),
supportsRecord: () => true,
latestPrompt: () => '',
- movedOn: () => false
+ movedOn: () => false,
+ readOnly: () => false
})
expect(candidates).toHaveLength(1)
@@ -470,13 +471,30 @@ describe('the resumable set', () => {
getRecord: () => claudeRecord(null, 'prov-session-2'),
supportsRecord: () => true,
latestPrompt: () => '',
- movedOn: () => false
+ movedOn: () => false,
+ readOnly: () => false
})
expect(set.candidates).toEqual([])
expect(set.superseded).toEqual([forked])
})
+ // A read-only chat cannot be continued here; its offer is kept for an updated Orca, not spent.
+ it('neither offers nor spends a read-only chat, and offers it once its journal is writable', () => {
+ const offer = marker()
+ expect(resumableSet({ markers: [offer], readOnly: [SESSION] })).toEqual({
+ candidates: [],
+ superseded: []
+ })
+ expect(resumableSet({ markers: [offer] }).candidates).toHaveLength(1)
+ })
+
+ // Read-only does not keep a forked chat's offer alive: the fork still ends it.
+ it('still withdraws a forked read-only chat', () => {
+ const forked = marker({ providerHandleRoot: 'codex:"other-thread"' })
+ expect(resumableSet({ markers: [forked], readOnly: [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
From 112286f0c1970c52217475dfed2f96c0ed74024f Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Fri, 2 Oct 2026 04:08:41 -0700
Subject: [PATCH 23/52] fix(native-chat): a refused resume continuation keeps
the refusal's reason
A send refused while continuing a chat after a restart was filed with its code alone, so a newer
Orca's refusal lost its `journalWrittenByNewerOrca` reason. The continuation now carries the whole
refusal, as a refusal from the agent's start already did, so the filed failure keeps it.
---
...agent-session-restart-continuation.test.ts | 22 ++++++++++++++++++-
...ured-agent-session-restart-continuation.ts | 6 +++--
...nt-session-restart-offer-read-only.test.ts | 22 +++++++++++++++++++
3 files changed, 47 insertions(+), 3 deletions(-)
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.test.ts
index 973d7758358d..e455bd07fe01 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.test.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.test.ts
@@ -105,12 +105,32 @@ it.each([
).resolves.toEqual({
sessionId: SESSION,
outcome: 'refused',
- reason: code
+ reason: code,
+ refusal: { code }
})
expect(deps.awaitSettlement).not.toHaveBeenCalled()
expect(deps.note).toHaveBeenCalledExactlyOnceWith(...note)
})
+// A newer Orca's refusal keeps its reason, so the failure is filed as that and not a bare code.
+it('reports a newer-Orca send refusal with its reason', async () => {
+ const deps = dependencies('accepted')
+ const refusal = {
+ code: 'agent_session_journal_unreadable',
+ details: { reason: 'journalWrittenByNewerOrca' }
+ } as const
+ deps.send.mockResolvedValue({ ok: false, refusal })
+
+ await expect(
+ continueStructuredAgentSessionAfterRestart(deps, SESSION, marker(), 'operation-1')
+ ).resolves.toEqual({
+ sessionId: SESSION,
+ outcome: 'refused',
+ reason: 'agent_session_journal_unreadable',
+ refusal
+ })
+})
+
it('reports an unattached chat without sending', async () => {
const deps = dependencies('accepted')
deps.currentFence = () => null
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts
index 27c09bc2bbe1..f72c419452aa 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts
@@ -160,7 +160,7 @@ export type StructuredAgentSessionContinuationDeps = {
body: AgentJournalMessageItem
}) => Promise<{
ok: boolean
- refusal?: { code: string }
+ refusal?: AgentSessionRefusalReference
/** The submission is where the provider's answer lives; the envelope only says Orca took it.
* A continuation never sends `delivery`, so a queued answer cannot arrive; the key exists so
* the host's union return stays assignable. */
@@ -310,7 +310,9 @@ async function sendContinuation(
done: {
sessionId,
outcome: 'refused',
- reason: sent.refusal?.code ?? 'agent_session_send_failed'
+ reason: sent.refusal?.code ?? 'agent_session_send_failed',
+ // Its details too, so a newer Orca's refusal is filed as one, not as a bare code.
+ ...(sent.refusal ? { refusal: sent.refusal } : {})
}
}
}
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-read-only.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-read-only.test.ts
index a9c9be3eb409..6e634707f9f1 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-read-only.test.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-read-only.test.ts
@@ -1,6 +1,7 @@
import { afterEach, expect, it, vi } from 'vitest'
import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule'
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 {
HOST_TEST_NOW as NOW,
@@ -49,3 +50,24 @@ it('keeps a failure already filed for a chat that is now read-only, but does not
{ sessionId: SESSION, retryable: true }
])
})
+
+// The journal latches after the offer was listed: the refusal is filed with its newer-Orca reason,
+// and while the chat stays read-only it is not shown.
+it('files a newer-Orca refusal with its reason, and does not show it while read-only', async () => {
+ const { host, root } = await interruptedRestart()
+ await host.restartResume.list()
+ vi.spyOn(AgentSessionJournal.prototype, 'appendSubmission').mockRejectedValue(
+ new AgentSessionJournalError('journal_read_only', 'a newer Orca wrote this journal')
+ )
+
+ await host.restartResume.continueAfterRestart([SESSION], 'modal')
+
+ expect(await new AgentSessionRecoveryCapsule(root).listFailed(NOW)).toMatchObject([
+ {
+ reason: 'agent_session_journal_unreadable',
+ details: { reason: 'journalWrittenByNewerOrca' }
+ }
+ ])
+ journalsReadOnly()
+ expect(await host.restartResume.listFailures()).toEqual([])
+})
From 6516578006ea2b3b02a900232bcbc8be3a5cba61 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Fri, 2 Oct 2026 04:25:15 -0700
Subject: [PATCH 24/52] fix(native-chat): a newer Orca's chat keeps its resume
offer however it is met
- A newer Orca's whole database counts as read-only for resume, so a chat whose journal cannot
be opened at all (a table the newer schema changed) is not offered, run or counted either.
- The checks made right before sending no longer skip read-only chats: turning one away there was
read as the user having moved on and deleted the offer. Its send is refused instead.
- Settling a resume files nothing for a newer Orca's refusal; the existing rollback reopens the
offer for an updated Orca.
- The continuation records the refusal as a reference, without the wire prose.
---
...agent-session-restart-continuation.test.ts | 4 +-
...ured-agent-session-restart-continuation.ts | 10 +-
...ed-agent-session-restart-failure-ledger.ts | 9 ++
...nt-session-restart-offer-read-only.test.ts | 106 ++++++++++++++++--
...tured-agent-session-restart-resume-host.ts | 33 ++++--
5 files changed, 139 insertions(+), 23 deletions(-)
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.test.ts
index e455bd07fe01..8f846bb6f1a3 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.test.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.test.ts
@@ -112,14 +112,14 @@ it.each([
expect(deps.note).toHaveBeenCalledExactlyOnceWith(...note)
})
-// A newer Orca's refusal keeps its reason, so the failure is filed as that and not a bare code.
+// A newer Orca's refusal keeps its reason, as a reference without the wire prose.
it('reports a newer-Orca send refusal with its reason', async () => {
const deps = dependencies('accepted')
const refusal = {
code: 'agent_session_journal_unreadable',
details: { reason: 'journalWrittenByNewerOrca' }
} as const
- deps.send.mockResolvedValue({ ok: false, refusal })
+ deps.send.mockResolvedValue({ ok: false, refusal: { ...refusal, message: 'Update Orca.' } })
await expect(
continueStructuredAgentSessionAfterRestart(deps, SESSION, marker(), 'operation-1')
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts
index f72c419452aa..0dc2c6f3a028 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts
@@ -14,7 +14,11 @@ import {
readAgentSessionFailureFact,
type UnreadAgentSessionFailureFact
} from '../../../shared/agent-session-failure'
-import type { AgentSessionRefusalReference } from '../../../shared/agent-session-wire-refusals'
+import {
+ agentSessionRefusalReference,
+ type AgentSessionRefusalReference
+} from '../../../shared/agent-session-wire-refusals'
+import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire'
import {
agentSessionSendSubmission,
type AgentSessionMutationEnvelope,
@@ -160,7 +164,7 @@ export type StructuredAgentSessionContinuationDeps = {
body: AgentJournalMessageItem
}) => Promise<{
ok: boolean
- refusal?: AgentSessionRefusalReference
+ refusal?: AgentSessionWireRefusal
/** The submission is where the provider's answer lives; the envelope only says Orca took it.
* A continuation never sends `delivery`, so a queued answer cannot arrive; the key exists so
* the host's union return stays assignable. */
@@ -312,7 +316,7 @@ async function sendContinuation(
outcome: 'refused',
reason: sent.refusal?.code ?? 'agent_session_send_failed',
// Its details too, so a newer Orca's refusal is filed as one, not as a bare code.
- ...(sent.refusal ? { refusal: sent.refusal } : {})
+ ...(sent.refusal ? { refusal: agentSessionRefusalReference(sent.refusal) } : {})
}
}
}
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 e0a80c657f50..f55694e87cf0 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
@@ -160,6 +160,15 @@ export function createStructuredAgentSessionRestartFailureLedger(deps: {
])
for (const outcome of outcomes) {
const resumed = outcome.outcome === 'resumed'
+ // A newer Orca's refusal spends nothing: the rollback below reopens the offer for an updated
+ // Orca, 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
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-read-only.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-read-only.test.ts
index 6e634707f9f1..74f5757660fc 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-read-only.test.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-read-only.test.ts
@@ -1,8 +1,15 @@
import { afterEach, expect, it, vi } from 'vitest'
import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule'
+import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types'
+import {
+ insertTestJournalRowJson,
+ liveTestJournalRows,
+ openTestJournalHostDatabase
+} from '../agent-session-journal/journal-host-database-test-support'
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 { StructuredAgentSessionResumeAdmission } from './structured-agent-session-restart-resume-runner'
import {
HOST_TEST_NOW as NOW,
HOST_TEST_SESSION as SESSION
@@ -51,23 +58,108 @@ it('keeps a failure already filed for a chat that is now read-only, but does not
])
})
-// The journal latches after the offer was listed: the refusal is filed with its newer-Orca reason,
-// and while the chat stays read-only it is not shown.
-it('files a newer-Orca refusal with its reason, and does not show it while read-only', async () => {
+/** 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 {
+ const { db } = openTestJournalHostDatabase(root)
+ const last = liveTestJournalRows(db, SESSION).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')
+ }
+ const { epoch } = parsed
+ const seq = last.seq + 1
+ insertTestJournalRowJson(
+ db,
+ SESSION,
+ seq,
+ JSON.stringify({
+ v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION,
+ kind: 'future-mark',
+ epoch,
+ seq,
+ fence: 1,
+ ts: NOW
+ }),
+ NOW
+ )
+}
+
+async function nothingOfferedRunOrCounted(
+ host: Awaited>['host'],
+ root: string,
+ acquire: ReturnType
+) {
+ expect(await host.restartResume.list()).toEqual([])
+ expect((await host.restartResume.continueAfterRestart([SESSION], 'modal')).continued).toEqual([])
+ expect(acquire).not.toHaveBeenCalled()
+ expect(await host.restartResume.listFailures()).toEqual([])
+ const capsule = new AgentSessionRecoveryCapsule(root)
+ expect((await capsule.list(NOW)).map((marker) => marker.sessionId)).toEqual([SESSION])
+ expect(await capsule.listFailed(NOW)).toEqual([])
+}
+
+it('keeps the offer of a chat whose journal really is read-only, and offers, runs and counts nothing', async () => {
+ const { host, root, acquire } = await interruptedRestart()
+ newerOrcaRow(root)
+ await nothingOfferedRunOrCounted(host, root, acquire)
+})
+
+// A newer Orca's whole database: even a chat whose journal fails to open is a newer Orca's.
+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 })
+ // A table the newer schema changed: this chat's journal cannot be opened at all.
+ database.db.exec('ALTER TABLE journal_sessions RENAME TO journal_sessions_newer')
+ await nothingOfferedRunOrCounted(host, root, acquire)
+})
+
+// However a newer Orca's refusal reaches a send (a journal first opened when sending), it files
+// nothing and keeps the offer.
+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(
new AgentSessionJournalError('journal_read_only', 'a newer Orca wrote this journal')
)
- await host.restartResume.continueAfterRestart([SESSION], 'modal')
+ const action = await host.restartResume.continueAfterRestart([SESSION], 'modal')
- expect(await new AgentSessionRecoveryCapsule(root).listFailed(NOW)).toMatchObject([
+ expect(action.continued).toMatchObject([
{
+ outcome: 'refused',
reason: 'agent_session_journal_unreadable',
- details: { reason: 'journalWrittenByNewerOrca' }
+ refusal: { details: { reason: 'journalWrittenByNewerOrca' } }
}
])
- journalsReadOnly()
+ const capsule = new AgentSessionRecoveryCapsule(root)
+ expect(await capsule.listFailed(NOW)).toEqual([])
+ expect((await capsule.list(NOW)).map((marker) => marker.sessionId)).toEqual([SESSION])
+ expect(await host.restartResume.listFailures()).toEqual([])
+})
+
+// Read-only first seen after the action chose the chat: the check right before sending must not
+// turn it away as if the user had moved on, which would spend the offer.
+it('keeps the offer of a chat first seen read-only when sending', async () => {
+ const { host, root } = await interruptedRestart()
+ await host.restartResume.list()
+ const database = openTestJournalHostDatabase(root)
+ // After the action chose the chat, before its send-time checks.
+ const admit = StructuredAgentSessionResumeAdmission.prototype.run
+ vi.spyOn(StructuredAgentSessionResumeAdmission.prototype, 'run').mockImplementation(
+ async function (this, ...args) {
+ Object.defineProperty(database, 'readOnly', { value: true, configurable: true })
+ return admit.apply(this, args)
+ }
+ )
+ vi.spyOn(AgentSessionJournal.prototype, 'appendSubmission').mockRejectedValue(
+ new AgentSessionJournalError('journal_read_only', 'a newer Orca wrote this journal')
+ )
+
+ await host.restartResume.continueAfterRestart([SESSION], 'modal')
+
+ const capsule = new AgentSessionRecoveryCapsule(root)
+ expect((await capsule.list(NOW)).map((marker) => marker.sessionId)).toEqual([SESSION])
+ expect(await capsule.listFailed(NOW)).toEqual([])
expect(await host.restartResume.listFailures()).toEqual([])
})
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 f6a9440a0903..84df6f333ecd 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
@@ -44,6 +44,7 @@ import {
import type { StructuredAgentSessionRestartResumeSurfaces } from './structured-agent-session-restart-resume-wiring'
import { createStructuredAgentSessionRestartWitnesses } from './structured-agent-session-restart-witnesses'
import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child'
+import type { JournalHostDatabase } from '../agent-session-journal/journal-host-database'
import type { StructuredAgentSessionLogger } from './structured-agent-session-logger'
type LiveSession = StructuredAgentSessionRestartOfferSession
@@ -79,6 +80,7 @@ export function createStructuredAgentSessionRestartResume(
adapter: StructuredAgentSessionAdapter
recoveryCapsule?: AgentSessionRecoveryCapsule
logger: StructuredAgentSessionLogger
+ journalDatabase: Pick
},
sessions: ReadonlyMap,
surfaces: StructuredAgentSessionRestartResumeSurfaces
@@ -102,15 +104,22 @@ export function createStructuredAgentSessionRestartResume(
now: surfaces.now,
enqueue: enqueueRecoveryOperation
})
- // Read once the chat is revealed: listing and acting open each marker's journal first.
- const readOnly = (sessionId: string) => sessions.get(sessionId)?.journal.isReadOnly === true
- const derive = createStructuredAgentSessionRestartCandidateReader({
- sessions,
- getRecord: deps.store.getRecord,
- adapter: deps.adapter,
- movedOn: withdrawal.movedOn,
- readOnly
- })
+ // A newer Orca's chat: the whole database is a newer Orca's (so even a journal that fails to open
+ // is one), or the chat's journal, which listing and acting open first, is read-only here.
+ const readOnly = (sessionId: string) =>
+ deps.journalDatabase.readOnly || sessions.get(sessionId)?.journal.isReadOnly === true
+ const reader = (skipReadOnly: (sessionId: string) => boolean) =>
+ createStructuredAgentSessionRestartCandidateReader({
+ sessions,
+ getRecord: deps.store.getRecord,
+ adapter: deps.adapter,
+ movedOn: withdrawal.movedOn,
+ readOnly: skipReadOnly
+ })
+ const derive = reader(readOnly)
+ // The check made right before sending skips nothing for read-only: turning a chat 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,
@@ -154,7 +163,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
@@ -198,7 +207,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)
From 42a49ea3f4fc967a0bbe86f699715c702165c26a Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Fri, 2 Oct 2026 04:25:51 -0700
Subject: [PATCH 25/52] chore(native-chat): one import of the session wire
types in the continuation
---
.../structured-agent-session-restart-continuation.ts | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts
index 0dc2c6f3a028..0d5ffb939e97 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts
@@ -18,12 +18,12 @@ import {
agentSessionRefusalReference,
type AgentSessionRefusalReference
} from '../../../shared/agent-session-wire-refusals'
-import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire'
import {
agentSessionSendSubmission,
type AgentSessionMutationEnvelope,
type AgentSessionMutationResult,
- type AgentSessionSendResult
+ type AgentSessionSendResult,
+ type AgentSessionWireRefusal
} from '../../../shared/agent-session-wire'
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
import {
From 1150b05af8553e9d220e18e9dbcfa2de431c3035 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Fri, 2 Oct 2026 04:31:20 -0700
Subject: [PATCH 26/52] docs(native-chat): resume and read-only comments match
the current rule
---
.../structured-agent-session-restart-candidates.ts | 4 ++--
.../structured-agent-session-restart-failure-ledger.ts | 4 ++--
.../structured-agent-session-restart-resume-set.ts | 2 +-
src/shared/agent-session-read-only.ts | 4 ++--
4 files changed, 7 insertions(+), 7 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 5eb0fe5b42e8..d05a81c3c55e 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
@@ -2,7 +2,7 @@
//
// A different question from storage: the durable record decides which markers are still present;
// this decides which of those a resume may act on. The offer, the click and the pre-send check all
-// ask it, and all get the same answer.
+// ask it; only the pre-send check is built not to skip a newer Orca's chat (see the host).
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import type { AgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker'
@@ -30,7 +30,7 @@ export function createStructuredAgentSessionRestartCandidateReader(deps: {
adapter: StructuredAgentSessionAdapter
/** Whether the chat moved on since the offer was taken; see the offer withdrawal. */
movedOn: (marker: AgentSessionResumeMarker) => boolean
- /** Whether the chat's open journal is read-only here. */
+ /** Whether the chat is a newer Orca's here: its whole database, or its open journal. */
readOnly: (sessionId: string) => boolean
}): StructuredAgentSessionRestartCandidateReader {
return (markers, leaseState) =>
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 f55694e87cf0..7347b22d37d3 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,8 +78,8 @@ export function createStructuredAgentSessionRestartFailureLedger(deps: {
adapter: StructuredAgentSessionAdapter
/** The predicate a retry applies to the failure's marker. */
retryable: (marker: AgentSessionResumeMarker) => boolean
- /** Whether the chat's open journal is read-only here: its failure is kept but not shown, since
- * no retry can land until Orca is updated. */
+ /** Whether the chat is a newer Orca's here (its whole database, or its open journal): its
+ * failure is kept but not shown, since no retry can land until Orca is updated. */
readOnly: (sessionId: string) => boolean
/** Makes the failed chats readable here, so `retryable` reads each one's journal. */
reveal: (markers: readonly AgentSessionResumeMarker[]) => Promise
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 af996be852e1..a9a66ea43df5 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
@@ -79,7 +79,7 @@ export type StructuredAgentSessionResumeSetInput = {
latestPrompt: (sessionId: string) => string
/** Whether the chat moved on since the offer was taken; false when its journal is not open here. */
movedOn: (marker: AgentSessionResumeMarker) => boolean
- /** Whether the chat's open journal is read-only here (a newer Orca's); false when not open. */
+ /** Whether the chat is a newer Orca's here: its whole database, or its open journal. */
readOnly: (sessionId: string) => boolean
/**
* Whether the lease must be free.
diff --git a/src/shared/agent-session-read-only.ts b/src/shared/agent-session-read-only.ts
index 5977b9723523..81984feb743a 100644
--- a/src/shared/agent-session-read-only.ts
+++ b/src/shared/agent-session-read-only.ts
@@ -7,8 +7,8 @@ import type { AgentSessionWriteNoticeSentence } from './agent-session-write-noti
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. */
+/** 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 {
From 3cca472d181f322f20c7f5e0b91b78c43487e02f Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Fri, 2 Oct 2026 07:16:58 -0700
Subject: [PATCH 27/52] test(native-chat): a closed set of a journal row cannot
change without the row version
The test walks the body schema and the row and mutation kind tables, collects every closed set (a
row kind, a mutation kind, each enum, literal or discriminant), and compares them with a snapshot
recorded beside the row version. A new value makes older builds go read-only, so it fails until
the reader ships first or `v` is bumped and the snapshot updated. Tags a catch-all arm accepts as
any string (block types, goal states) are listed apart: older builds read a new one as-is.
---
.../journal-closed-sets.test.ts | 109 ++++++++++++++++++
.../journal-row-schema.ts | 4 +-
2 files changed, 111 insertions(+), 2 deletions(-)
create mode 100644 src/main/native-chat/agent-session-journal/journal-closed-sets.test.ts
diff --git a/src/main/native-chat/agent-session-journal/journal-closed-sets.test.ts b/src/main/native-chat/agent-session-journal/journal-closed-sets.test.ts
new file mode 100644
index 000000000000..e5a749aa623c
--- /dev/null
+++ b/src/main/native-chat/agent-session-journal/journal-closed-sets.test.ts
@@ -0,0 +1,109 @@
+// Every closed set a journal row is read against: a row kind, a lifecycle mutation kind, and each
+// enum, literal or discriminant in a body. A value outside one reads as a newer build's (a turn's
+// context usage is dropped instead), so older builds go read-only; this snapshot sits beside the
+// row version so that a change to either is deliberate.
+
+import { expect, it } from 'vitest'
+import { z } from 'zod'
+import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types'
+import { AgentJournalItemBodySchema } from '../../../shared/agent-session-journal-schemas'
+import { KNOWN_MUTATION_KINDS, KNOWN_ROW_KINDS } from './journal-row-schema'
+
+type ClosedValue = string | number | boolean | null
+type ClosedSets = Record
+
+/** Nodes that hold no closed set and nothing nested. */
+const LEAVES = new Set(['string', 'number', 'boolean', 'unknown', 'any', 'null', 'undefined'])
+
+function isClosedValue(value: unknown): value is ClosedValue {
+ return (
+ typeof value === 'string' ||
+ typeof value === 'number' ||
+ typeof value === 'boolean' ||
+ value === null
+ )
+}
+
+/** Walks a schema and collects, by path, every value a closed node allows. A plain union whose
+ * other arm takes any string for a discriminated arm's tag (blocks, goal states) is open there:
+ * older builds read a new tag as-is, so that path is listed apart. Throws on a node kind it does
+ * not know, so a new kind of schema cannot hide a closed set from this test. */
+function collectClosedSets(
+ schema: z.core.$ZodType,
+ path: string,
+ into: { closed: ClosedSets; catchAll: string[] }
+): void {
+ const join = (key: string) => (path ? `${path}.${key}` : key)
+ if (schema instanceof z.ZodLiteral || schema instanceof z.ZodEnum) {
+ const kept = (into.closed[path] ??= [])
+ const values = schema instanceof z.ZodLiteral ? [...schema.values] : schema.options
+ for (const value of values) {
+ if (isClosedValue(value) && !kept.includes(value)) {
+ kept.push(value)
+ }
+ }
+ kept.sort((left, right) => String(left).localeCompare(String(right)))
+ } else if (schema instanceof z.ZodObject) {
+ for (const [key, field] of Object.entries(schema.shape)) {
+ collectClosedSets(field, join(key), into)
+ }
+ } else if (schema instanceof z.ZodOptional || schema instanceof z.ZodNullable) {
+ collectClosedSets(schema.unwrap(), path, into)
+ } else if (schema instanceof z.ZodArray) {
+ collectClosedSets(schema.element, `${path}[]`, into)
+ } else if (schema instanceof z.ZodUnion) {
+ for (const option of schema.options) {
+ collectClosedSets(option, path, into)
+ }
+ for (const tag of catchAllTags(schema.options)) {
+ delete into.closed[join(tag)]
+ into.catchAll.push(join(tag))
+ }
+ } else if (!LEAVES.has(schema._zod.def.type)) {
+ throw new Error(`no rule for a ${schema._zod.def.type} schema at ${path || 'the root'}`)
+ }
+}
+
+/** Tags a discriminated arm keys on that another arm of the same union accepts as any string. */
+function catchAllTags(options: readonly z.core.$ZodType[]): string[] {
+ const tags = options.flatMap((option) =>
+ option instanceof z.ZodDiscriminatedUnion ? [option.def.discriminator] : []
+ )
+ return tags.filter((tag) =>
+ options.some(
+ (option) => option instanceof z.ZodObject && option.shape[tag] instanceof z.ZodString
+ )
+ )
+}
+
+function journalClosedSets() {
+ const closed: ClosedSets = {
+ 'row.kind': [...KNOWN_ROW_KINDS.keys()].sort(),
+ 'row.mutations[].kind': [...KNOWN_MUTATION_KINDS.keys()].sort()
+ }
+ const into = { closed, catchAll: new Array() }
+ collectClosedSets(AgentJournalItemBodySchema, 'body', into)
+ return { v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, ...into }
+}
+
+/** Recorded with the row version it holds for. Change it only together with that decision. */
+const SNAPSHOT = {
+ v: 3,
+ closed: {
+ 'row.kind': ['dispatch', 'epoch', 'item', 'lifecycle-batch', 'submission', 'tombstone'],
+ 'row.mutations[].kind': ['item', 'tombstone'],
+ 'body.kind': ['approval', 'diff', 'message', 'question', 'status', 'tool-call', 'turn'],
+ 'body.subject.kind': ['plan'],
+ 'body.contextUsage.used.kind': ['estimate', 'report', 'unknown'],
+ 'body.contextUsage.used.categories[].deferred': [true]
+ },
+ catchAll: ['body.blocks[].type', 'body.threadGoal.state']
+}
+
+it('changes no closed set of a journal row without the row version', () => {
+ expect(
+ journalClosedSets(),
+ 'A new closed-set value makes older builds go read-only: ship the reader first or bump v, ' +
+ 'then update this snapshot.'
+ ).toEqual(SNAPSHOT)
+})
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 adb2b8d2d3f8..4557bf20af07 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
@@ -333,7 +333,7 @@ const ROW_CONTENT_CHECK_BY_KIND: Record<
)
}
}
-const KNOWN_ROW_KINDS = new Map(Object.entries(ROW_CONTENT_CHECK_BY_KIND))
+export 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 {
@@ -360,7 +360,7 @@ const MUTATION_CONTENT_CHECK_BY_KIND: Record<
tombstone: (mutation) =>
fieldsContent(typeof mutation.itemId === 'string' && Number.isInteger(mutation.revision))
}
-const KNOWN_MUTATION_KINDS = new Map(Object.entries(MUTATION_CONTENT_CHECK_BY_KIND))
+export const KNOWN_MUTATION_KINDS = new Map(Object.entries(MUTATION_CONTENT_CHECK_BY_KIND))
/** The kind first: a newer build's kind needs none of the fields this build's kinds have. */
function lifecycleMutationContent(value: unknown): AgentJournalContentVerdict {
From c4857f89759ebc9727cbb82f990e7e46db708657 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Fri, 2 Oct 2026 10:58:20 -0700
Subject: [PATCH 28/52] feat(native-chat): a body or plan subject of a newer
kind is kept and the chat stays writable
An item body kind and an approval subject kind this build does not know now read through the
same catch-all blocks and goal states use (`openDiscriminatedUnion`), instead of making the chat
read-only. The item is kept, drawn by nobody, and ignored by everything that reads items (turns,
prompts, status); a rewind carries it as it was, kept by its place like every other row. An
approval whose subject this build cannot draw shows its `detail`, which Orca's writers fill with
the subject's text. A sent message of a kind this build does not know stays a newer build's.
---
.../journal-closed-sets.test.ts | 4 +-
.../journal-row-schema.ts | 7 +-
.../journal-unknown-content.test.ts | 96 +++++++++++--------
.../agent-session-read-only-page.test.ts | 10 +-
.../structured-rewind-journal-body.test.ts | 16 ++++
.../structured-rewind-journal-body.ts | 3 +-
.../NativeChatApprovalCard.test.tsx | 18 ++++
...ent-session-journal-body-admission.test.ts | 26 +++--
.../agent-session-journal-body-admission.ts | 21 ++--
.../agent-session-journal-open-union.ts | 27 ++++++
src/shared/agent-session-journal-schemas.ts | 70 ++++++--------
...uctured-agent-session-unknown-item.test.ts | 38 ++++++++
.../agent-session-read-only.unit.test.ts | 45 +++++----
13 files changed, 258 insertions(+), 123 deletions(-)
create mode 100644 src/shared/agent-session-journal-open-union.ts
create mode 100644 src/shared/structured-agent-session-unknown-item.test.ts
diff --git a/src/main/native-chat/agent-session-journal/journal-closed-sets.test.ts b/src/main/native-chat/agent-session-journal/journal-closed-sets.test.ts
index e5a749aa623c..56201fa0f7a2 100644
--- a/src/main/native-chat/agent-session-journal/journal-closed-sets.test.ts
+++ b/src/main/native-chat/agent-session-journal/journal-closed-sets.test.ts
@@ -92,12 +92,10 @@ const SNAPSHOT = {
closed: {
'row.kind': ['dispatch', 'epoch', 'item', 'lifecycle-batch', 'submission', 'tombstone'],
'row.mutations[].kind': ['item', 'tombstone'],
- 'body.kind': ['approval', 'diff', 'message', 'question', 'status', 'tool-call', 'turn'],
- 'body.subject.kind': ['plan'],
'body.contextUsage.used.kind': ['estimate', 'report', 'unknown'],
'body.contextUsage.used.categories[].deferred': [true]
},
- catchAll: ['body.blocks[].type', 'body.threadGoal.state']
+ catchAll: ['body.blocks[].type', 'body.subject.kind', 'body.threadGoal.state', 'body.kind']
}
it('changes no closed set of a journal row without the row version', () => {
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 4557bf20af07..c120b0e77810 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
@@ -14,9 +14,10 @@
// or is written at a bumped `v`.
//
// 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
+// this build does not know, a sent message of a kind it does not know, or a body holding a value
+// outside a closed set it knows is UNREADABLE, whatever else in the row fails; a turn's context
+// usage is the exception, dropped like the other unusable annotations. A body of a kind it does
+// not know is READABLE and drawn by nobody (agent-session-journal-schemas.ts). 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.
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 ebe38219fd9f..fabb31dfe192 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 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.
+// Inside a row of a known kind, what a newer build wrote is never repaired away. A body of a kind
+// this build does not know (or a plan subject of one) is kept and the chat stays writable; a
+// mutation or sent message of a newer kind 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'
@@ -125,34 +125,57 @@ async function expectReadOnlyAndKept(written: { seq: number; rowJson: string }[]
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 () => {
+/** Opens writable with nothing repaired, keeps the newer item in the snapshot, takes a write,
+ * and leaves every earlier row byte-identical. */
+async function expectWritableAndKept(written: { seq: number; rowJson: string }[], itemId: string) {
+ const journal = await open()
+ expect(journal.isReadOnly).toBe(false)
+ expect(journal.repair).toEqual({ malformedRows: 0 })
+ expect(journal.snapshot().items.some((entry) => entry.itemId === itemId)).toBe(true)
+ await journal.appendItem(item(5), { kind: 'status', text: 'taken' }, SCOPE)
+ await journals.closeAll()
+ expect(stored().slice(0, written.length)).toEqual(written)
+}
+
+describe("a newer build's body kind, kept and writable", () => {
+ it.each([
+ ['an item body of a newer kind', NEWER_BODY],
+ ['a plan subject of a newer kind', { ...PLAN_APPROVAL, subject: { kind: 'diff' } }]
+ ])('keeps %s and stays writable', async (_name, body) => {
const { written } = await journalWith((epoch) => [
- itemRow(epoch, 'codex:thread-1:turn-1:2', NEWER_BODY)
+ itemRow(epoch, 'codex:thread-1:turn-1:2', body)
])
- await expectReadOnlyAndKept(written)
+ await expectWritableAndKept(written, 'codex:thread-1:turn-1:2')
})
- 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) => {
+ it('keeps a lifecycle mutation whose body is a newer kind, and stays writable', async () => {
const { written } = await journalWith((epoch) => [
batchRow(epoch, [
- {
- kind: 'item',
- itemId: 'codex:thread-1:turn-1:3',
- revision: 1,
- body: { kind: 'status', text: 'ok' }
- },
- mutation
+ { kind: 'item', itemId: 'codex:thread-1:turn-1:4', revision: 1, body: NEWER_BODY }
])
])
- await expectReadOnlyAndKept(written)
+ await expectWritableAndKept(written, 'codex:thread-1:turn-1:4')
})
+})
+
+describe("a newer build's row content that stays a closed set", () => {
+ it.each([['of a newer kind', { kind: 'pin', itemId: 'codex:thread-1:turn-1:3', revision: 2 }]])(
+ 'latches read-only on a lifecycle mutation %s',
+ async (_name, mutation) => {
+ const { written } = await journalWith((epoch) => [
+ batchRow(epoch, [
+ {
+ 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) => [
@@ -171,13 +194,6 @@ describe("a newer build's content inside a known row", () => {
await expectReadOnlyAndKept(written)
})
- 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', { ...PLAN_APPROVAL, subject: { kind: 'diff' } })
- ])
- await expectReadOnlyAndKept(written)
- })
-
it('latches read-only on a newer mutation kind that carries no item id', async () => {
const { written } = await journalWith((epoch) => [
batchRow(epoch, [{ kind: 'turn-settle', turnId: 'turn-1', outcome: 'done' }])
@@ -186,20 +202,18 @@ describe("a newer build's content inside a known row", () => {
})
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'
+ v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION,
+ epoch,
+ fence: 1,
+ ts: 5_000,
+ kind: 'submission',
+ clientMessageId: 'client-1',
+ payloadFingerprint: 7,
+ providerHandle: IDENTITY.providerHandle,
+ body: { kind: 'voice-note', clip: 'by a newer build' }
})
],
[
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 bc8ecc89862b..49fb7a35646f 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
@@ -117,7 +117,15 @@ 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: [] } })]
+ [
+ 'a newer batch-change kind',
+ () => ({
+ v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION,
+ kind: 'lifecycle-batch',
+ settlementId: 'settle-1',
+ mutations: [{ kind: 'pin', itemId: 'x', revision: 1 }]
+ })
+ ]
])('names a newer Orca on every page, for %s', async (_cause, row) => {
const journal = await reopenedAfter(row)
expect(journal.isReadOnly).toBe(true)
diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.test.ts b/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.test.ts
index 9de1925791fa..e126c36f52f5 100644
--- a/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.test.ts
+++ b/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.test.ts
@@ -24,6 +24,22 @@ describe('rewind recovery of newer durable records', () => {
})
})
+ // Retained by its place, never by its kind, so an updated Orca still has it after the rewind.
+ it('carries an item of a kind this build does not know as it was, and records it', () => {
+ const body = { kind: 'plan-card', steps: [{ text: 'by a newer build' }] }
+ expect(restoreRewindJournalBody(body)).toBe(body)
+ expect(
+ AgentSessionRewindRecordSchema.safeParse({
+ operationId: 'op-1',
+ callerKey: 'caller-1',
+ itemId: 'codex:thread-1:turn-1:3',
+ expectedEpoch: 'epoch-1',
+ phase: 'prepared',
+ retained: [{ itemId: 'codex:thread-1:turn-1:1', body, observedAt: 1 }]
+ }).success
+ ).toBe(true)
+ })
+
it('preserves background-task blocks across rewind recovery', () => {
const body = {
kind: 'message' as const,
diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.ts b/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.ts
index 99489b18769a..5cd762870dd2 100644
--- a/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.ts
+++ b/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.ts
@@ -10,7 +10,8 @@ import { NATIVE_CHAT_ROLES } from '../../../shared/native-chat-types'
type StoredBody = AgentSessionRewindRecord['retained'][number]['body']
/** A row this build cannot place stays visible as a row, never invented turn or prompt state —
- * and never as its stored JSON, which is Orca's record, not something a person reads. */
+ * and never as its stored JSON, which is Orca's record, not something a person reads. An item of
+ * a kind this build does not know is carried as it was: kept by its place, as every other row. */
export function restoreRewindJournalBody(body: StoredBody): AgentJournalItemBody {
let normalized: unknown = body
// A placeholder, not a failure anyone can act on, so it carries no fact.
diff --git a/src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx b/src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx
index 92b548ca3d60..56a292436e77 100644
--- a/src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx
@@ -7,6 +7,24 @@ import { NativeChatApprovalCard } from './NativeChatApprovalCard'
afterEach(cleanup)
describe('NativeChatApprovalCard', () => {
+ it('shows the detail of a subject this build cannot draw, so nothing is approved unseen', () => {
+ render(
+ {}}
+ />
+ )
+ expect(document.querySelector('[data-native-chat-approval-detail]')?.textContent).toContain(
+ '# Release'
+ )
+ })
+
it('a disabled card shows the request with nothing to press, and Escape cancels nothing', () => {
const onChoose = vi.fn()
const onCancel = vi.fn()
diff --git a/src/shared/agent-session-journal-body-admission.test.ts b/src/shared/agent-session-journal-body-admission.test.ts
index 7189326d89d3..82489e8850d3 100644
--- a/src/shared/agent-session-journal-body-admission.test.ts
+++ b/src/shared/agent-session-journal-body-admission.test.ts
@@ -5,7 +5,7 @@ import {
readAgentJournalItemBody,
readAgentJournalMessageBody
} from './agent-session-journal-body-admission'
-import { unknownDiscriminantArm } from './agent-session-journal-schemas'
+import { openDiscriminatedUnion } from './agent-session-journal-open-union'
const RESOLUTION = { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null }
const QUESTION_ENTRY = {
@@ -68,29 +68,37 @@ describe('a body this build reads', () => {
'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' }]
+ ['a turn state it does not know', { kind: 'turn', turnId: 't', state: 'handed-off' }],
+ ['a body kind it does not know', { kind: 'plan-card', steps: [{ text: 'by a newer build' }] }],
+ [
+ 'a plan subject kind it does not know',
+ { ...PLAN_APPROVAL, subject: { kind: 'diff', path: 'a.ts' } }
+ ]
])('is readable: %s', (_name, body) => {
expect(readAgentJournalItemBody(body)).toBe('readable')
})
})
describe("a value outside a closed set this build knows: a newer build's", () => {
+ // A turn's context usage is the closed set left in a body; the row reader drops it before this.
+ const NEWER_USAGE = { used: { kind: 'measured' } }
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' } }],
- ['one beside damage', { ...PLAN_APPROVAL, title: 5, subject: { kind: 'diff' } }]
+ [
+ 'a nested discriminant',
+ { kind: 'turn', turnId: 't', state: 'done', contextUsage: NEWER_USAGE }
+ ],
+ ['one beside damage', { kind: 'turn', turnId: 5, state: 'done', contextUsage: NEWER_USAGE }]
])('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([
+ const OpenBlock = openDiscriminatedUnion(
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(
diff --git a/src/shared/agent-session-journal-body-admission.ts b/src/shared/agent-session-journal-body-admission.ts
index 47ef889a9cf1..c5eecfb39f66 100644
--- a/src/shared/agent-session-journal-body-admission.ts
+++ b/src/shared/agent-session-journal-body-admission.ts
@@ -1,8 +1,10 @@
-// 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.
+// Reading a persisted body by the extension rule in agent-session-journal-schemas.ts: it parses
+// (a body or block of a kind this build does not know included), or it holds a value outside a
+// closed set this build knows (a newer build's), or it is damage.
import type { z } from 'zod'
import {
+ AGENT_JOURNAL_ITEM_BODY_KINDS,
AgentJournalItemBodySchema,
isAdmissibleAgentJournalMessageBody
} from './agent-session-journal-schemas'
@@ -27,18 +29,23 @@ export function readAgentJournalContent(
return parsed.error.issues.some(isOutsideClosedSet) ? 'unreadable' : 'malformed'
}
-/** The same for a submission's body, which only a user message may be. */
+/** The same for a submission's body, which only a user message may be. What was sent stays a
+ * closed set: a kind this build does not know is a newer build's, never sent again here. */
export function readAgentJournalMessageBody(body: unknown): AgentJournalContentVerdict {
const verdict = readAgentJournalItemBody(body)
- return verdict === 'readable' && !isAdmissibleAgentJournalMessageBody(body)
- ? 'malformed'
- : verdict
+ if (verdict !== 'readable' || isAdmissibleAgentJournalMessageBody(body)) {
+ return verdict
+ }
+ const kind = typeof body === 'object' && body !== null && 'kind' in body ? body.kind : undefined
+ return typeof kind === 'string' && kind !== '' && !AGENT_JOURNAL_ITEM_BODY_KINDS.has(kind)
+ ? 'unreadable'
+ : 'malformed'
}
/** A new string where a closed set of strings expects one of its own: the set's type, so never a
* 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. */
+ * (`openDiscriminatedUnion`), 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-open-union.ts b/src/shared/agent-session-journal-open-union.ts
new file mode 100644
index 000000000000..7d3d07abc9f8
--- /dev/null
+++ b/src/shared/agent-session-journal-open-union.ts
@@ -0,0 +1,27 @@
+// A discriminated union that reads a tag this build does not know instead of failing on it: the
+// journal's blocks, goal states, approval subjects and item bodies, so a newer build can add one.
+
+import { z } from 'zod'
+
+/** Tags a discriminated union knows, read off it so the two can never disagree. */
+export function knownTags(union: z.ZodDiscriminatedUnion): ReadonlySet {
+ const key = union.def.discriminator
+ return new Set(
+ union.options.flatMap((option) => {
+ const tag = option instanceof z.ZodObject ? option.shape[key] : undefined
+ return tag instanceof z.ZodLiteral ? [...tag.values].map(String) : []
+ })
+ )
+}
+
+/** A discriminated union a tag this build does not know still reads through: kept as-is, for a
+ * reader to skip. The catch-all aborts, so a known arm's own failure still reaches the reader
+ * (agent-session-journal-body-admission.ts). */
+export function openDiscriminatedUnion(known: T) {
+ const key = known.def.discriminator
+ const tags = knownTags(known)
+ const unknown = z
+ .object({ [key]: z.string().min(1) })
+ .refine((value) => !tags.has(value[key] ?? ''), { abort: true })
+ return z.union([known, unknown])
+}
diff --git a/src/shared/agent-session-journal-schemas.ts b/src/shared/agent-session-journal-schemas.ts
index 86ac93ffd362..75658a65f6c1 100644
--- a/src/shared/agent-session-journal-schemas.ts
+++ b/src/shared/agent-session-journal-schemas.ts
@@ -13,19 +13,22 @@
// 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).
//
-// 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 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, except that their rewind replaces an unknown
-// turn, tool or resolution state with a placeholder row and turns an unknown block into a text
-// block of its raw JSON (structured-rewind-journal-body.ts); a goal state is kept. History pages
-// carry no row `v`, so older clients see it too. Such a value must be safe for every older build.
+// A newer writer extends a body only by new keys, new kinds of an open union, new values in a
+// closed set, or a bumped row `v`, never by changing a field's type or a bound this build checks.
+// Open unions (a body's `kind`, a block's `type`, a goal's `state`, an approval subject's `kind`)
+// keep a value this build does not know as-is and nobody draws it; the chat stays writable. 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); a turn's context usage is dropped instead
+// (journal-row-unusable-annotations.ts). Any other failure is damage. A new value of an open
+// string (turn, resolution, tool or goal `state`) is read as-is too, except that an older build's
+// rewind replaces an unknown turn, tool or resolution state with a placeholder row and turns an
+// unknown block into a text block of its raw JSON (structured-rewind-journal-body.ts); an unknown
+// body kind is carried as-is. History pages carry no row `v`, so older clients see such a value
+// too. It must be safe for every older build.
import { z } from 'zod'
import { AgentSessionContextUsageSchema } from './agent-session-context-usage-schema'
+import { knownTags, openDiscriminatedUnion } from './agent-session-journal-open-union'
import type {
AgentJournalItemBody,
AgentJournalMessageItem,
@@ -54,21 +57,6 @@ const ToolMetadata = {
webSearchResults: z.array(z.object({ title: z.string(), url: z.string() })).optional()
}
-const KNOWN_BLOCK_TYPES = new Set([
- 'text',
- 'tool-call',
- 'tool-result',
- 'image-ref',
- 'subagent-group',
- '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()
@@ -88,7 +76,7 @@ const SubagentEntry = z.object({
/** Renderers select blocks by `type` equality and skip what they cannot draw,
* so an unknown block type stays admissible; a known type with a broken
* payload does not. */
-const Block = z.union([
+const Block = openDiscriminatedUnion(
z.discriminatedUnion('type', [
z.object({
type: z.literal('text'),
@@ -139,9 +127,8 @@ const Block = z.union([
startedAt: z.number().optional(),
settledAt: z.number().optional()
})
- ]),
- unknownDiscriminantArm('type', KNOWN_BLOCK_TYPES)
-])
+ ])
+)
const PromptOption = z.object({
id: z.string(),
@@ -180,11 +167,13 @@ const ApprovalMatchedAskRule = z.object({
ruleContent: z.string().optional()
})
-const ApprovalSubject = z.object({
- kind: z.literal('plan'),
- text: z.string().min(1),
- filePath: z.string().optional()
-})
+/** Open like blocks. An approval whose subject this build cannot draw shows its `detail`, which
+ * Orca's writers fill with the subject's text, so it is never approved unseen. */
+const ApprovalSubject = openDiscriminatedUnion(
+ z.discriminatedUnion('kind', [
+ z.object({ kind: z.literal('plan'), text: z.string().min(1), filePath: z.string().optional() })
+ ])
+)
const MessageBody = z.object({
kind: z.literal('message'),
@@ -206,13 +195,12 @@ const ThreadGoal = z.object({
})
/** Like blocks: an unknown `state` stays admissible, a known one with a broken payload does not. */
-const ThreadGoalState = z.union([
+const ThreadGoalState = openDiscriminatedUnion(
z.discriminatedUnion('state', [
z.object({ state: z.literal('set'), goal: ThreadGoal }),
z.object({ state: z.literal('cleared') })
- ]),
- unknownDiscriminantArm('state', new Set(['set', 'cleared']))
-])
+ ])
+)
/** Open like `state`: a kind, audience or refusal detail a newer host writes must not turn the row
* malformed; the fact reader is where an unplaceable one is dropped. */
@@ -222,7 +210,7 @@ const FailureFact = z.object({
refusal: z.object({ code: z.string().min(1), details: z.looseObject({}).optional() }).optional()
})
-export const AgentJournalItemBodySchema = z.discriminatedUnion('kind', [
+const KnownItemBody = z.discriminatedUnion('kind', [
MessageBody,
z.object({
kind: z.literal('tool-call'),
@@ -295,6 +283,10 @@ export const AgentJournalItemBodySchema = z.discriminatedUnion('kind', [
})
])
+/** Every body kind this build knows; a body of another kind is kept as-is and drawn by nobody. */
+export const AGENT_JOURNAL_ITEM_BODY_KINDS = knownTags(KnownItemBody)
+export const AgentJournalItemBodySchema = openDiscriminatedUnion(KnownItemBody)
+
/** Producer linkage as it rides a render item across the process boundary.
* `producerKind` stays an open string for the reason the header gives: a host
* that learns a third kind must not make its rows unreadable to this client. */
diff --git a/src/shared/structured-agent-session-unknown-item.test.ts b/src/shared/structured-agent-session-unknown-item.test.ts
new file mode 100644
index 000000000000..92ec054d1427
--- /dev/null
+++ b/src/shared/structured-agent-session-unknown-item.test.ts
@@ -0,0 +1,38 @@
+// An item of a kind this build does not know is kept and drawn by nobody: no client renders it,
+// and nothing the host or a client decides from items (a running turn, a pending prompt, status)
+// reads it.
+
+import { expect, it } from 'vitest'
+import { isAdmissibleAgentJournalItemBody } from './agent-session-journal-schemas'
+import type { AgentJournalRenderItem } from './agent-session-journal-types'
+import { activeStructuredAgentSessionTurnId } from './structured-agent-session-live-turn'
+import {
+ projectStructuredAgentSessionStatusSummary,
+ projectStructuredItemsToNativeChat
+} from './structured-agent-session-projection'
+
+function renderItem(sequence: number, body: unknown): AgentJournalRenderItem {
+ // Admitted through the catch-all, though the journal's body type names only known kinds.
+ if (!isAdmissibleAgentJournalItemBody(body)) {
+ throw new Error('the journal admits this body')
+ }
+ return { itemId: `item-${sequence}`, revision: 1, sequence, observedAt: sequence, body }
+}
+
+const ITEMS = [
+ renderItem(1, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Ship it' }] }),
+ renderItem(2, { kind: 'turn', turnId: 'turn-1', state: 'completed' }),
+ renderItem(3, { kind: 'plan-card', state: 'running', resolution: { state: 'pending' } })
+]
+
+it('draws nothing for an item of a kind this build does not know', () => {
+ expect(projectStructuredItemsToNativeChat(ITEMS).map((message) => message.id)).toEqual(['item-1'])
+})
+
+it('takes no turn, prompt or work from it', () => {
+ expect(activeStructuredAgentSessionTurnId(ITEMS)).toBeNull()
+ expect(projectStructuredAgentSessionStatusSummary(ITEMS)).toMatchObject({
+ status: 'idle',
+ latestPrompt: 'Ship it'
+ })
+})
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 fdc26bd0e8fe..aaa730740b60 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
@@ -35,9 +35,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 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.
+ * 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.
*/
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
@@ -87,9 +87,13 @@ const NEWER_NESTED_LITERAL = {
subject: { kind: 'diff', path: 'a.ts' }
}
-/** This build's journal of two items, then `body` as a newer build wrote it: closed. */
-async function journalWithNewerBody(
- body: Record = NEWER_BODY_KIND
+/** A row of a kind this build does not know: the journal opens read-only. */
+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
+ * `body`, or `row` itself: closed. */
+async function journalWithNewerRow(
+ row: Record = NEWER_ROW_KIND
): Promise<{ rows: string[]; newer: string }> {
const journal = await journals.open({ identity: IDENTITY, stateDirectory: directory })
await journal.appendItem(
@@ -105,14 +109,11 @@ async function journalWithNewerBody(
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
+ ...row
})
await journals.closeAll()
insertTestJournalRowJson(
@@ -142,7 +143,7 @@ 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()
+ await journalWithNewerRow()
const journal = await journals.open({ identity: IDENTITY, stateDirectory: directory })
expect(journal.isReadOnly).toBe(true)
const page = readAgentSessionHydrationPage(journal, 1)
@@ -173,7 +174,7 @@ describe('a chat a newer Orca saved, across versions', () => {
async () => {
rmSync(directory, { recursive: true, force: true })
directory = mkdtempSync(join(tmpdir(), 'orca-read-only-xv-'))
- await journalWithNewerBody()
+ await journalWithNewerRow()
const journal = await journals.open({ identity: IDENTITY, stateDirectory: directory })
expect(journal.isReadOnly).toBe(true)
const request = { sessionId: IDENTITY.sessionId, limit: 1 }
@@ -241,20 +242,26 @@ 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.
+ // 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.
it.each([
['body kind', NEWER_BODY_KIND],
- ['nested literal', NEWER_NESTED_LITERAL]
+ ['plan subject kind', NEWER_NESTED_LITERAL]
])(
- "this build keeps a newer build's %s and goes read-only; a build before it deletes it",
+ "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-'))
- const { rows, newer } = await journalWithNewerBody(body)
+ const { rows, newer } = await journalWithNewerRow({
+ kind: 'item',
+ itemId: 'codex:thread-1:turn-1:1',
+ revision: 1,
+ body
+ })
const reopened = await journals.open({ identity: IDENTITY, stateDirectory: directory })
- expect(reopened.isReadOnly).toBe(true)
+ expect(reopened.isReadOnly).toBe(false)
+ expect(reopened.repair).toEqual({ malformedRows: 0 })
await journals.closeAll()
expect(storedRows()).toEqual(rows)
From dbf8a2304f93ac5d0e8af7a66e0d8c0b3e800df1 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Fri, 2 Oct 2026 11:00:29 -0700
Subject: [PATCH 29/52] fix(native-chat): rewind narrows a retained body by its
kind before normalizing it
---
.../structured-rewind-journal-body.test.ts | 2 +-
.../structured-rewind-journal-body.ts | 18 ++++++++++++++++--
2 files changed, 17 insertions(+), 3 deletions(-)
diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.test.ts b/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.test.ts
index e126c36f52f5..c110a935aea8 100644
--- a/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.test.ts
+++ b/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.test.ts
@@ -26,7 +26,7 @@ describe('rewind recovery of newer durable records', () => {
// Retained by its place, never by its kind, so an updated Orca still has it after the rewind.
it('carries an item of a kind this build does not know as it was, and records it', () => {
- const body = { kind: 'plan-card', steps: [{ text: 'by a newer build' }] }
+ const body = { kind: 'plan-card', title: 'by a newer build' }
expect(restoreRewindJournalBody(body)).toBe(body)
expect(
AgentSessionRewindRecordSchema.safeParse({
diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.ts b/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.ts
index 5cd762870dd2..f20ed4879c61 100644
--- a/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.ts
+++ b/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.ts
@@ -1,4 +1,7 @@
-import { isAdmissibleAgentJournalItemBody } from '../../../shared/agent-session-journal-schemas'
+import {
+ AGENT_JOURNAL_ITEM_BODY_KINDS,
+ isAdmissibleAgentJournalItemBody
+} from '../../../shared/agent-session-journal-schemas'
import {
AGENT_JOURNAL_TURN_LIFECYCLE_STATES,
AGENT_JOURNAL_TURN_OUTCOMES,
@@ -9,10 +12,21 @@ import { NATIVE_CHAT_ROLES } from '../../../shared/native-chat-types'
type StoredBody = AgentSessionRewindRecord['retained'][number]['body']
+function hasKnownKind(body: StoredBody): body is AgentJournalItemBody {
+ return AGENT_JOURNAL_ITEM_BODY_KINDS.has(body.kind)
+}
+
/** A row this build cannot place stays visible as a row, never invented turn or prompt state —
* and never as its stored JSON, which is Orca's record, not something a person reads. An item of
* a kind this build does not know is carried as it was: kept by its place, as every other row. */
-export function restoreRewindJournalBody(body: StoredBody): AgentJournalItemBody {
+export function restoreRewindJournalBody(stored: StoredBody): AgentJournalItemBody {
+ if (!hasKnownKind(stored)) {
+ if (!isAdmissibleAgentJournalItemBody(stored)) {
+ throw new Error('agent_session_rewind:invalid-retained-body')
+ }
+ return stored
+ }
+ const body = stored
let normalized: unknown = body
// A placeholder, not a failure anyone can act on, so it carries no fact.
const fallback = () => ({
From c25d7cd14c9779352e579a5c351d3a7b16e39d8d Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Fri, 2 Oct 2026 11:07:43 -0700
Subject: [PATCH 30/52] fix(native-chat): one chat that cannot open no longer
stops the startup restore of the rest
---
...ured-agent-session-restart-restore.test.ts | 37 +++++++++++++++++++
...tructured-agent-session-restart-restore.ts | 11 +++++-
2 files changed, 47 insertions(+), 1 deletion(-)
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts
index 441d4e80626d..3488f5ade181 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts
@@ -219,6 +219,43 @@ describe('restart journal restoration', () => {
})
})
+ it('opens every other chat when one chat cannot be opened, and the pass still settles', async () => {
+ restoreRead.mockImplementation(async (_deps, sessionId: string) => {
+ if (sessionId === 'session-0') {
+ throw new Error('this chat cannot be opened')
+ }
+ return { session: { journal: {}, params: {}, child: null, sessionId }, reset: null }
+ })
+ const readable: string[] = []
+ const log = recordingStructuredAgentSessionLogger()
+ const records = Array.from(
+ { length: 12 },
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the restore reads only the record's session id here.
+ (_, index) => ({ sessionId: `session-${index}` }) as AgentSessionRecord
+ )
+
+ await expect(
+ restoreStructuredAgentSessionsOnRestart({
+ openDeps: { ...NO_OPEN_DEPS, logger: log.logger },
+ records,
+ reconcile: async () => true,
+ resolveRecovery: async () => true,
+ serialize: async (_sessionId, task) => task(),
+ hasSession: () => false,
+ onReadable: (sessionId) => {
+ readable.push(sessionId)
+ }
+ })
+ ).resolves.toBeUndefined()
+
+ expect(readable).toEqual(records.slice(1).map((record) => record.sessionId))
+ expect(log.entries).toEqual([
+ expect.objectContaining({
+ fields: expect.objectContaining({ scope: 'restart-restore', sessionId: 'session-0' })
+ })
+ ])
+ })
+
it('does not settle again when a second restore finds the session already open', async () => {
restoreRead.mockResolvedValue({
session: { journal: {}, params: {}, child: null },
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts
index 29ea17845d8a..eed6ff7d54dc 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts
@@ -92,6 +92,15 @@ export async function restoreStructuredAgentSessionsOnRestart(
await mapWithConcurrency(input.records, JOURNAL_RESTORE_CONCURRENCY, async ({ sessionId }) => {
// A journal open is synchronous SQLite: without a macrotask per chat the restore is one long task.
await yieldToEventLoop()
- await restoreOneStructuredAgentSessionRead(input, sessionId, settleLeases)
+ // One chat that cannot open must not keep the rest from restoring; a read of it still refuses.
+ await restoreOneStructuredAgentSessionRead(input, sessionId, settleLeases).catch(
+ (error: unknown) => {
+ input.openDeps.logger.warn('restoring a chat at startup failed', {
+ scope: 'restart-restore',
+ sessionId,
+ error
+ })
+ }
+ )
})
}
From fe3a82a3e9cb6fe053f39a7b269efbe1bcb0c54a Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Fri, 2 Oct 2026 11:27:27 -0700
Subject: [PATCH 31/52] fix(native-chat): a damaged chat fails to load and
nothing is deleted
A row this build cannot parse, a gap, or an epoch without its first row used to
be repaired on open: every row from the damage on was deleted and a rebuild from
the provider transcript was attempted. The open now fails through one refusal
(failLoadOnJournalDamage, journalCorrupt), every row stays, and a damaged
per-chat file is kept whole and never copied. A newer build's row still wins
and keeps the chat read-only. The body classifier for closed-set values, the
repair marker and disclosure, and journal recovery from the transcript go.
---
config/reliability-gates.jsonc | 38 +-
src/main/codex/codex-close-turn-end.test.ts | 1 -
.../codex-requested-close-turn-timing.test.ts | 1 -
.../codex/codex-structured-session-acquire.ts | 1 -
...ructured-session-adapter-lifecycle.test.ts | 8 +-
.../codex-structured-session-adapter.test.ts | 12 -
.../codex/codex-structured-session-adapter.ts | 9 +-
.../codex-structured-session-close.test.ts | 1 -
...structured-session-options-catalog.test.ts | 1 -
.../codex-structured-session-options.test.ts | 2 -
.../codex/codex-structured-session-state.ts | 1 -
.../codex/codex-structured-thread-facts.ts | 7 -
.../codex-structured-thread-open.test.ts | 1 -
.../codex/codex-structured-thread-open.ts | 5 +-
.../journal-closed-sets.test.ts | 11 +-
.../journal-corruption-repair.test.ts | 302 ---------
.../journal-damage.test.ts | 171 ++++++
.../journal-database-schema.ts | 4 +-
.../journal-database.test.ts | 6 +-
.../journal-epoch-replacement.ts | 7 +-
.../journal-epoch-rollover.ts | 9 +-
.../journal-file-format-remnant.test.ts | 31 +-
.../journal-legacy-import.test.ts | 3 +-
.../journal-legacy-import.ts | 2 +-
.../journal-open-failure.ts | 24 +-
.../agent-session-journal/journal-open.ts | 127 ++--
.../journal-per-session-import.test.ts | 34 +-
.../journal-per-session-import.ts | 51 +-
.../journal-per-session-source.ts | 20 +-
.../journal-repair-disclosure.ts | 32 -
.../journal-repair-marker.ts | 65 --
.../journal-row-context-usage.test.ts | 2 -
.../journal-row-schema.ts | 12 +-
.../journal-row-table.ts | 12 -
.../journal-stop-and-resume-rows.test.ts | 20 +-
.../journal-store-collaborators.ts | 4 -
.../journal-store-open.ts | 54 +-
.../journal-store-restore.ts | 38 +-
.../journal-store-schema.test.ts | 43 +-
.../journal-store.test.ts | 14 +-
.../agent-session-journal/journal-store.ts | 19 -
.../journal-streaming-replay.test.ts | 18 +-
.../journal-unknown-content.test.ts | 43 +-
.../journal-unknown-row-kind.test.ts | 55 +-
.../agent-session-journal-recovery.test.ts | 574 ------------------
.../agent-session-journal-recovery.ts | 99 ---
...-agent-session-acquisition-options.test.ts | 23 +-
...structured-agent-session-adapter-router.ts | 3 -
.../structured-agent-session-adapter.ts | 3 -
...-agent-session-attach-test-conversation.ts | 6 +-
...-agent-session-command-publication.test.ts | 2 +-
...uctured-agent-session-conversation-open.ts | 26 +-
.../structured-agent-session-host-delivery.ts | 7 +-
.../structured-agent-session-host.ts | 7 -
...ctured-agent-session-refusal-retry.test.ts | 11 +-
...agent-session-settled-attach-retry.test.ts | 13 +-
...ructured-agent-session-subscribers.test.ts | 9 +-
.../structured-agent-session-subscribers.ts | 30 +-
...ured-agent-session-turn-completion-feed.ts | 2 +-
...d-agent-session-integration-replay.test.ts | 47 +-
...ent-session-journal-body-admission.test.ts | 36 +-
.../agent-session-journal-body-admission.ts | 59 +-
src/shared/agent-session-journal-schemas.ts | 25 +-
.../structured-agent-session-read-refusal.ts | 6 +-
.../agent-session-read-only.unit.test.ts | 1 -
...-session-stop-event-downgrade.unit.test.ts | 1 -
66 files changed, 559 insertions(+), 1752 deletions(-)
delete mode 100644 src/main/native-chat/agent-session-journal/journal-corruption-repair.test.ts
create mode 100644 src/main/native-chat/agent-session-journal/journal-damage.test.ts
delete mode 100644 src/main/native-chat/agent-session-journal/journal-repair-disclosure.ts
delete mode 100644 src/main/native-chat/agent-session-journal/journal-repair-marker.ts
delete mode 100644 src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts
delete mode 100644 src/main/native-chat/agent-session-wire/agent-session-journal-recovery.ts
diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc
index 5b4c135c1da4..002cf9ab4782 100644
--- a/config/reliability-gates.jsonc
+++ b/config/reliability-gates.jsonc
@@ -639,7 +639,7 @@
"protection": "partial",
"owner": "agent-session-runtime",
"layer": "runtime-unit",
- "surfaces": ["structured chat journal replay", "structured chat recovery"],
+ "surfaces": ["structured chat journal replay", "structured chat damaged history"],
"platforms": ["macos", "linux", "windows"],
"providers": ["local", "remote-runtime"],
"coveredPlatforms": ["macos"],
@@ -648,15 +648,15 @@
"motivatingLinks": [
"https://github.com/stablyai/orca/blob/main/src/main/native-chat/agent-session-journal/journal-open.ts"
],
- "invariant": "Replay preserves latest revisions, original item order, fences, aliases, submissions, repair precedence, read-only schema latching and cursor cleanup while retaining live items rather than all historical bodies.",
- "oracle": "Replay 2,048 16 KiB revisions into one latest item with less than 8 MiB sampled live heap growth; preserve prefix and future-schema latching after a gap, malformed suffix repair precedence, and hold no SQLite read snapshot across reduction (a mid-replay checkpoint is not busy). Existing journal and subscriber tests cover replayed content and recovery.",
+ "invariant": "Replay preserves latest revisions, original item order, fences, aliases, submissions, read-only schema latching over damage and cursor cleanup while retaining live items rather than all historical bodies; damage fails the load and deletes nothing.",
+ "oracle": "Replay 2,048 16 KiB revisions into one latest item with less than 8 MiB sampled live heap growth; latch future-schema rows read past a gap, name the first damage, fail a damaged load with every row kept, and hold no SQLite read snapshot across reduction (a mid-replay checkpoint is not busy). Existing journal and subscriber tests cover replayed content.",
"commands": [
- "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 src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts",
- "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/main/native-chat/agent-session-journal/journal-streaming-replay.test.ts src/main/native-chat/agent-session-journal/journal-corruption-repair.test.ts"
+ "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",
+ "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/main/native-chat/agent-session-journal/journal-streaming-replay.test.ts src/main/native-chat/agent-session-journal/journal-damage.test.ts"
],
"testFiles": [
"src/main/native-chat/agent-session-journal/journal-streaming-replay.test.ts",
- "src/main/native-chat/agent-session-journal/journal-corruption-repair.test.ts"
+ "src/main/native-chat/agent-session-journal/journal-damage.test.ts"
],
"assertionRefs": [
{
@@ -664,9 +664,16 @@
"assertions": [
"releases superseded revision bodies while reducing a long journal",
"holds no read snapshot while reducing, so a checkpoint can pass mid-replay",
- "keeps the prefix but latches read-only for a future row beyond a gap",
- "keeps gap repair precedence when a later row is malformed",
- "rejects an unanchored prefix before a later gap"
+ "reads past a gap to a future row, which latches read-only with no damage named",
+ "names the first damage when a later row is malformed too",
+ "names a missing epoch row before a later gap"
+ ]
+ },
+ {
+ "file": "src/main/native-chat/agent-session-journal/journal-damage.test.ts",
+ "assertions": [
+ "fails to load and keeps every row: %s",
+ "reads past damage to a newer build's row, which keeps the chat read-only instead"
]
}
],
@@ -675,10 +682,19 @@
"date": "2026-09-11",
"runner": "local",
"platform": "macos",
- "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 src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts",
+ "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": 7.71,
- "summary": "245 tests passed across 22 files. Retained-heap oracle fails on baseline at 68.6 MB and passes under 8 MiB with streaming; gap/schema and cursor-cleanup assertions passed."
+ "summary": "245 tests passed across 22 files. Retained-heap oracle fails on baseline at 68.6 MB and passes under 8 MiB with streaming; gap/schema and cursor-cleanup assertions passed. Recorded before the read-time repair was removed; its command then also named agent-session-journal-recovery.test.ts, which went with it."
+ },
+ {
+ "date": "2026-10-02",
+ "runner": "local",
+ "platform": "macos",
+ "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."
}
],
"runtimeBudget": {
diff --git a/src/main/codex/codex-close-turn-end.test.ts b/src/main/codex/codex-close-turn-end.test.ts
index 1e3475301bf0..98ae89a7347e 100644
--- a/src/main/codex/codex-close-turn-end.test.ts
+++ b/src/main/codex/codex-close-turn-end.test.ts
@@ -62,7 +62,6 @@ function sessionWithRunningTurn() {
fence: 7,
acquisitionGeneration: 'generation-1',
threadId: 'thread-1',
- historyPath: null,
prompts: new CodexPromptRegistry(),
options: new Map(),
reportedOptions: {},
diff --git a/src/main/codex/codex-requested-close-turn-timing.test.ts b/src/main/codex/codex-requested-close-turn-timing.test.ts
index a7baf6a14b59..454c3ccd4a01 100644
--- a/src/main/codex/codex-requested-close-turn-timing.test.ts
+++ b/src/main/codex/codex-requested-close-turn-timing.test.ts
@@ -67,7 +67,6 @@ describe('requested-close durable turn timing', () => {
fence: 7,
acquisitionGeneration: 'generation-1',
threadId: 'thread-1',
- historyPath: null,
prompts: new CodexPromptRegistry(),
options: new Map(),
reportedOptions: {},
diff --git a/src/main/codex/codex-structured-session-acquire.ts b/src/main/codex/codex-structured-session-acquire.ts
index 37c34a4bf3a1..ae965795f7dd 100644
--- a/src/main/codex/codex-structured-session-acquire.ts
+++ b/src/main/codex/codex-structured-session-acquire.ts
@@ -234,7 +234,6 @@ export async function acquireCodexStructuredSession(input: {
connection,
...codexSessionLifecycle(acquireInput.fence, acquired.acquisitionGeneration as string),
threadId: opened.threadId,
- historyPath: opened.historyPath,
historyMode: opened.historyMode,
activeTurnIds: new Set(),
prompts: acquisition.prompts,
diff --git a/src/main/codex/codex-structured-session-adapter-lifecycle.test.ts b/src/main/codex/codex-structured-session-adapter-lifecycle.test.ts
index e3211f320e85..2e553cb9e0b8 100644
--- a/src/main/codex/codex-structured-session-adapter-lifecycle.test.ts
+++ b/src/main/codex/codex-structured-session-adapter-lifecycle.test.ts
@@ -188,9 +188,7 @@ describe('CodexStructuredSessionAdapter lifecycle', () => {
fence: 7
})
).rejects.toThrow('no live codex app-server')
- expect(await adapter.historyFilePath({ identity: identityFor('session-1') })).toBe(
- '/rollouts/abc.jsonl'
- )
+ expect(adapter.backgroundTaskStops('session-1')).toBeDefined()
await expect(adapter.closeSession('session-1')).resolves.toBe(false)
expect(events.filter((event) => event.type === 'ended')).toHaveLength(1)
})
@@ -205,9 +203,7 @@ describe('CodexStructuredSessionAdapter lifecycle', () => {
codex.connections[0].handlers.onExit?.(new Error('the superseded child died'))
expect(events.filter((event) => event.type === 'ended')).toHaveLength(endedBeforeStaleExit)
- expect(await adapter.historyFilePath({ identity: identityFor('session-1') })).toBe(
- '/rollouts/abc.jsonl'
- )
+ expect(adapter.backgroundTaskStops('session-1')).toBeDefined()
})
it('ignores Codex traffic that arrives after the session is gone', async () => {
diff --git a/src/main/codex/codex-structured-session-adapter.test.ts b/src/main/codex/codex-structured-session-adapter.test.ts
index 70b2ecd7147a..8cc6061cd19f 100644
--- a/src/main/codex/codex-structured-session-adapter.test.ts
+++ b/src/main/codex/codex-structured-session-adapter.test.ts
@@ -288,18 +288,6 @@ describe('CodexStructuredSessionAdapter.acquire', () => {
expect(codex.connections).toHaveLength(0)
})
- it('reports the rollout path Codex named, and null when it named none', async () => {
- const withPath = fakeCodex()
- const adapter = await acquired(withPath)
- expect(await adapter.historyFilePath({ identity: identityFor('session-1') })).toBe(
- '/rollouts/abc.jsonl'
- )
-
- const withoutPath = fakeCodex({ 'thread/start': () => ({ thread: { id: THREAD_ID } }) })
- const bare = await acquired(withoutPath)
- expect(await bare.historyFilePath({ identity: identityFor('session-1') })).toBeNull()
- })
-
it('lets closeAll cancel and reap an acquisition still opening', async () => {
const codex = fakeCodex()
let releaseOpen = (): void => {}
diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts
index 13a687a72cdc..6f67b68f8e63 100644
--- a/src/main/codex/codex-structured-session-adapter.ts
+++ b/src/main/codex/codex-structured-session-adapter.ts
@@ -1,8 +1,5 @@
import * as codexRewind from './codex-structured-rewind'
-import type {
- AgentJournalMessageItem,
- AgentSessionJournalIdentity
-} from '../../shared/agent-session-journal-types'
+import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types'
import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire'
import { isCodexAppServerRequestError } from './codex-app-server-connection'
import type {
@@ -284,10 +281,6 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap
readOptions = (input: { sessionId: string; fence: number }) =>
readLiveCodexSessionOptions(this.session(input.sessionId), this.deps.requestTimeoutMs)
- historyFilePath = async (input: {
- identity: AgentSessionJournalIdentity
- }): Promise => this.sessions.get(input.identity.sessionId)?.historyPath ?? null
-
closeSession = (sessionId: string): Promise => this.teardown.close(sessionId)
forceCloseSession = (sessionId: string): Promise => this.teardown.forceClose(sessionId)
disposeSession = (sessionId: string): Promise => this.teardown.close(sessionId)
diff --git a/src/main/codex/codex-structured-session-close.test.ts b/src/main/codex/codex-structured-session-close.test.ts
index 9d80a0fa9829..4906b45a529a 100644
--- a/src/main/codex/codex-structured-session-close.test.ts
+++ b/src/main/codex/codex-structured-session-close.test.ts
@@ -100,7 +100,6 @@ describe('Codex structured session close lifecycle', () => {
fence: 7,
acquisitionGeneration: 'generation-1',
threadId: THREAD,
- historyPath: null,
prompts,
options: new Map(),
reportedOptions: {},
diff --git a/src/main/codex/codex-structured-session-options-catalog.test.ts b/src/main/codex/codex-structured-session-options-catalog.test.ts
index aac9e2946cf7..ad4935e5d4b4 100644
--- a/src/main/codex/codex-structured-session-options-catalog.test.ts
+++ b/src/main/codex/codex-structured-session-options-catalog.test.ts
@@ -57,7 +57,6 @@ function storeSession(
fence: 1,
acquisitionGeneration: 'generation-1',
threadId: 'thread-1',
- historyPath: null,
prompts: new CodexAcquisitionWindow().prompts,
options: new Map(),
reportedOptions: { model: 'gpt-live', effort: 'high' },
diff --git a/src/main/codex/codex-structured-session-options.test.ts b/src/main/codex/codex-structured-session-options.test.ts
index d99ecc0305e1..4df25a9477ca 100644
--- a/src/main/codex/codex-structured-session-options.test.ts
+++ b/src/main/codex/codex-structured-session-options.test.ts
@@ -31,7 +31,6 @@ function optionSession(request: CodexAppServerConnection['request']): CodexSessi
fence: 1,
acquisitionGeneration: 'generation-1',
threadId: 'thread-1',
- historyPath: null,
prompts: new CodexAcquisitionWindow().prompts,
options: new Map(),
reportedOptions: { model: 'gpt-live', effort: 'high' },
@@ -137,7 +136,6 @@ describe('structured Codex session options', () => {
expect(
reportedCodexThreadOptions({
threadId: 'thread-1',
- historyPath: null,
model: 'gpt-live',
effort: 'high'
})
diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts
index a47cb5a78fe2..6ddc11ac1f60 100644
--- a/src/main/codex/codex-structured-session-state.ts
+++ b/src/main/codex/codex-structured-session-state.ts
@@ -109,7 +109,6 @@ export type CodexSession = {
fence: number
acquisitionGeneration: string
threadId: string
- historyPath: string | null
historyMode?: 'legacy' | 'paginated'
/** Primary-thread turns Codex reported started and not yet ended, as read off the wire: what
* rewind waits out and what a Stop naming no turn interrupts when the journal shows none. */
diff --git a/src/main/codex/codex-structured-thread-facts.ts b/src/main/codex/codex-structured-thread-facts.ts
index f9f2b78b7f9b..96359286b676 100644
--- a/src/main/codex/codex-structured-thread-facts.ts
+++ b/src/main/codex/codex-structured-thread-facts.ts
@@ -21,13 +21,6 @@ export function readCodexThreadId(payload: unknown): string | null {
return nonEmptyString(record(root.thread)?.id) ?? nonEmptyString(root.threadId)
}
-/** Rollout file for the thread, when Codex reports one. Journal recovery reads
- * it; a null just falls back to the existing session-file resolver. */
-export function readCodexThreadPath(payload: unknown): string | null {
- const root = record(payload)
- return root ? nonEmptyString(record(root.thread)?.path) : null
-}
-
/** `turn/start` responses carry `turn.id`; `turn/started` notifications carry
* the same under `turn`, and older builds put `turnId` on the envelope. */
export function readCodexTurnId(payload: unknown): string | null {
diff --git a/src/main/codex/codex-structured-thread-open.test.ts b/src/main/codex/codex-structured-thread-open.test.ts
index fede869a83c9..efd32b666cd5 100644
--- a/src/main/codex/codex-structured-thread-open.test.ts
+++ b/src/main/codex/codex-structured-thread-open.test.ts
@@ -95,7 +95,6 @@ describe('openCodexThread', () => {
).resolves.toEqual({
threadId: 'thread-standard',
thread: { id: 'thread-standard' },
- historyPath: null,
serviceTier: null
})
})
diff --git a/src/main/codex/codex-structured-thread-open.ts b/src/main/codex/codex-structured-thread-open.ts
index 0f8b530d6240..19a3980cb208 100644
--- a/src/main/codex/codex-structured-thread-open.ts
+++ b/src/main/codex/codex-structured-thread-open.ts
@@ -10,15 +10,13 @@ import {
type CodexAppServerConnection
} from './codex-app-server-connection'
import type { CodexStructuredPermissionPolicy } from './codex-structured-permission-policy'
-import { readCodexThreadId, readCodexThreadPath } from './codex-structured-thread-facts'
+import { readCodexThreadId } from './codex-structured-thread-facts'
export type CodexOpenedThread = {
threadId: string
/** The unsaved thread this new one was started in place of. */
supersededThreadId?: string
thread?: Record
- /** Rollout file Codex named, when it named one. */
- historyPath: string | null
historyMode?: 'legacy' | 'paginated'
model?: string
effort?: string
@@ -145,7 +143,6 @@ export async function openCodexThread(
threadId,
...(supersededThreadId === undefined ? {} : { supersededThreadId }),
thread,
- historyPath: readCodexThreadPath(opened),
...(thread.historyMode === 'legacy' || thread.historyMode === 'paginated'
? { historyMode: thread.historyMode }
: {}),
diff --git a/src/main/native-chat/agent-session-journal/journal-closed-sets.test.ts b/src/main/native-chat/agent-session-journal/journal-closed-sets.test.ts
index 56201fa0f7a2..f982b9181924 100644
--- a/src/main/native-chat/agent-session-journal/journal-closed-sets.test.ts
+++ b/src/main/native-chat/agent-session-journal/journal-closed-sets.test.ts
@@ -1,7 +1,8 @@
// Every closed set a journal row is read against: a row kind, a lifecycle mutation kind, and each
-// enum, literal or discriminant in a body. A value outside one reads as a newer build's (a turn's
-// context usage is dropped instead), so older builds go read-only; this snapshot sits beside the
-// row version so that a change to either is deliberate.
+// enum, literal or discriminant in a body. A new row or mutation kind makes older builds go
+// read-only; a new value in a body's closed set is damage to them, and the chat fails to load
+// (a turn's context usage is dropped instead). This snapshot sits beside the row version so that
+// a change to either is deliberate.
import { expect, it } from 'vitest'
import { z } from 'zod'
@@ -101,7 +102,7 @@ const SNAPSHOT = {
it('changes no closed set of a journal row without the row version', () => {
expect(
journalClosedSets(),
- 'A new closed-set value makes older builds go read-only: ship the reader first or bump v, ' +
- 'then update this snapshot.'
+ 'A new closed-set value makes older builds go read-only or fail to load the chat: ship the ' +
+ 'reader first or bump v, then update this snapshot.'
).toEqual(SNAPSHOT)
})
diff --git a/src/main/native-chat/agent-session-journal/journal-corruption-repair.test.ts b/src/main/native-chat/agent-session-journal/journal-corruption-repair.test.ts
deleted file mode 100644
index a5e7506d1bdc..000000000000
--- a/src/main/native-chat/agent-session-journal/journal-corruption-repair.test.ts
+++ /dev/null
@@ -1,302 +0,0 @@
-import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types'
-// A repair drops what it cannot replay, and says so.
-//
-// Two things make a suffix unreplayable: a row this build cannot parse, and a
-// sequence gap that makes every later row unanchored. The rejected suffix is
-// DELETED; the load reports `corrupt`, and recovery rebuilds the epoch from
-// provider history. Every case here asserts the same two halves: the live epoch
-// holds only the replayable prefix, AND the epoch stays anchored so nothing
-// replays a repaired journal as a clean timeline.
-
-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 type {
- AgentJournalItemBody,
- AgentJournalItemIdentity,
- AgentSessionJournalIdentity
-} from '../../../shared/agent-session-journal-types'
-import type Database from '../../sqlite/sync-database'
-import { parseJournalRow, type JournalRow } from './journal-row-schema'
-import type { openAgentSessionJournal } from './journal-store-factory'
-import {
- createTrackedJournalOpener,
- openTestJournalHostDatabase,
- loadTestJournal,
- liveTestJournalRows,
- updateTestJournalRowJson,
- deleteTestJournalRow
-} from './journal-host-database-test-support'
-
-const IDENTITY: AgentSessionJournalIdentity = {
- sessionId: 'session-1',
- workspaceId: 'ws-1',
- hostId: 'host-1',
- agent: 'codex',
- providerHandle: { kind: 'codex', threadId: 'thread-1' }
-}
-
-let root: string
-let clock = 1_000
-const journals = createTrackedJournalOpener()
-
-function tick(): number {
- clock += 1
- return clock
-}
-
-function item(ordinal: number): AgentJournalItemIdentity {
- return { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal }
-}
-
-function body(value: string): AgentJournalItemBody {
- return { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: value }] }
-}
-
-function open(overrides: Partial[0]> = {}) {
- return journals.open({
- identity: IDENTITY,
- stateDirectory: root,
- now: tick,
- mintEpoch: () => `epoch-${clock}`,
- ...overrides
- })
-}
-
-async function withJournalDatabase(run: (db: Database.Database) => void): Promise {
- const opened = openTestJournalHostDatabase(root)
- try {
- run(opened.db)
- } finally {
- opened.close()
- }
-}
-
-/** The row replay anchors on, parsed exactly as replay parses it. */
-function firstLiveRow(): Promise {
- let row: JournalRow | null = null
- return withJournalDatabase((db) => {
- const stored = liveTestJournalRows(db, IDENTITY.sessionId)[0]
- const parsed = stored ? parseJournalRow(stored.rowJson) : null
- row = parsed?.ok ? parsed.row : null
- }).then(() => row)
-}
-
-function liveSequences(): Promise {
- let sequences: number[] = []
- return withJournalDatabase((db) => {
- sequences = liveTestJournalRows(db, IDENTITY.sessionId).map((row) => row.seq)
- }).then(() => sequences)
-}
-
-beforeEach(async () => {
- root = await mkdtemp(join(tmpdir(), 'orca-journal-repair-'))
- clock = 1_000
-})
-
-afterEach(async () => {
- await journals.closeAll()
- await rm(root, { recursive: true, force: true })
-})
-
-describe('a malformed row', () => {
- it('keeps the readable prefix live and drops the rest of the epoch', async () => {
- const journal = await open()
- await journal.appendItem(item(0), body('readable'), {
- fence: 1,
- turnScope: AGENT_JOURNAL_THREAD_SCOPE
- })
- await journal.appendItem(item(1), body('unreadable'), {
- fence: 1,
- turnScope: AGENT_JOURNAL_THREAD_SCOPE
- })
- await journal.appendItem(item(2), body('after the fault'), {
- fence: 1,
- turnScope: AGENT_JOURNAL_THREAD_SCOPE
- })
- await journal.close()
- await withJournalDatabase((db) => {
- updateTestJournalRowJson(db, IDENTITY.sessionId, 3, '{"not":"a row"}')
- })
-
- const reopened = await open()
- expect(reopened.repair.malformedRows).toBe(1)
- // 1..2 is the surviving prefix; 3 is the disclosure the repair appends.
- expect(await liveSequences()).toEqual([1, 2, 3])
- })
-
- it('discloses the line it could not read', async () => {
- const journal = await open()
- await journal.appendItem(item(0), body('readable'), {
- fence: 1,
- turnScope: AGENT_JOURNAL_THREAD_SCOPE
- })
- await journal.appendItem(item(1), body('later'), {
- fence: 1,
- turnScope: AGENT_JOURNAL_THREAD_SCOPE
- })
- await journal.close()
- await withJournalDatabase((db) => {
- updateTestJournalRowJson(db, IDENTITY.sessionId, 2, '}{')
- })
-
- const reopened = await open()
- const disclosure = reopened
- .snapshot()
- .items.map((entry) => entry.body)
- .find((entry) => entry.kind === 'status')
- expect(disclosure).toMatchObject({ kind: 'status' })
- expect(disclosure && 'text' in disclosure ? disclosure.text : '').toContain(
- '1 journal line could not be read'
- )
- })
-})
-
-describe('a sequence gap', () => {
- it('drops every row after the hole and reports the epoch corrupt', async () => {
- const journal = await open()
- for (let ordinal = 0; ordinal < 5; ordinal += 1) {
- await journal.appendItem(item(ordinal), body(`m${ordinal}`), {
- fence: 1,
- turnScope: AGENT_JOURNAL_THREAD_SCOPE
- })
- }
- await journal.close()
- // Sequence 1 is the epoch row, so the items occupy 2..6. Removing 4 leaves
- // 5 and 6 valid but unanchored.
- await withJournalDatabase((db) => {
- deleteTestJournalRow(db, IDENTITY.sessionId, 4)
- })
-
- const reopened = await open()
- expect(await liveSequences()).toEqual([1, 2, 3])
- expect(reopened.repair).toEqual({ malformedRows: 0 })
- expect(reopened.snapshot().items.map((entry) => entry.body)).toEqual([body('m0'), body('m1')])
- })
-
- // The prefix survives, so there is no emptied epoch to re-anchor and — a gap
- // costing no malformed row — no disclosure either. Without a durable marker
- // the next probe reads a contiguous anchored prefix and calls it clean, and
- // the rows the repair deleted are never asked for again.
- it('still reports corrupt on the next probe, with the deleted suffix unrebuilt', async () => {
- const journal = await open()
- for (let ordinal = 0; ordinal < 5; ordinal += 1) {
- await journal.appendItem(item(ordinal), body(`m${ordinal}`), {
- fence: 1,
- turnScope: AGENT_JOURNAL_THREAD_SCOPE
- })
- }
- await journal.close()
- await withJournalDatabase((db) => {
- deleteTestJournalRow(db, IDENTITY.sessionId, 4)
- })
-
- const repaired = await open()
- await repaired.close()
- expect(loadTestJournal(root, IDENTITY.sessionId)).toMatchObject({ corrupt: true })
-
- // Same policy the emptied-epoch repair takes: a session that writes into the
- // epoch owns it, and a later import must not replace rows the user has seen.
- const writable = await open()
- await writable.appendItem(item(9), body('typed after the repair'), {
- fence: 1,
- turnScope: AGENT_JOURNAL_THREAD_SCOPE
- })
- await writable.close()
- expect(loadTestJournal(root, IDENTITY.sessionId)).toMatchObject({ corrupt: false })
- })
-
- // The disclosure is the repair talking about itself, not the session writing:
- // counting it as content would retire the marker the instant it was raised.
- it('is not settled by the repair disclosure it appends for a malformed row', async () => {
- const journal = await open()
- for (let ordinal = 0; ordinal < 3; ordinal += 1) {
- await journal.appendItem(item(ordinal), body(`m${ordinal}`), {
- fence: 1,
- turnScope: AGENT_JOURNAL_THREAD_SCOPE
- })
- }
- await journal.close()
- await withJournalDatabase((db) => {
- updateTestJournalRowJson(db, IDENTITY.sessionId, 3, '}{')
- })
-
- const repaired = await open()
- expect(repaired.repair.malformedRows).toBe(1)
- await repaired.close()
- expect(loadTestJournal(root, IDENTITY.sessionId)).toMatchObject({ corrupt: true })
- })
-})
-
-describe('a missing epoch row', () => {
- // Sequence 1 is the anchor for the whole epoch. Validating from the first row
- // that HAPPENS to remain declares the leftovers contiguous, and replay then
- // renders a repaired journal as a clean timeline.
- it('rejects the whole surviving range rather than declaring it contiguous', async () => {
- const journal = await open()
- await journal.appendItem(item(0), body('anchor'), {
- fence: 1,
- turnScope: AGENT_JOURNAL_THREAD_SCOPE
- })
- await journal.appendSubmission({
- clientMessageId: 'client-message-1',
- payloadFingerprint: 'fingerprint-1',
- body: {
- kind: 'message',
- role: 'user',
- blocks: [{ type: 'text', text: 'the user typed this' }]
- },
- fence: 1
- })
- await journal.resolveDispatch({
- clientMessageId: 'client-message-1',
- state: 'accepted',
- providerIdentity: item(1),
- fence: 1
- })
- await journal.close()
- await withJournalDatabase((db) => {
- deleteTestJournalRow(db, IDENTITY.sessionId, 1)
- })
-
- const reopened = await open()
- expect(reopened.repair).toEqual({ malformedRows: 0 })
- expect(reopened.snapshot().items).toEqual([])
-
- // The epoch cannot be left row-less. An ordinary append would then take
- // sequence 1, and replay would call that non-epoch row a clean timeline.
- expect(await liveSequences()).toEqual([1])
- const anchor = await firstLiveRow()
- expect(anchor).toMatchObject({ kind: 'epoch', reason: 'unreconcilable_prefix' })
- })
-
- // The repair epoch is a placeholder for history it could not rebuild. Left
- // clean it would end automatic recovery: the provider transcript is never
- // consulted again and the dropped rows never come back.
- it('keeps asking for provider history until the epoch has content of its own', async () => {
- const journal = await open()
- await journal.appendItem(item(0), body('anchor'), {
- fence: 1,
- turnScope: AGENT_JOURNAL_THREAD_SCOPE
- })
- await journal.close()
- await withJournalDatabase((db) => {
- deleteTestJournalRow(db, IDENTITY.sessionId, 1)
- })
-
- const repaired = await open()
- await repaired.close()
- expect(loadTestJournal(root, IDENTITY.sessionId)).toMatchObject({ corrupt: true })
-
- // A session that writes into the epoch owns it: its own rows are not a
- // repair placeholder, and a later import must not replace them.
- const writable = await open()
- await writable.appendItem(item(1), body('typed after the repair'), {
- fence: 1,
- turnScope: AGENT_JOURNAL_THREAD_SCOPE
- })
- await writable.close()
- expect(loadTestJournal(root, IDENTITY.sessionId)).toMatchObject({ corrupt: false })
- })
-})
diff --git a/src/main/native-chat/agent-session-journal/journal-damage.test.ts b/src/main/native-chat/agent-session-journal/journal-damage.test.ts
new file mode 100644
index 000000000000..9d0ebe3d4d66
--- /dev/null
+++ b/src/main/native-chat/agent-session-journal/journal-damage.test.ts
@@ -0,0 +1,171 @@
+// Damage fails the chat's load, and nothing is deleted.
+//
+// A row this build cannot parse, a row numbered against its key, a gap in the sequence and an epoch
+// without its first row are each damage. The open refuses the chat as history that cannot be
+// loaded, and every stored row is where it was.
+
+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,
+ type AgentJournalItemBody,
+ type AgentJournalItemIdentity,
+ type AgentSessionJournalIdentity
+} from '../../../shared/agent-session-journal-types'
+import type Database from '../../sqlite/sync-database'
+import type { openAgentSessionJournal } from './journal-store-factory'
+import {
+ createTrackedJournalOpener,
+ openTestJournalHostDatabase,
+ loadTestJournal,
+ liveTestJournalRows,
+ updateTestJournalRowJson,
+ deleteTestJournalRow
+} from './journal-host-database-test-support'
+
+const IDENTITY: AgentSessionJournalIdentity = {
+ sessionId: 'session-1',
+ workspaceId: 'ws-1',
+ hostId: 'host-1',
+ agent: 'codex',
+ providerHandle: { kind: 'codex', threadId: 'thread-1' }
+}
+
+const UNLOADABLE = {
+ refusal: { code: 'agent_session_journal_unreadable', details: { reason: 'journalCorrupt' } }
+}
+
+let root: string
+let clock = 1_000
+const journals = createTrackedJournalOpener()
+
+function item(ordinal: number): AgentJournalItemIdentity {
+ return { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal }
+}
+
+function body(value: string): AgentJournalItemBody {
+ return { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: value }] }
+}
+
+function open(overrides: Partial[0]> = {}) {
+ return journals.open({
+ identity: IDENTITY,
+ stateDirectory: root,
+ now: () => (clock += 1),
+ mintEpoch: () => `epoch-${clock}`,
+ ...overrides
+ })
+}
+
+function withJournalDatabase(run: (db: Database.Database) => T): T {
+ const opened = openTestJournalHostDatabase(root)
+ try {
+ return run(opened.db)
+ } finally {
+ opened.close()
+ }
+}
+
+function storedRows(): { seq: number; rowJson: string }[] {
+ return withJournalDatabase((db) => liveTestJournalRows(db, IDENTITY.sessionId))
+}
+
+/** A chat of an epoch row and five messages: sequences 1 to 6. */
+async function writeChat(): Promise {
+ const journal = await open()
+ for (let ordinal = 0; ordinal < 5; ordinal += 1) {
+ await journal.appendItem(item(ordinal), body(`m${ordinal}`), {
+ fence: 1,
+ turnScope: AGENT_JOURNAL_THREAD_SCOPE
+ })
+ }
+ await journal.close()
+}
+
+beforeEach(async () => {
+ root = await mkdtemp(join(tmpdir(), 'orca-journal-damage-'))
+ clock = 1_000
+})
+
+afterEach(async () => {
+ await journals.closeAll()
+ await rm(root, { recursive: true, force: true })
+})
+
+describe('a damaged chat', () => {
+ it.each([
+ [
+ 'a row that is not a row',
+ (db: Database.Database) => updateTestJournalRowJson(db, IDENTITY.sessionId, 3, '}{')
+ ],
+ [
+ 'a row of a known kind missing a field',
+ (db: Database.Database) =>
+ updateTestJournalRowJson(
+ db,
+ IDENTITY.sessionId,
+ 3,
+ JSON.stringify({ ...JSON.parse(storedRowJson(db, 3)), itemId: 5 })
+ )
+ ],
+ [
+ 'a row numbered against its key',
+ (db: Database.Database) =>
+ updateTestJournalRowJson(
+ db,
+ IDENTITY.sessionId,
+ 3,
+ JSON.stringify({ ...JSON.parse(storedRowJson(db, 3)), seq: 9 })
+ )
+ ],
+ [
+ 'a gap in the sequence',
+ (db: Database.Database) => deleteTestJournalRow(db, IDENTITY.sessionId, 4)
+ ],
+ ['no epoch row', (db: Database.Database) => deleteTestJournalRow(db, IDENTITY.sessionId, 1)]
+ ])('fails to load and keeps every row: %s', async (_name, damage) => {
+ await writeChat()
+ withJournalDatabase(damage)
+ const before = storedRows()
+
+ await expect(open()).rejects.toMatchObject(UNLOADABLE)
+ await expect(open()).rejects.toMatchObject(UNLOADABLE)
+
+ expect(storedRows()).toEqual(before)
+ })
+
+ it('names where the damage starts', async () => {
+ await writeChat()
+ withJournalDatabase((db) => deleteTestJournalRow(db, IDENTITY.sessionId, 4))
+
+ expect(loadTestJournal(root, IDENTITY.sessionId)).toMatchObject({
+ readOnly: false,
+ damage: { sequence: 4, cause: 'sequence-gap' }
+ })
+ })
+
+ it("reads past damage to a newer build's row, which keeps the chat read-only instead", async () => {
+ await writeChat()
+ withJournalDatabase((db) => {
+ updateTestJournalRowJson(db, IDENTITY.sessionId, 3, '}{')
+ const newer = { ...JSON.parse(storedRowJson(db, 5)), kind: 'checkpoint' }
+ updateTestJournalRowJson(db, IDENTITY.sessionId, 5, JSON.stringify(newer))
+ })
+ const before = storedRows()
+
+ const journal = await open()
+
+ expect(journal.isReadOnly).toBe(true)
+ expect(storedRows()).toEqual(before)
+ })
+})
+
+function storedRowJson(db: Database.Database, seq: number): string {
+ const row = liveTestJournalRows(db, IDENTITY.sessionId).find((entry) => entry.seq === seq)
+ if (!row) {
+ throw new Error(`no row at ${seq}`)
+ }
+ return row.rowJson
+}
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 e110b9221c44..fd529586f4ea 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
@@ -2,8 +2,8 @@
//
// `journal_rows` is every chat's append-only log, keyed `(session_id, epoch, seq)`.
// `journal_sessions` names each chat's live epoch, and is written only when that epoch changes, so
-// an append is one INSERT. `journal_repairs` carries at most one row per chat: the standing demand
-// for a rebuild a partial repair leaves behind (see journal-repair-marker.ts). `journal_imports`
+// an append is one INSERT. `journal_repairs` is only for an older build, which reads and writes it
+// on this same schema version; this build does neither. `journal_imports`
// records which per-chat file each chat was copied from (journal-per-session-reimport.ts), and
// `journal_set_aside` each chat whose per-chat file is not this build's history and is never read
// again. The `agent_session_*` tables hold each chat's ownership record, its operation ledger, the
diff --git a/src/main/native-chat/agent-session-journal/journal-database.test.ts b/src/main/native-chat/agent-session-journal/journal-database.test.ts
index 677efc21c4d7..649cfccdf1a3 100644
--- a/src/main/native-chat/agent-session-journal/journal-database.test.ts
+++ b/src/main/native-chat/agent-session-journal/journal-database.test.ts
@@ -16,7 +16,6 @@ import { journalDatabasePath } from './journal-host-database'
import { JournalUnreleasedSchemaError } from './journal-open-failure'
import {
deleteJournalEpochRows,
- deleteJournalRowSuffix,
deleteUnpublishedJournalRows,
insertJournalRow,
iterateJournalEpochRows,
@@ -130,7 +129,7 @@ describe('the host journal database open', () => {
})
describe('journal row statements', () => {
- it('serves replay, resume, suffix truncation and an epoch discard', () => {
+ it('serves replay, resume and an epoch discard', () => {
const db = openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS).db
try {
db.exec('BEGIN IMMEDIATE')
@@ -149,9 +148,6 @@ describe('journal row statements', () => {
4, 5
])
- expect(deleteJournalRowSuffix(db, 'session-1', 'epoch-1', 4)).toBe(2)
- expect(rowsOf(db, 'session-1', 'epoch-1')).toEqual([1, 2, 3])
-
// Another chat in the same file is untouched by this chat's discard.
deleteJournalEpochRows(db, 'session-1', 'epoch-1')
expect(rowsOf(db, 'session-1', 'epoch-1')).toEqual([])
diff --git a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts
index 70efc8912837..cc67fc9c62d6 100644
--- a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts
+++ b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts
@@ -1,8 +1,7 @@
// Republishing a live item set into a fresh epoch.
//
// One transaction: discard the old epoch's rows, insert the epoch row plus the
-// replacement items, move the session projection, and retire any repair marker
-// — this republished history is exactly what the marker was holding out for.
+// replacement items, and move the session projection.
import type {
AgentJournalItemBody,
@@ -13,7 +12,6 @@ import type {
} from '../../../shared/agent-session-journal-types'
import type { JournalHostDatabase } from './journal-host-database'
import type { JournalLoad } from './journal-open'
-import { clearJournalRepairMarker } from './journal-repair-marker'
import { applyJournalRow, createJournalReducerState } from './journal-reducer'
import { buildJournalItemRow, journalRowBase } from './journal-row-builders'
import {
@@ -97,7 +95,6 @@ export function replaceJournalEpoch(input: {
if (retired !== null) {
deleteJournalEpochRows(db, sessionId, retired)
}
- clearJournalRepairMarker(db, sessionId)
for (const row of rows) {
insertJournalRow(db, sessionId, row)
}
@@ -108,5 +105,5 @@ export function replaceJournalEpoch(input: {
// live one. The caller adopts that immediately, or a later failure leaves the
// live store writing into an epoch whose rows were just deleted.
state.oldestSequence = 1
- input.onPublished({ state, readOnly: false, corrupt: false, malformedRows: 0 })
+ input.onPublished({ state, readOnly: false, damage: null })
}
diff --git a/src/main/native-chat/agent-session-journal/journal-epoch-rollover.ts b/src/main/native-chat/agent-session-journal/journal-epoch-rollover.ts
index 855f60ac3261..a753053d19fe 100644
--- a/src/main/native-chat/agent-session-journal/journal-epoch-rollover.ts
+++ b/src/main/native-chat/agent-session-journal/journal-epoch-rollover.ts
@@ -1,15 +1,13 @@
// Opening a new epoch.
//
// One transaction: discard every row of the superseded epoch, insert the new
-// epoch row at sequence 1, move the session projection onto it, and retire any
-// repair marker the superseded epoch was carrying. Superseded rows are DELETED
-// rather than retained — nothing would ever shed them.
+// epoch row at sequence 1, and move the session projection onto it. Superseded
+// rows are DELETED rather than retained — nothing would ever shed them.
import { journalRowSchemaVersion } from '../../../shared/agent-session-journal-types'
import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types'
import type { JournalHostDatabase } from './journal-host-database'
import type { JournalLoad } from './journal-open'
-import { clearJournalRepairMarker } from './journal-repair-marker'
import { applyJournalRow, createJournalReducerState } from './journal-reducer'
import {
deleteJournalEpochRows,
@@ -47,7 +45,6 @@ export function publishNewEpoch(input: {
if (retired !== null) {
deleteJournalEpochRows(db, sessionId, retired)
}
- clearJournalRepairMarker(db, sessionId)
insertJournalRow(db, sessionId, row)
publishJournalSessionEpoch(db, input.identity, input.epoch)
})
@@ -58,5 +55,5 @@ export function publishNewEpoch(input: {
const state = createJournalReducerState(sessionId, input.epoch)
applyJournalRow(state, row)
state.oldestSequence = 1
- input.onPublished({ state, readOnly: false, corrupt: false, malformedRows: 0 })
+ input.onPublished({ state, readOnly: false, damage: null })
}
diff --git a/src/main/native-chat/agent-session-journal/journal-file-format-remnant.test.ts b/src/main/native-chat/agent-session-journal/journal-file-format-remnant.test.ts
index ed27ed6fa0b2..dafc111fff77 100644
--- a/src/main/native-chat/agent-session-journal/journal-file-format-remnant.test.ts
+++ b/src/main/native-chat/agent-session-journal/journal-file-format-remnant.test.ts
@@ -21,7 +21,8 @@ import {
openTestJournalHostDatabase,
loadTestJournal,
deleteTestJournalRow,
- insertTestJournalRowJson
+ insertTestJournalRowJson,
+ liveTestJournalRows
} from './journal-host-database-test-support'
const IDENTITY: AgentSessionJournalIdentity = {
@@ -142,11 +143,8 @@ describe('a chat whose history is still in the pre-SQLite format', () => {
expect(disclosure(reopened)).toContain(join(legacyDir(), 'log.jsonl'))
})
- // A repair's epoch is the marker that history was deleted and never rebuilt,
- // and any row that is not the repair's own disclosure retires it. Appending
- // here would silently stop the session ever asking the provider for that
- // history — with the journal still holding none.
- it('stays out of a journal this open just repaired', async () => {
+ // A damaged journal fails its open before this branch, so nothing is appended to it.
+ it('writes nothing into a damaged journal', async () => {
const journal = await open()
await journal.appendItem(
{ provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 },
@@ -154,10 +152,6 @@ describe('a chat whose history is still in the pre-SQLite format', () => {
{ fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
)
await journal.close()
- // Deleting the anchor leaves every row unanchored: replay keeps nothing, so
- // the repair publishes an empty `unreconcilable_prefix` epoch and — costing
- // no malformed row — appends no disclosure of its own. That is the one state
- // where this branch and a repair meet.
const opened = openTestJournalHostDatabase(root)
try {
deleteTestJournalRow(opened.db, IDENTITY.sessionId, 1)
@@ -166,11 +160,18 @@ describe('a chat whose history is still in the pre-SQLite format', () => {
}
await writeRemnant()
- const repaired = await open()
-
- expect(disclosure(repaired)).toBeNull()
- // Still asking the provider for the history the repair dropped.
- expect(loadTestJournal(root, IDENTITY.sessionId)).toMatchObject({ corrupt: true })
+ await expect(open()).rejects.toMatchObject({
+ refusal: { details: { reason: 'journalCorrupt' } }
+ })
+ expect(loadTestJournal(root, IDENTITY.sessionId)).toMatchObject({
+ damage: { sequence: 1, cause: 'no-epoch-row' }
+ })
+ const stored = openTestJournalHostDatabase(root)
+ try {
+ expect(liveTestJournalRows(stored.db, IDENTITY.sessionId).map((row) => row.seq)).toEqual([2])
+ } finally {
+ stored.close()
+ }
})
// A journal latched by a newer row reaches the same branch — and an append into one throws,
diff --git a/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts b/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts
index a38a37fd0c82..3d835c21b730 100644
--- a/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts
+++ b/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts
@@ -566,8 +566,7 @@ describe('import failures', () => {
})
// A transcript with no decodable messages recovers nothing. Publishing an
- // empty replacement would roll the epoch and drop whatever the journal held —
- // including a repair's own anchor and disclosure.
+ // empty replacement would roll the epoch and drop whatever the journal held.
it('leaves the epoch untouched when the transcript decodes to no messages', async () => {
const journal = await open('codex', CODEX_SESSION)
await journal.appendItem(
diff --git a/src/main/native-chat/agent-session-journal/journal-legacy-import.ts b/src/main/native-chat/agent-session-journal/journal-legacy-import.ts
index cf9ff45805cf..a5dc864034f0 100644
--- a/src/main/native-chat/agent-session-journal/journal-legacy-import.ts
+++ b/src/main/native-chat/agent-session-journal/journal-legacy-import.ts
@@ -73,7 +73,7 @@ export async function importLegacyTranscriptIntoJournal(input: {
if (!prepared.ok) {
return prepared
}
- // An empty import must preserve any existing repair anchor and disclosure.
+ // An empty import leaves the epoch as it stands.
if (prepared.items.length === 0) {
const current = input.journal.cursor()
return { ok: true, epoch: current.epoch, cursor: current, imported: 0, replaced: false }
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 d154e1b29e69..f4391d153e95 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
@@ -12,6 +12,7 @@ import {
type AgentSessionWireRefusal
} from '../../../shared/agent-session-wire-refusals'
import { isSqliteCorruption } from '../../sqlite/sqlite-read-failure'
+import type { JournalLoad } from './journal-open'
import { AgentSessionJournalError } from './journal-write-guards'
import type { StructuredAgentSessionLogger } from '../agent-session-wire/structured-agent-session-logger'
@@ -25,6 +26,24 @@ export class JournalImportMismatchError extends Error {
override readonly name = 'JournalImportMismatchError'
}
+/** A history whose rows are not what they promise: a row no build wrote, a gap, no epoch row. */
+export class JournalDamageError extends Error {
+ override readonly name = 'JournalDamageError'
+}
+
+/** The one way damage fails a chat's load, wherever its rows are read: refused as history that
+ * cannot be loaded, and every row is left where it is. A newer build's row wins, so a read-only
+ * load never fails here. */
+export function failLoadOnJournalDamage(sessionId: string, load: JournalLoad): void {
+ if (!load.damage) {
+ return
+ }
+ const { sequence, cause } = load.damage
+ throw journalOpenRefusalError(
+ new JournalDamageError(`journal of ${sessionId} is damaged at sequence ${sequence}: ${cause}`)
+ )
+}
+
/** A database only an unreleased development build wrote: left as found, never migrated. */
export class JournalUnreleasedSchemaError extends Error {
override readonly name = 'JournalUnreleasedSchemaError'
@@ -33,14 +52,15 @@ export class JournalUnreleasedSchemaError extends Error {
// Bounds a cause chain that loops back on itself.
const MAX_CAUSE_DEPTH = 8
-/** Unusable only where proven: damage the storage reports, a copy that did not verify, or a file
- * only an unreleased build wrote. Anything unproven can clear. */
+/** Unusable only where proven: damage the storage or the rows show, a copy that did not verify,
+ * or a file only an unreleased build wrote. Anything unproven can clear. */
export function classifyJournalOpenFailure(error: unknown): JournalOpenFailure {
let current = error
for (let depth = 0; depth < MAX_CAUSE_DEPTH && current !== undefined; depth += 1) {
if (
isSqliteCorruption(current) ||
current instanceof JournalImportMismatchError ||
+ current instanceof JournalDamageError ||
current instanceof JournalUnreleasedSchemaError
) {
return 'journalCorrupt'
diff --git a/src/main/native-chat/agent-session-journal/journal-open.ts b/src/main/native-chat/agent-session-journal/journal-open.ts
index ff811983a253..475973762579 100644
--- a/src/main/native-chat/agent-session-journal/journal-open.ts
+++ b/src/main/native-chat/agent-session-journal/journal-open.ts
@@ -2,9 +2,9 @@
// epoch's rows are folded through the reducer in sequence order.
//
// There is no snapshot to anchor to and no superseded-epoch rows to drop — a
-// roll deletes them in the same transaction that publishes the new epoch. A gap
-// in the surviving sequence is corruption, and the caller rolls the epoch
-// rather than rendering a partial timeline.
+// roll deletes them in the same transaction that publishes the new epoch. A row
+// this build cannot parse, a gap, or a first row that is not the epoch's is
+// damage: the load names it and the open fails on it. Nothing is deleted.
import type Database from '../../sqlite/sync-database'
import {
@@ -17,27 +17,24 @@ import {
readJournalRowsAfter,
readJournalSessionEpoch
} from './journal-row-table'
-import { JOURNAL_REPAIR_DISCLOSURE_ITEM_ID } from './journal-repair-disclosure'
-import { pendingJournalRepairSequence } from './journal-repair-marker'
-import { isJournalStopOrResumeRow, parseJournalRow, type JournalRow } from './journal-row-schema'
+import { parseJournalRow, type JournalRow } from './journal-row-schema'
/** Every epoch row is sequence 1, and no compaction moves that floor. */
const FIRST_JOURNAL_SEQUENCE = 1
+/** Where a journal stops being one this build can read as written. */
+export type JournalDamage = {
+ /** The first sequence that is missing, or not what the rows before it promise. */
+ sequence: number
+ cause: 'unparseable-row' | 'misnumbered-row' | 'sequence-gap' | 'no-epoch-row'
+}
+
export type JournalLoad = {
state: JournalReducerState
- /** A row from a future schema, or of a kind this build does not know, was met: no writes, no
- * deletion. */
+ /** A row from a future schema, or of a kind this build does not know, was met: no writes. */
readOnly: boolean
- /** Set when the surviving prefix is unusable and the caller must roll the epoch. */
- corrupt: boolean
- /** Rows dropped because they failed to parse or name another sequence than their key (an
- * unreadable row latches `readOnly`, never counted here). The store discloses these in the
- * timeline. */
- malformedRows: number
- /** Directory-internal: the first sequence of an unusable suffix. The store
- * deletes from here before it accepts a write; a probe leaves it alone. */
- truncateFrom?: number
+ /** Set when the history is damaged; a newer build's row wins over damage beside it. */
+ damage: JournalDamage | null
}
/** Replays one chat from the host's database. Returns null when the chat has no journal yet. */
@@ -46,20 +43,8 @@ export function replayJournal(db: Database.Database, sessionId: string): Journal
if (epoch === null) {
return null
}
- return foldJournalRows({
- sessionId,
- epoch,
- repairedFrom: pendingJournalRepairSequence(db, sessionId, epoch),
- rows: iterateJournalEpochRows(db, sessionId, epoch)
- })
-}
-
-/** Folds one epoch's stored rows, in sequence order, wherever they are stored. */
-function foldJournalRows(
- input: JournalRowFoldInput & { rows: Iterable<{ seq: number; rowJson: string }> }
-): JournalLoad {
- const fold = startJournalRowFold(input)
- for (const entry of input.rows) {
+ const fold = startJournalRowFold({ sessionId, epoch })
+ for (const entry of iterateJournalEpochRows(db, sessionId, epoch)) {
if (!fold.add(entry)) {
break
}
@@ -67,85 +52,59 @@ function foldJournalRows(
return fold.finish()
}
-type JournalRowFoldInput = {
- sessionId: string
- epoch: string
- /** The sequence a pending repair on this epoch left free. */
- repairedFrom: number | null
-}
-
/** The same fold, fed a row at a time, for a caller that yields between batches of rows. */
-export function startJournalRowFold(input: JournalRowFoldInput): {
+export function startJournalRowFold(input: { sessionId: string; epoch: string }): {
/** False once the fold has stopped: the rest of the rows are not read. */
add: (entry: { seq: number; rowJson: string }) => boolean
finish: () => JournalLoad
} {
- const { repairedFrom } = input
const state = createJournalReducerState(input.sessionId, input.epoch)
let expectedSequence = FIRST_JOURNAL_SEQUENCE
- let gapSequence: number | undefined
- let unanchoredSequence: number | undefined
- let anchor: Extract | undefined
- let repairHasContent = false
- let providerHasContent = false
- let malformedRows = 0
+ let damage: JournalDamage | null = null
+ let anchored = false
let latched = false
- let truncateFrom: number | undefined
const add = (entry: { seq: number; rowJson: string }): boolean => {
const parsed = parseJournalRow(entry.rowJson)
- // A body naming another sequence than its key is malformed there: writes number past the key.
- if (!parsed.ok || parsed.row.seq !== entry.seq) {
- truncateFrom = entry.seq
- latched = !parsed.ok && parsed.unreadable
- malformedRows = latched ? 0 : 1
+ if (!parsed.ok && parsed.unreadable) {
+ latched = true
return false
}
- const row = parsed.row
- // Parse past a gap so an unreadable future row still latches read-only.
- if (gapSequence !== undefined) {
+ // Damage is read past, so a newer build's row further on still latches read-only.
+ if (!parsed.ok) {
+ damage ??= { sequence: entry.seq, cause: 'unparseable-row' }
return true
}
+ // A body naming another sequence than its key: writes would number past the key.
+ if (parsed.row.seq !== entry.seq) {
+ damage ??= { sequence: entry.seq, cause: 'misnumbered-row' }
+ return true
+ }
+ if (damage) {
+ return true
+ }
+ const row = parsed.row
if (row.seq !== expectedSequence) {
- gapSequence = row.seq
+ const cause = expectedSequence === FIRST_JOURNAL_SEQUENCE ? 'no-epoch-row' : 'sequence-gap'
+ damage = { sequence: expectedSequence, cause }
return true
}
expectedSequence += 1
- if (row.seq === FIRST_JOURNAL_SEQUENCE) {
- if (row.kind === 'epoch') {
- anchor = row
- } else {
- unanchoredSequence = row.seq
- }
- }
- if (!anchor) {
+ if (row.seq === FIRST_JOURNAL_SEQUENCE && row.kind !== 'epoch') {
+ damage = { sequence: row.seq, cause: 'no-epoch-row' }
return true
}
+ anchored = true
applyJournalRow(state, row)
- const disclosure = row.kind === 'item' && row.itemId === JOURNAL_REPAIR_DISCLOSURE_ITEM_ID
- // A Stop or Resume is no history, so it never reads as a rebuilt or provider-backed epoch.
- if (!disclosure && !isJournalStopOrResumeRow(row)) {
- repairHasContent ||= repairedFrom !== null && row.seq >= repairedFrom
- providerHasContent ||= row.seq >= FIRST_JOURNAL_SEQUENCE + 1
- }
return true
}
const finish = (): JournalLoad => {
- // Anchor rejection takes precedence over a gap, which takes precedence over malformed rows.
- truncateFrom = unanchoredSequence ?? gapSequence ?? truncateFrom
state.oldestSequence = FIRST_JOURNAL_SEQUENCE
- return {
- state,
- readOnly: latched,
- corrupt:
- gapSequence !== undefined ||
- malformedRows > 0 ||
- (!latched && !anchor) ||
- (repairedFrom !== null && !repairHasContent) ||
- (anchor?.reason === 'unreconcilable_prefix' && !providerHasContent),
- malformedRows,
- ...(truncateFrom !== undefined && !latched ? { truncateFrom } : {})
- }
+ // An epoch with no rows at all never had its first row written.
+ const found =
+ damage ??
+ (anchored ? null : { sequence: FIRST_JOURNAL_SEQUENCE, cause: 'no-epoch-row' as const })
+ return { state, readOnly: latched, damage: latched ? null : found }
}
return { add, finish }
}
diff --git a/src/main/native-chat/agent-session-journal/journal-per-session-import.test.ts b/src/main/native-chat/agent-session-journal/journal-per-session-import.test.ts
index 9ec154d0e5df..5e91ae6a1f91 100644
--- a/src/main/native-chat/agent-session-journal/journal-per-session-import.test.ts
+++ b/src/main/native-chat/agent-session-journal/journal-per-session-import.test.ts
@@ -26,7 +26,7 @@ import {
readTestJournalRows
} from './journal-host-database-test-support'
import { journalDirectoryFor, legacyJournalDatabaseFile } from './journal-paths'
-import { importPerSessionJournal } from './journal-per-session-import'
+import { importPerSessionJournal, previewPerSessionJournal } from './journal-per-session-import'
import { readJournalSessionEpoch, type JournalStoredRow } from './journal-row-table'
import { createStructuredAgentSessionLogger } from '../agent-session-wire/structured-agent-session-logger'
@@ -170,7 +170,7 @@ function alteringFirstCopiedRead(
}
}
-const losingFirstCopiedRow = alteringFirstCopiedRead((rows) => rows.slice(1))
+const losingLastCopiedRow = alteringFirstCopiedRead((rows) => rows.slice(0, -1))
/** Every row still there and still parsing, but the reply's words changed. */
const garblingCopiedReply = alteringFirstCopiedRead((rows) =>
@@ -385,7 +385,7 @@ describe('importing a per-chat journal', () => {
database,
identity: IDENTITY,
legacyDirectory: legacyDir(),
- openSource: losingFirstCopiedRow
+ openSource: losingLastCopiedRow
}
const before = await readFile(legacyJournalDatabaseFile(legacyDir()))
@@ -410,6 +410,34 @@ describe('importing a per-chat journal', () => {
expect(rowCount(database.db)).toBe(rows.length)
})
+ it('keeps a damaged file whole, copies none of it, and refuses the chat on every open', async () => {
+ const { epoch, rows } = await historyRows()
+ // A gap: the reply's row is gone from the file.
+ await writeLegacyJournal(
+ epoch,
+ rows.filter((row) => row.seq !== 2)
+ )
+ const database = openTestJournalHostDatabase(root)
+ const before = await readFile(legacyJournalDatabaseFile(legacyDir()))
+ const unloadable = {
+ refusal: { message: 'Unable to load this chat.', details: { reason: 'journalCorrupt' } }
+ }
+ const input = { database, identity: IDENTITY, legacyDirectory: legacyDir() }
+
+ await expect(previewPerSessionJournal(input)).rejects.toMatchObject(unloadable)
+ for (let attempt = 0; attempt < 2; attempt += 1) {
+ await expect(importPerSessionJournal(input)).rejects.toMatchObject(unloadable)
+ }
+ await expect(openChat()).rejects.toMatchObject(unloadable)
+
+ expect((await readFile(legacyJournalDatabaseFile(legacyDir()))).equals(before)).toBe(true)
+ expect(readJournalSessionEpoch(database.db, IDENTITY.sessionId)).toBeNull()
+ expect(rowCount(database.db)).toBe(0)
+ expect(
+ database.db.prepare('SELECT count(*) AS total FROM journal_imports').get()
+ ).toMatchObject({ total: 0 })
+ })
+
// A copy that keeps every count but not every byte is no copy: the file is all there is.
it('keeps the file and refuses the chat when a copied row differs but every count matches', async () => {
const { epoch, rows } = await historyRows()
diff --git a/src/main/native-chat/agent-session-journal/journal-per-session-import.ts b/src/main/native-chat/agent-session-journal/journal-per-session-import.ts
index 5f85fdded437..ee370ea58070 100644
--- a/src/main/native-chat/agent-session-journal/journal-per-session-import.ts
+++ b/src/main/native-chat/agent-session-journal/journal-per-session-import.ts
@@ -9,10 +9,10 @@
// The copy runs in bounded batches, each its own transaction, yielding the event loop between them.
// The rows go in under the file's epoch, which the chat's pointer does not name yet, so no reader
// sees them. Once they read back as the file does (every row's sequence, time and bytes), one
-// transaction publishes the chat's pointer with its repair and import markers, so the chat is
-// imported all at once or not at all. A try that stops midway leaves only unpublished rows, which
-// the next try deletes before it copies again. A copy that does not read back as the file is never
-// published: the file stays, and the chat is refused as unreadable.
+// transaction publishes the chat's pointer with its import marker, so the chat is imported all at
+// once or not at all. A try that stops midway leaves only unpublished rows, which the next try
+// deletes before it copies again. A copy that does not read back as the file, or a file whose
+// history is damaged, is never published: the file stays, and the chat is refused as unloadable.
//
// Only after that commit is the file deleted, its connection closed first. A read that fails
// leaves the file where it is for the next open, and the open is refused rather than served empty:
@@ -24,8 +24,12 @@ import { setImmediate as yieldToEventLoop } from 'node:timers/promises'
import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types'
import type Database from '../../sqlite/sync-database'
import type { JournalHostDatabase } from './journal-host-database'
-import type { JournalLoad } from './journal-open'
-import { JournalImportMismatchError, journalOpenRefusalError } from './journal-open-failure'
+import { startJournalRowFold, type JournalLoad } from './journal-open'
+import {
+ failLoadOnJournalDamage,
+ JournalImportMismatchError,
+ journalOpenRefusalError
+} from './journal-open-failure'
import { legacyJournalDatabaseFile } from './journal-paths'
import {
planPerSessionImport,
@@ -42,7 +46,6 @@ import {
legacyRowBatches,
openLegacySource,
readLegacyHead,
- readLegacyRepair,
retireLegacyJournal,
type ImportBatch
} from './journal-per-session-source'
@@ -57,10 +60,6 @@ import {
const INSERT_ROW =
'INSERT INTO journal_rows (session_id, epoch, seq, ts, row_json) VALUES (?, ?, ?, ?, ?)'
-const UPSERT_REPAIR = `INSERT INTO journal_repairs (session_id, epoch, content_from, repaired_at)
-VALUES (?, ?, ?, ?)
-ON CONFLICT(session_id) DO UPDATE SET
- epoch = excluded.epoch, content_from = excluded.content_from, repaired_at = excluded.repaired_at`
type PerSessionJournalImportDeps = {
openSource?: (path: string) => Database.Database
@@ -151,7 +150,8 @@ async function importOnce(input: ImportInput): Promise
@@ -173,16 +173,17 @@ export async function previewPerSessionJournal(
return null
}
const loaded = await foldLegacyJournal(source, sessionId, legacy)
- return loaded.corrupt || loaded.readOnly || loaded.truncateFrom !== undefined ? null : loaded
+ failLoadOnJournalDamage(sessionId, loaded)
+ return loaded.readOnly ? null : loaded
} finally {
source.close()
}
}
/**
- * Batches under the file's epoch, which no reader follows until the chat's pointer names it. Once
- * the rows read back as the file does, one transaction publishes the pointer with the chat's repair
- * marker and the import marker.
+ * Batches under the file's epoch, which no reader follows until the chat's pointer names it. Each
+ * batch is folded as it is copied, so a damaged file is refused before anything is published. Once
+ * the rows read back as the file does, one transaction publishes the pointer and the import marker.
*/
async function copyLegacyJournal(
input: ImportInput,
@@ -191,13 +192,15 @@ async function copyLegacyJournal(
): Promise {
const { sessionId } = input.identity
const { epoch } = legacy
- const repair = readLegacyRepair(source, sessionId)
const batchRows = input.batchRows ?? IMPORT_BATCH_ROWS
+ const fold = startJournalRowFold({ sessionId, epoch })
+ let folding = true
let first = true
for (const batch of legacyRowBatches(source, sessionId, epoch, batchRows)) {
if (!first) {
await yieldToEventLoop()
}
+ folding &&= batch.rows.every(fold.add)
// Unsynced: no reader follows these rows, and the publish's synced commit covers them.
input.database.unsyncedTransaction((db) => {
if (first) {
@@ -212,17 +215,15 @@ async function copyLegacyJournal(
})
first = false
}
+ const copied = fold.finish()
+ if (copied.damage) {
+ // Never published, so the copy goes; the file it came from stays.
+ input.database.transaction((db) => deleteUnpublishedJournalRows(db, sessionId))
+ }
+ failLoadOnJournalDamage(sessionId, copied)
await verifyCopiedJournal(input, legacyRowBatches(source, sessionId, epoch, batchRows), epoch)
input.database.transaction((db) => {
publishJournalSessionEpoch(db, input.identity, epoch)
- if (repair) {
- db.prepare(UPSERT_REPAIR).run(
- sessionId,
- repair.epoch,
- repair.content_from,
- repair.repaired_at
- )
- }
writePerSessionImportMarker(db, sessionId, legacy)
})
}
diff --git a/src/main/native-chat/agent-session-journal/journal-per-session-source.ts b/src/main/native-chat/agent-session-journal/journal-per-session-source.ts
index dc548e2f3dd0..a58a1de3bf5d 100644
--- a/src/main/native-chat/agent-session-journal/journal-per-session-source.ts
+++ b/src/main/native-chat/agent-session-journal/journal-per-session-source.ts
@@ -4,11 +4,9 @@
import { rmdirSync, rmSync } from 'node:fs'
import { setImmediate as yieldToEventLoop } from 'node:timers/promises'
import Database from '../../sqlite/sync-database'
-import type { SqliteRow } from '../../sqlite/sqlite-statement'
import { startJournalRowFold, type JournalLoad } from './journal-open'
import { legacyJournalDatabaseFile } from './journal-paths'
import type { PerSessionJournalHead } from './journal-per-session-reimport'
-import { pendingJournalRepairSequence } from './journal-repair-marker'
/** The newest per-chat file shape any build wrote. */
const LEGACY_JOURNAL_SCHEMA_VERSION = 2
@@ -21,8 +19,6 @@ const SELECT_LEGACY_TIP =
const SELECT_LEGACY_ROWS = `SELECT seq, ts, row_json FROM journal_rows
WHERE session_id = ? AND epoch = ? AND seq > ? ORDER BY seq ASC LIMIT ?`
const HAS_LEGACY_TABLE = "SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = ?"
-const SELECT_LEGACY_REPAIR =
- 'SELECT epoch, content_from, repaired_at FROM journal_repairs WHERE session_id = ?'
type ImportedRow = { seq: number; ts: number; rowJson: string }
export type ImportBatch = { rows: ImportedRow[]; last: boolean }
@@ -81,27 +77,13 @@ export function* legacyRowBatches(
}
}
-/** The per-chat repair marker, as stored; v1 files predate the table. */
-export function readLegacyRepair(source: Database.Database, sessionId: string): SqliteRow | null {
- if (!source.prepare(HAS_LEGACY_TABLE).get('journal_repairs')) {
- return null
- }
- return source.prepare(SELECT_LEGACY_REPAIR).get(sessionId) ?? null
-}
-
/** The chat as its file holds it, folded the way a replay folds the host's database. */
export async function foldLegacyJournal(
source: Database.Database,
sessionId: string,
legacy: PerSessionJournalHead
): Promise {
- const fold = startJournalRowFold({
- sessionId,
- epoch: legacy.epoch,
- repairedFrom: source.prepare(HAS_LEGACY_TABLE).get('journal_repairs')
- ? pendingJournalRepairSequence(source, sessionId, legacy.epoch)
- : null
- })
+ const fold = startJournalRowFold({ sessionId, epoch: legacy.epoch })
let first = true
for (const batch of legacyRowBatches(source, sessionId, legacy.epoch, IMPORT_BATCH_ROWS)) {
// A batch per turn: a large chat's file read in one task holds up everything else at startup.
diff --git a/src/main/native-chat/agent-session-journal/journal-repair-disclosure.ts b/src/main/native-chat/agent-session-journal/journal-repair-disclosure.ts
deleted file mode 100644
index fee5edf142b1..000000000000
--- a/src/main/native-chat/agent-session-journal/journal-repair-disclosure.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-// What a repair tells the user it did.
-//
-// The identity is a constant because replay reads it back: an epoch holding
-// nothing but its anchor and this row is a repair that has not been
-// reconstructed yet, not a timeline.
-
-import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key'
-import type { AgentJournalItemIdentity } from '../../../shared/agent-session-journal-types'
-
-/** One stable identity, so a reopen upserts the same row instead of adding one. */
-export const JOURNAL_REPAIR_DISCLOSURE_IDENTITY: AgentJournalItemIdentity = {
- provider: 'orca',
- clientMessageId: 'journal-malformed-lines'
-}
-
-export const JOURNAL_REPAIR_DISCLOSURE_ITEM_ID = agentJournalItemKey(
- JOURNAL_REPAIR_DISCLOSURE_IDENTITY
-)
-
-export type JournalRepairDisclosure = {
- identity: AgentJournalItemIdentity
- body: { kind: 'status'; text: string }
-}
-
-/** Disclosed when a repair skipped a row it could not read. */
-export function journalRepairDisclosure(input: { malformedRows: number }): JournalRepairDisclosure {
- const lines = `${input.malformedRows} journal line${input.malformedRows === 1 ? '' : 's'}`
- return {
- identity: JOURNAL_REPAIR_DISCLOSURE_IDENTITY,
- body: { kind: 'status', text: `${lines} could not be read` }
- }
-}
diff --git a/src/main/native-chat/agent-session-journal/journal-repair-marker.ts b/src/main/native-chat/agent-session-journal/journal-repair-marker.ts
deleted file mode 100644
index 785339456712..000000000000
--- a/src/main/native-chat/agent-session-journal/journal-repair-marker.ts
+++ /dev/null
@@ -1,65 +0,0 @@
-// The standing demand for a rebuild that a repair leaves behind.
-//
-// A repair that empties the epoch republishes an `unreconcilable_prefix` anchor,
-// and replay reads that back as history still owed. A repair that KEEPS a prefix
-// has no such anchor to publish and — for a plain sequence gap — no malformed
-// row to disclose either, so nothing on disk would record that the deleted
-// suffix was never reconstructed. This marker is that record, written in the
-// SAME transaction as the deletion: a crash between the two would otherwise
-// leave the rows gone with nothing left asking for them back.
-//
-// It records the first sequence at which the epoch would hold content of its
-// own again, because it retires under exactly the rule the emptied-epoch anchor
-// takes: a fresh epoch carries the rebuild, and a session that writes past that
-// sequence owns the epoch and stops the retry.
-
-import type Database from '../../sqlite/sync-database'
-import type { JournalHostDatabase } from './journal-host-database'
-import { deleteJournalRowSuffix } from './journal-row-table'
-
-const SELECT_REPAIR = 'SELECT epoch, content_from FROM journal_repairs WHERE session_id = ?'
-const UPSERT_REPAIR = `INSERT INTO journal_repairs (session_id, epoch, content_from, repaired_at)
-VALUES (?, ?, ?, ?)
-ON CONFLICT(session_id) DO UPDATE SET
- epoch = excluded.epoch, content_from = excluded.content_from, repaired_at = excluded.repaired_at`
-const DELETE_REPAIR = 'DELETE FROM journal_repairs WHERE session_id = ?'
-
-/**
- * The sequence a pending repair on THIS epoch left free, or null when none is
- * pending. Epoch-scoped: a marker raised on an epoch that has since been
- * superseded says nothing about the live one.
- */
-export function pendingJournalRepairSequence(
- db: Database.Database,
- sessionId: string,
- epoch: string
-): number | null {
- const row = db.prepare(SELECT_REPAIR).get(sessionId) as
- | { epoch?: string; content_from?: number }
- | undefined
- return row?.epoch === epoch ? (row.content_from ?? null) : null
-}
-
-/** Retires the marker. Called from inside the epoch transactions, whose new
- * epoch is the rebuilt history the marker was holding out for. */
-export function clearJournalRepairMarker(db: Database.Database, sessionId: string): void {
- db.prepare(DELETE_REPAIR).run(sessionId)
-}
-
-/** Drop the rejected suffix and record that it is owed, atomically. */
-export function deleteJournalRepairedSuffix(input: {
- database: JournalHostDatabase
- sessionId: string
- epoch: string
- /** First sequence of the rejected suffix. */
- fromSeq: number
- /** First sequence left free once the suffix is gone. */
- contentFrom: number
- now: number
-}): number {
- return input.database.transaction((db) => {
- const deleted = deleteJournalRowSuffix(db, input.sessionId, input.epoch, input.fromSeq)
- db.prepare(UPSERT_REPAIR).run(input.sessionId, input.epoch, input.contentFrom, input.now)
- return deleted
- })
-}
diff --git a/src/main/native-chat/agent-session-journal/journal-row-context-usage.test.ts b/src/main/native-chat/agent-session-journal/journal-row-context-usage.test.ts
index 623c97445a61..9dcced9944af 100644
--- a/src/main/native-chat/agent-session-journal/journal-row-context-usage.test.ts
+++ b/src/main/native-chat/agent-session-journal/journal-row-context-usage.test.ts
@@ -104,7 +104,6 @@ describe('context facts on replayed turn rows', () => {
await journal.close()
const reopened = await open()
- expect(reopened.repair.malformedRows).toBe(0)
expect(reopened.snapshot().items.map((item) => item.body)).toEqual(written)
expect(written).toHaveLength(FACTS.length)
})
@@ -132,7 +131,6 @@ describe('context facts on replayed turn rows', () => {
}
const reopened = await open()
- expect(reopened.repair.malformedRows).toBe(0)
expect(reopened.snapshot().items.map((item) => item.body)).toEqual([
turn('turn-0'),
{ kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'after' }] }
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 c120b0e77810..9bdc9746dd0c 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
@@ -14,12 +14,12 @@
// or is written at a bumped `v`.
//
// Inside a known row with that envelope the same holds a level down. A lifecycle mutation of a kind
-// this build does not know, a sent message of a kind it does not know, or a body holding a value
-// outside a closed set it knows is UNREADABLE, whatever else in the row fails; a turn's context
-// usage is the exception, dropped like the other unusable annotations. A body of a kind it does
-// not know is READABLE and drawn by nobody (agent-session-journal-schemas.ts). 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.
+// this build does not know, or a sent message of a kind it does not know, is UNREADABLE, whatever
+// else in the row fails; unusable annotations are dropped. A body of a kind it does not know is
+// READABLE and drawn by nobody (agent-session-journal-schemas.ts). Any other failure is damage, and
+// the chat fails to load with every row kept (journal-open.ts). Builds from before this rule delete
+// the journal from such a row, so the same reader-first-or-`v` rule applies to a new mutation or
+// sent-message kind.
import type { AgentSessionFailureFact } from '../../../shared/agent-session-failure'
import {
diff --git a/src/main/native-chat/agent-session-journal/journal-row-table.ts b/src/main/native-chat/agent-session-journal/journal-row-table.ts
index 4665a0571150..85eca03b8ac1 100644
--- a/src/main/native-chat/agent-session-journal/journal-row-table.ts
+++ b/src/main/native-chat/agent-session-journal/journal-row-table.ts
@@ -19,7 +19,6 @@ const INSERT_ROW =
const SELECT_ROWS_AFTER = `SELECT seq, ts, row_json FROM journal_rows
WHERE session_id = ? AND epoch = ? AND seq > ? ORDER BY seq ASC`
const SELECT_ROWS_AFTER_LIMITED = `${SELECT_ROWS_AFTER} LIMIT ?`
-const DELETE_SUFFIX = 'DELETE FROM journal_rows WHERE session_id = ? AND epoch = ? AND seq >= ?'
const DELETE_EPOCH = 'DELETE FROM journal_rows WHERE session_id = ? AND epoch = ?'
const DELETE_UNPUBLISHED = `DELETE FROM journal_rows WHERE session_id = ?
AND epoch IS NOT (SELECT epoch FROM journal_sessions WHERE session_id = ?)`
@@ -102,14 +101,3 @@ export function deleteJournalEpochRows(
export function deleteUnpublishedJournalRows(db: Database.Database, sessionId: string): void {
db.prepare(DELETE_UNPUBLISHED).run(sessionId, sessionId)
}
-
-/** Drop the rejected suffix a repair found, from `fromSeq` to the tip. */
-export function deleteJournalRowSuffix(
- db: Database.Database,
- sessionId: string,
- epoch: string,
- fromSeq: number
-): number {
- const deleted = db.prepare(DELETE_SUFFIX).run(sessionId, epoch, fromSeq)
- return Number(deleted.changes ?? 0)
-}
diff --git a/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.test.ts b/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.test.ts
index 8eee42a70d03..3404c2310959 100644
--- a/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.test.ts
+++ b/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.test.ts
@@ -46,8 +46,8 @@ afterEach(async () => {
})
/** One epoch's rows, folded as an open folds them. */
-function fold(rows: readonly JournalRow[], repairedFrom: number | null = null) {
- const folding = startJournalRowFold({ sessionId: IDENTITY.sessionId, epoch: EPOCH, repairedFrom })
+function fold(rows: readonly JournalRow[]) {
+ const folding = startJournalRowFold({ sessionId: IDENTITY.sessionId, epoch: EPOCH })
for (const row of rows) {
folding.add({ seq: row.seq, rowJson: serializeJournalRow(row) })
}
@@ -110,13 +110,13 @@ describe("a Stop's event and a Resume", () => {
expect(projected).toMatchObject({ ok: true, batch: { items: [], removedItemIds: [] } })
})
- it('are no provider history: an unreconcilable epoch holding only them still reads corrupt', () => {
- expect(fold(epochWith('unreconcilable_prefix', ['stop', 'resume'])).corrupt).toBe(true)
- expect(fold(epochWith('unreconcilable_prefix', ['stop', 'item'])).corrupt).toBe(false)
- })
-
- it('are no rebuilt history: a pending repair followed only by them still reads corrupt', () => {
- expect(fold(epochWith('handle_forked', ['stop', 'resume']), 2).corrupt).toBe(true)
- expect(fold(epochWith('handle_forked', ['stop', 'item']), 2).corrupt).toBe(false)
+ // An older build founded `unreconcilable_prefix` epochs when it deleted history; this one opens
+ // such an epoch as it stands.
+ it('fold as history in an epoch an older build founded after deleting rows', () => {
+ expect(fold(epochWith('unreconcilable_prefix', ['stop', 'resume']))).toMatchObject({
+ readOnly: false,
+ damage: null,
+ state: { lastSequence: 3 }
+ })
})
})
diff --git a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts
index b938efa3c5c9..50200481895c 100644
--- a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts
+++ b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts
@@ -43,10 +43,6 @@ export type JournalStoreHost = {
cursor: () => AgentJournalCursor
adopt: (loaded: JournalLoad) => void
commit: (row: JournalRow) => void
- /** Records whether the open's replay found an unusable prefix. */
- setOpenedCorrupt: (corrupt: boolean) => void
- malformedRows: () => number
- setMalformedRows: (count: number) => void
journal: () => AgentSessionJournal
enqueue: (build: (seq: number, ts: number) => JournalRow) => Promise
}
diff --git a/src/main/native-chat/agent-session-journal/journal-store-open.ts b/src/main/native-chat/agent-session-journal/journal-store-open.ts
index a7163860be96..1d12809136fe 100644
--- a/src/main/native-chat/agent-session-journal/journal-store-open.ts
+++ b/src/main/native-chat/agent-session-journal/journal-store-open.ts
@@ -8,33 +8,24 @@ import {
journalFileFormatRemnantDisclosure
} from './journal-file-format-remnant'
import type { JournalLoad } from './journal-open'
-import { journalRepairDisclosure, type JournalRepairDisclosure } from './journal-repair-disclosure'
+import { failLoadOnJournalDamage } from './journal-open-failure'
import { staleSubagentRosterRevisions } from './journal-subagent-liveness'
-/** What any of this file's disclosures hands the store — a repair's, or the
- * pre-SQLite notice's. Same shape, and neither is only a repair. */
-type JournalDisclosure = JournalRepairDisclosure
+type JournalDisclosure = ReturnType
export function journalStoreLoadedFields(loaded: JournalLoad) {
return {
state: loaded.state,
- readOnly: loaded.readOnly,
- malformedRows: loaded.malformedRows
+ readOnly: loaded.readOnly
}
}
export async function openJournalStoreState(input: {
+ sessionId: string
legacyDirectory: string
replay: () => JournalLoad | null
- /** Drops the rejected suffix and records the rebuild it owes, in ONE
- * transaction. Corruption is not preserved; replay keeps reporting `corrupt`
- * until provider history republishes the epoch or the session writes past
- * `contentFrom`, the first sequence the repair left free. */
- deleteSuffix: (fromSeq: number, contentFrom: number) => number
start: () => void
adopt: (loaded: JournalLoad) => void
- /** Republishes an anchor row for an epoch a repair emptied. */
- publishRepairEpoch: () => void
appendItem: (
identity: AgentJournalItemIdentity,
body: AgentJournalItemBody,
@@ -42,8 +33,6 @@ export async function openJournalStoreState(input: {
) => Promise
agent: AgentType
highestFence: () => number
- malformedRows: () => number
- setMalformedRows: (count: number) => void
readOnly: () => boolean
}): Promise {
const loaded = input.replay()
@@ -52,36 +41,15 @@ export async function openJournalStoreState(input: {
await discloseFileFormatRemnant(input)
return
}
+ failLoadOnJournalDamage(input.sessionId, loaded)
input.adopt(loaded)
- if (loaded.truncateFrom !== undefined && !loaded.readOnly) {
- input.deleteSuffix(loaded.truncateFrom, loaded.state.lastSequence + 1)
- }
- // A repair that took every live row leaves the epoch with no anchor. Publish
- // one before anything can append into it: an ordinary row at sequence 1 would
- // replay as a clean timeline and hide that the history was never rebuilt.
- if (!loaded.readOnly && loaded.state.lastSequence === 0) {
- input.publishRepairEpoch()
- // The replacement epoch adopts a clean load; what this open's repair did is
- // still the answer `repair` and the disclosure below owe the caller.
- input.setMalformedRows(loaded.malformedRows)
- }
- if (input.malformedRows() > 0 && !input.readOnly()) {
- const disclosure = journalRepairDisclosure({ malformedRows: input.malformedRows() })
- await input.appendItem(disclosure.identity, disclosure.body, input.highestFence())
- }
await settleStaleSubagentRosters(input, loaded)
// Founding the epoch and appending the row are two transactions, and a
// committed epoch sends every later open down this branch instead. Anything
// that interrupts between them — a quit during startup restore, a failed
// append — would otherwise lose the message for good. An epoch holding nothing
// is exactly the state that append was owed, so offer it again.
- //
- // Never onto a repair, though: `loaded.state` is the PRE-repair load, so a
- // journal this open just emptied looks identical. The repair's epoch is the
- // marker that its history was deleted and never rebuilt, and any row that is
- // not the repair's own disclosure retires it — this row would silently stop
- // the session ever asking the provider for that history again.
- if (!loaded.corrupt && loaded.state.items.size === 0 && loaded.state.submissions.size === 0) {
+ if (loaded.state.items.size === 0 && loaded.state.submissions.size === 0) {
await discloseFileFormatRemnant(input)
}
}
@@ -111,13 +79,7 @@ async function discloseFileFormatRemnant(input: {
await input.appendItem(disclosure.identity, disclosure.body, input.highestFence())
}
-/**
- * Retires a `working` subagent roster the previous host never got to settle.
- *
- * Skipped on a corrupt load: that journal is still owed a rebuild from provider
- * history, and content written past the repair's free sequence retires the
- * demand for it.
- */
+/** Retires a `working` subagent roster the previous host never got to settle. */
async function settleStaleSubagentRosters(
input: {
appendItem: (
@@ -130,7 +92,7 @@ async function settleStaleSubagentRosters(
},
loaded: JournalLoad
): Promise {
- if (input.readOnly() || loaded.corrupt) {
+ if (input.readOnly()) {
return
}
for (const revision of staleSubagentRosterRevisions(loaded.state.items.values())) {
diff --git a/src/main/native-chat/agent-session-journal/journal-store-restore.ts b/src/main/native-chat/agent-session-journal/journal-store-restore.ts
index 8fce8457b630..cac30b2c55e8 100644
--- a/src/main/native-chat/agent-session-journal/journal-store-restore.ts
+++ b/src/main/native-chat/agent-session-journal/journal-store-restore.ts
@@ -1,7 +1,7 @@
// Bringing a store's in-memory state up from disk.
//
// Split out of the store for the same reason its collaborators were: this is the
-// ORDERING between replay, suffix repair and disclosure, and none of it belongs
+// ORDERING between import, replay and disclosure, and none of it belongs
// to the store's public surface. Every step here reads or writes through the
// same host the collaborators use, so the store keeps the state and this owns
// the sequence.
@@ -9,10 +9,10 @@
import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types'
import type { JournalEpochController } from './journal-epoch-controller'
import { replayJournal, type JournalLoad } from './journal-open'
+import { failLoadOnJournalDamage } from './journal-open-failure'
import { createJournalReducerState } from './journal-reducer'
import type { JournalStoreHost } from './journal-store-collaborators'
import { openJournalStoreState } from './journal-store-open'
-import { deleteJournalRepairedSuffix } from './journal-repair-marker'
import { importPerSessionJournal, previewPerSessionJournal } from './journal-per-session-import'
import { AgentSessionJournalError } from './journal-write-guards'
@@ -37,6 +37,7 @@ export async function restoreJournalStore(
if (!imported) {
throw new Error(`per-chat journal of ${host.identity.sessionId} was gone before its copy`)
}
+ failLoadOnJournalDamage(host.identity.sessionId, imported)
host.adopt(imported)
})
} else {
@@ -44,43 +45,24 @@ export async function restoreJournalStore(
await importPerSessionJournal(source)
}
return openJournalStoreState({
+ sessionId: host.identity.sessionId,
legacyDirectory: host.legacyDirectory,
- replay: () => {
- const loaded = preview ?? replayJournal(host.database().db, host.identity.sessionId)
- host.setOpenedCorrupt(loaded?.corrupt ?? false)
- return loaded
- },
- deleteSuffix: (fromSeq, contentFrom) =>
- deleteJournalRepairedSuffix({
- database: host.database(),
- sessionId: host.identity.sessionId,
- epoch: host.state().epoch,
- fromSeq,
- contentFrom,
- now: host.now()
- }),
+ replay: () => preview ?? replayJournal(host.database().db, host.identity.sessionId),
start: () => collaborators.epochController.start('session_created', 0),
- // `unreconcilable_prefix` is the durable statement that this epoch exists
- // because a repair emptied one: replay reads it back and keeps asking for
- // provider history until the timeline is rebuilt or the session writes.
- publishRepairEpoch: () =>
- collaborators.epochController.start('unreconcilable_prefix', host.state().highestFence),
adopt: host.adopt,
- // Repair and file-format notices are about the conversation, not any turn in it.
+ // File-format and roster notices are about the conversation, not any turn in it.
appendItem: (identity, body, fence) =>
host.journal().appendItem(identity, body, { fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE }),
agent: host.identity.agent,
highestFence: () => host.state().highestFence,
- malformedRows: host.malformedRows,
- setMalformedRows: host.setMalformedRows,
readOnly: host.readOnly
})
}
/**
* A newer Orca's database: each chat shows what this build can read of it, from the database or a
- * per-chat file never copied in, and nothing is written, copied, repaired or founded. Every write
- * the store is asked for is refused as read-only.
+ * per-chat file never copied in, and nothing is written, copied or founded. Every write the store
+ * is asked for is refused as read-only, and a newer build's database wins over damage in it.
*/
async function restoreFromNewerDatabase(
host: JournalStoreHost,
@@ -99,11 +81,9 @@ async function restoreFromNewerDatabase(
{ cause: error }
)
}
- host.setOpenedCorrupt(false)
host.adopt({
state: loaded?.state ?? createJournalReducerState(sessionId, ''),
readOnly: true,
- corrupt: false,
- malformedRows: 0
+ damage: null
})
}
diff --git a/src/main/native-chat/agent-session-journal/journal-store-schema.test.ts b/src/main/native-chat/agent-session-journal/journal-store-schema.test.ts
index da6ffc84febc..edd80e1e8962 100644
--- a/src/main/native-chat/agent-session-journal/journal-store-schema.test.ts
+++ b/src/main/native-chat/agent-session-journal/journal-store-schema.test.ts
@@ -224,7 +224,7 @@ describe('axis 2: the row body shape', () => {
})
})
- it('skips a malformed row without giving up the journal, and discloses the skip', async () => {
+ it('refuses a journal holding a row that is not a row, and keeps that row', async () => {
const journal = await open()
await journal.appendItem(item(0), body('a'), {
fence: 1,
@@ -235,39 +235,16 @@ describe('axis 2: the row body shape', () => {
await journal.close()
await appendRawRow(epoch, nextSeq, '{not json')
- const reopened = await open()
- expect(reopened.isReadOnly).toBe(false)
- const items = reopened.snapshot().items
- // The surviving row is untouched…
- expect(items.some((entry) => entry.body.kind === 'message')).toBe(true)
- // …and the skip is visible in the timeline instead of silently swallowed.
- expect(
- items.some(
- (entry) => entry.body.kind === 'status' && entry.body.text.includes('could not be read')
- )
- ).toBe(true)
- })
-
- it('keeps one disclosure row across reopens instead of stacking duplicates', async () => {
- const journal = await open()
- await journal.appendItem(item(0), body('a'), {
- fence: 1,
- turnScope: AGENT_JOURNAL_THREAD_SCOPE
+ for (let attempt = 0; attempt < 2; attempt += 1) {
+ await expect(open()).rejects.toMatchObject({
+ refusal: { code: 'agent_session_journal_unreadable', details: { reason: 'journalCorrupt' } }
+ })
+ }
+ await withDatabase((db) => {
+ const stored = liveTestJournalRows(db, IDENTITY.sessionId)
+ expect(stored.map((row) => row.seq)).toEqual([1, 2, nextSeq])
+ expect(stored.at(-1)?.rowJson).toBe('{not json')
})
- const epoch = journal.epoch
- const nextSeq = journal.cursor().sequence + 1
- await journal.close()
- await appendRawRow(epoch, nextSeq, '{not json')
-
- await open().then((first) => first.close())
- const reopened = await open()
- expect(
- reopened
- .snapshot()
- .items.filter(
- (entry) => entry.body.kind === 'status' && entry.body.text.includes('could not be read')
- )
- ).toHaveLength(1)
})
it('reopens a journal holding an admitted malformed-percent item id without throwing', async () => {
diff --git a/src/main/native-chat/agent-session-journal/journal-store.test.ts b/src/main/native-chat/agent-session-journal/journal-store.test.ts
index 871aca7880dc..fcb541b8faa8 100644
--- a/src/main/native-chat/agent-session-journal/journal-store.test.ts
+++ b/src/main/native-chat/agent-session-journal/journal-store.test.ts
@@ -294,7 +294,7 @@ describe('replay', () => {
expect(reopened.snapshot().items).toHaveLength(0)
})
- it('keeps the intact prefix and drops the rejected suffix', async () => {
+ it('refuses a journal with a gap and keeps every row', async () => {
const journal = await open()
for (let index = 0; index < 4; index += 1) {
await journal.appendItem(item(index), body(`m${index}`), {
@@ -302,22 +302,18 @@ describe('replay', () => {
turnScope: AGENT_JOURNAL_THREAD_SCOPE
})
}
- const before = journal.epoch
await journal.close()
await withJournalDatabase(root, (db) => {
deleteTestJournalRow(db, IDENTITY.sessionId, 3)
})
- const reopened = await open()
- expect(reopened.epoch).toBe(before)
- expect(reopened.snapshot().items.map((entry) => entry.body)).toEqual([body('m0')])
- // Sequences 4 and 5 are VALID rows that the gap at 3 made unreplayable.
- // Nothing preserves them; recovery rebuilds the epoch from provider history.
+ await expect(open()).rejects.toMatchObject({
+ refusal: { details: { reason: 'journalCorrupt' } }
+ })
await withJournalDatabase(root, (db) => {
const rows = liveTestJournalRows(db, IDENTITY.sessionId)
- expect(rows.map((row) => row.seq)).toEqual([1, 2])
+ expect(rows.map((row) => row.seq)).toEqual([1, 2, 4, 5])
})
- expect(reopened.repair).toEqual({ malformedRows: 0 })
})
})
diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts
index 3de15cc3df60..55972aac2d8c 100644
--- a/src/main/native-chat/agent-session-journal/journal-store.ts
+++ b/src/main/native-chat/agent-session-journal/journal-store.ts
@@ -82,8 +82,6 @@ export class AgentSessionJournal {
private state: JournalReducerState
private readOnly = false
- private malformedRows = 0
- private openedCorrupt = false
private openedThrough: AgentJournalCursor = { epoch: '', sequence: 0 }
private onCommitted: (() => void) | null = null
private readonly queue: JournalWriteQueue
@@ -127,14 +125,7 @@ export class AgentSessionJournal {
applyJournalRow(this.state, row)
this.onCommitted?.()
},
- setOpenedCorrupt: (corrupt) => {
- this.openedCorrupt = corrupt
- },
notifyCommitted: () => this.onCommitted?.(),
- malformedRows: () => this.malformedRows,
- setMalformedRows: (count) => {
- this.malformedRows = count
- },
journal: () => this,
enqueue: (build) => this.rowWriter.enqueue(build)
})
@@ -165,16 +156,6 @@ export class AgentSessionJournal {
)
}
- /** What the last open's repair did. */
- get repair(): { malformedRows: number } {
- return { malformedRows: this.malformedRows }
- }
-
- /** The open replayed an unusable prefix: the chat is owed a rebuild from provider history. */
- get needsRebuild(): boolean {
- return this.openedCorrupt
- }
-
async open(): Promise {
await this.restore()
this.openedThrough = this.cursor()
diff --git a/src/main/native-chat/agent-session-journal/journal-streaming-replay.test.ts b/src/main/native-chat/agent-session-journal/journal-streaming-replay.test.ts
index c000ffe9fbae..73e66401c594 100644
--- a/src/main/native-chat/agent-session-journal/journal-streaming-replay.test.ts
+++ b/src/main/native-chat/agent-session-journal/journal-streaming-replay.test.ts
@@ -117,21 +117,20 @@ describe('streaming journal replay', () => {
expect(checkpoints.map((entry) => entry.busy)).toEqual([0, 0])
})
- it('keeps the prefix but latches read-only for a future row beyond a gap', () => {
+ it('reads past a gap to a future row, which latches read-only with no damage named', () => {
put(anchor())
put(revision(2))
put(revision(4))
put({ ...revision(5), v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION + 1 })
const loaded = replayJournal(opened.db, sessionId)!
- expect(loaded).toMatchObject({ readOnly: true, corrupt: true, malformedRows: 0 })
- expect(loaded.truncateFrom).toBeUndefined()
+ expect(loaded).toMatchObject({ readOnly: true, damage: null })
expect(loaded.state.items.get('message-1')?.revision).toBe(2)
expect(loaded.state.lastSequence).toBe(2)
const checkpoint = opened.db.pragma('wal_checkpoint(TRUNCATE)') as { busy: number }[]
expect(checkpoint[0].busy).toBe(0)
})
- it('keeps gap repair precedence when a later row is malformed', () => {
+ it('names the first damage when a later row is malformed too', () => {
put(anchor())
put(revision(2))
put(revision(4))
@@ -139,18 +138,19 @@ describe('streaming journal replay', () => {
const loaded = replayJournal(opened.db, sessionId)!
expect(loaded).toMatchObject({
readOnly: false,
- corrupt: true,
- malformedRows: 1,
- truncateFrom: 4
+ damage: { sequence: 3, cause: 'sequence-gap' }
})
expect(loaded.state.lastSequence).toBe(2)
})
- it('rejects an unanchored prefix before a later gap', () => {
+ it('names a missing epoch row before a later gap', () => {
put(revision(1))
put(revision(3))
const loaded = replayJournal(opened.db, sessionId)!
- expect(loaded).toMatchObject({ readOnly: false, corrupt: true, truncateFrom: 1 })
+ expect(loaded).toMatchObject({
+ readOnly: false,
+ damage: { sequence: 1, cause: 'no-epoch-row' }
+ })
expect(loaded.state.items.size).toBe(0)
expect(loaded.state.lastSequence).toBe(0)
})
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 fabb31dfe192..9cdcc55fec2e 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 a newer build wrote is never repaired away. A body of a kind
-// this build does not know (or a plan subject of one) is kept and the chat stays writable; a
-// mutation or sent message of a newer kind latches the chat read-only with every row kept as it
-// was, even beside damage. Anything else that fails is damage, repaired as before.
+// Inside a row of a known kind, what a newer build wrote is never damage. A body of a kind this
+// build does not know (or a plan subject of one) is kept and the chat stays writable; a mutation or
+// sent message of a newer kind latches the chat read-only with every row kept as it was, even
+// beside damage. Anything else that fails is damage: the chat fails to load, every row kept.
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
@@ -113,7 +113,6 @@ const PLAN_APPROVAL = {
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' })
@@ -125,12 +124,11 @@ async function expectReadOnlyAndKept(written: { seq: number; rowJson: string }[]
expect(stored()).toEqual(written)
}
-/** Opens writable with nothing repaired, keeps the newer item in the snapshot, takes a write,
- * and leaves every earlier row byte-identical. */
+/** Opens writable, keeps the newer item in the snapshot, takes a write, and leaves every earlier
+ * row byte-identical. */
async function expectWritableAndKept(written: { seq: number; rowJson: string }[], itemId: string) {
const journal = await open()
expect(journal.isReadOnly).toBe(false)
- expect(journal.repair).toEqual({ malformedRows: 0 })
expect(journal.snapshot().items.some((entry) => entry.itemId === itemId)).toBe(true)
await journal.appendItem(item(5), { kind: 'status', text: 'taken' }, SCOPE)
await journals.closeAll()
@@ -242,7 +240,6 @@ describe("a turn's context usage of a newer shape", () => {
])
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',
@@ -265,20 +262,18 @@ describe('damage', () => {
{ 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)
+ ])('fails the load from %s, and every row is kept', async (_name, body) => {
+ const { written } = await journalWith((epoch) => [
+ itemRow(epoch, 'codex:thread-1:turn-1:2', body)
+ ])
+ for (let attempt = 0; attempt < 2; attempt += 1) {
+ await expect(open()).rejects.toMatchObject({
+ refusal: {
+ code: 'agent_session_journal_unreadable',
+ details: { reason: 'journalCorrupt' }
+ }
+ })
}
- )
+ expect(stored()).toEqual(written)
+ })
})
diff --git a/src/main/native-chat/agent-session-journal/journal-unknown-row-kind.test.ts b/src/main/native-chat/agent-session-journal/journal-unknown-row-kind.test.ts
index 794ca52ab7d5..28135b7204e1 100644
--- a/src/main/native-chat/agent-session-journal/journal-unknown-row-kind.test.ts
+++ b/src/main/native-chat/agent-session-journal/journal-unknown-row-kind.test.ts
@@ -1,6 +1,6 @@
-// A newer build's row kind is never repaired away: like a newer row version, it latches this build
+// A newer build's row kind is never damage: like a newer row version, it latches this build
// read-only with every row kept, so the newer build still reads the whole chat after an upgrade.
-// A row whose stored sequence and body disagree is damage at its stored sequence.
+// A row whose stored sequence and body disagree is damage, which fails the load with every row kept.
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
@@ -21,6 +21,10 @@ import {
} from './journal-host-database-test-support'
import { parseJournalRow, type JournalRow } from './journal-row-schema'
+const UNLOADABLE = {
+ refusal: { code: 'agent_session_journal_unreadable', details: { reason: 'journalCorrupt' } }
+}
+
const IDENTITY: AgentSessionJournalIdentity = {
sessionId: 'session-newer-kind',
workspaceId: 'ws-1',
@@ -202,7 +206,6 @@ describe("a journal holding a newer build's row kind", () => {
const journal = await restartAfter((put) => put(3, newer))
const latched = stored()
expect(journal.isReadOnly).toBe(true)
- expect(journal.repair).toEqual({ malformedRows: 0 })
expect(itemIds(journal)).toEqual(['codex:thread-1:turn-1:0'])
await expect(
journal.appendItem(item(4), { kind: 'status', text: 'after' }, SCOPE)
@@ -232,18 +235,15 @@ describe("a journal holding a newer build's row kind", () => {
])
})
- it('reads a newer kind whose envelope is broken as malformed and drops from it', async () => {
+ it('reads a newer kind whose envelope is broken as damage, and keeps it', async () => {
const epoch = await journalWithOneItem()
- const journal = await restartAfter((put) =>
- put(3, JSON.stringify(newerRow(epoch, 3, { fence: 'one' })))
- )
- expect(journal.isReadOnly).toBe(false)
- expect(journal.repair).toEqual({ malformedRows: 1 })
- // 3 is the disclosure the repair appends where the broken row was.
+ await expect(
+ restartAfter((put) => put(3, JSON.stringify(newerRow(epoch, 3, { fence: 'one' }))))
+ ).rejects.toMatchObject(UNLOADABLE)
expect(storedKinds()).toEqual([
[1, 'epoch'],
[2, 'item'],
- [3, 'item']
+ [3, 'future-mark']
])
})
})
@@ -252,25 +252,16 @@ describe('a row whose body names another sequence than its stored key', () => {
it.each([
['an earlier', 2],
['a later', 9]
- ])(
- 'is dropped at its key when it names %s one, and the next write succeeds',
- async (_name, seq) => {
- const epoch = await journalWithOneItem()
- const tombstone = { ...newerRow(epoch, seq), kind: 'tombstone', itemId: 'gone', revision: 1 }
- const journal = await restartAfter((put) => put(3, JSON.stringify(tombstone)))
- expect(journal.repair).toEqual({ malformedRows: 1 })
- expect(journal.needsRebuild).toBe(true)
- // The valid row at 2 stays; 3 is the disclosure the repair appends.
- expect(storedKinds()).toEqual([
- [1, 'epoch'],
- [2, 'item'],
- [3, 'item']
- ])
-
- await journal.appendItem(item(1), { kind: 'status', text: 'after' }, SCOPE)
- const reopened = await restartAfter()
- expect(reopened.cursor().sequence).toBe(4)
- expect(itemIds(reopened)).toHaveLength(3)
- }
- )
+ ])('fails the load and is kept when it names %s one', async (_name, seq) => {
+ const epoch = await journalWithOneItem()
+ const tombstone = { ...newerRow(epoch, seq), kind: 'tombstone', itemId: 'gone', revision: 1 }
+ await expect(restartAfter((put) => put(3, JSON.stringify(tombstone)))).rejects.toMatchObject(
+ UNLOADABLE
+ )
+ expect(storedKinds()).toEqual([
+ [1, 'epoch'],
+ [2, 'item'],
+ [3, 'tombstone']
+ ])
+ })
})
diff --git a/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts b/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts
deleted file mode 100644
index 52fc3c86b9b6..000000000000
--- a/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts
+++ /dev/null
@@ -1,574 +0,0 @@
-import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types'
-// Recovery drives the real journal loader against real on-disk damage: a hole
-// punched in the row sequence, and a row stamped with a schema this host cannot
-// read — on both version axes, because only one of them is detectable before a
-// read.
-
-import { createHash } from 'node:crypto'
-import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
-import { tmpdir } from 'node:os'
-import { join } from 'node:path'
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import type {
- AgentJournalItemIdentity,
- AgentSessionJournalIdentity
-} from '../../../shared/agent-session-journal-types'
-import { JOURNAL_DB_SCHEMA_VERSION } from '../agent-session-journal/journal-database-schema'
-import { journalDatabasePath } from '../agent-session-journal/journal-host-database'
-import { replayJournal } from '../agent-session-journal/journal-open'
-import {
- closeTestJournalHostDatabases,
- createTrackedJournalOpener,
- openTestJournalHostDatabase,
- loadTestJournal,
- readTestJournalRows,
- deleteTestJournalRow,
- insertTestJournalRowJson,
- updateTestJournalRowJson
-} from '../agent-session-journal/journal-host-database-test-support'
-import type Database from '../../sqlite/sync-database'
-import {
- openAgentSessionJournalWithRecovery,
- providerHistoryId
-} from './agent-session-journal-recovery'
-import { JOURNAL_NEWER_SCHEMA_MESSAGE } from '../agent-session-journal/journal-open-failure'
-import { performSend, type AgentSessionTurnContext } from './structured-agent-session-turns'
-
-// The store's replay goes through the mock: it is the only read of the journal an open makes.
-vi.mock('../agent-session-journal/journal-open', async (importOriginal) => {
- const actual = await importOriginal<{ replayJournal: typeof replayJournal }>()
- return { ...actual, replayJournal: vi.fn(actual.replayJournal) }
-})
-
-const CODEX_SESSION = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60'
-
-const IDENTITY: AgentSessionJournalIdentity = {
- sessionId: CODEX_SESSION,
- workspaceId: 'ws-1',
- hostId: 'host-1',
- agent: 'codex',
- providerHandle: { kind: 'codex', threadId: CODEX_SESSION }
-}
-
-const CODEX_LINES = [
- {
- type: 'session_meta',
- timestamp: '2026-08-05T10:00:00.000Z',
- payload: {
- id: CODEX_SESSION,
- session_id: CODEX_SESSION,
- cwd: '/Users/dev/project',
- originator: 'codex_cli_rs',
- cli_version: '0.146.1'
- }
- },
- {
- type: 'event_msg',
- timestamp: '2026-08-05T10:00:02.000Z',
- payload: { type: 'user_message', message: 'add a retry', kind: 'plain' }
- },
- {
- type: 'event_msg',
- timestamp: '2026-08-05T10:00:05.000Z',
- payload: { type: 'agent_message', message: 'On it.' }
- }
-]
-
-let root: string
-let journalDir: string
-let historyFilePath: string
-const journals = createTrackedJournalOpener()
-
-function item(ordinal: number): AgentJournalItemIdentity {
- return { provider: 'codex', threadId: CODEX_SESSION, turnId: 'turn-1', ordinal }
-}
-
-/** Fills a journal with `count` items and hands back its epoch. */
-async function seedJournal(count: number): Promise {
- const journal = await journals.open({ identity: IDENTITY, stateDirectory: journalDir })
- for (let ordinal = 1; ordinal <= count; ordinal += 1) {
- await journal.appendItem(
- item(ordinal),
- { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: `item-${ordinal}` }] },
- { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
- )
- }
- const epoch = journal.epoch
- await journal.close()
- return epoch
-}
-
-/** A journal whose epoch row is gone: every surviving row is unanchored, so a
- * repair has to set aside the whole range. */
-async function seedRepairableSession(): Promise {
- const journal = await journals.open({ identity: IDENTITY, stateDirectory: journalDir })
- await journal.appendSubmission({
- clientMessageId: 'client-message-1',
- payloadFingerprint: 'fingerprint-1',
- body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'add a retry' }] },
- fence: 1
- })
- await journal.resolveDispatch({
- clientMessageId: 'client-message-1',
- state: 'accepted',
- providerIdentity: item(1),
- fence: 1
- })
- await journal.close()
- await deleteRow(1)
-}
-
-async function withJournalDatabase(
- directory: string,
- run: (db: Database.Database) => void
-): Promise {
- run(openTestJournalHostDatabase(directory).db)
-}
-
-async function digest(path: string): Promise {
- return createHash('sha256')
- .update(await readFile(path))
- .digest('hex')
-}
-
-/** The same logical hole `findSequenceGap` detects at replay. */
-async function deleteRow(seq: number): Promise {
- await withJournalDatabase(journalDir, (db) => {
- deleteTestJournalRow(db, CODEX_SESSION, seq)
- })
-}
-
-beforeEach(async () => {
- root = await mkdtemp(join(tmpdir(), 'orca-wire-recovery-'))
- journalDir = join(root, 'journal')
- historyFilePath = join(root, 'rollout.jsonl')
- await writeFile(
- historyFilePath,
- `${CODEX_LINES.map((line) => JSON.stringify(line)).join('\n')}\n`,
- 'utf-8'
- )
-})
-
-afterEach(async () => {
- await journals.closeAll()
- await rm(root, { recursive: true, force: true })
-})
-
-describe('providerHistoryId', () => {
- it('uses the provider handle, never the Orca session id', () => {
- expect(providerHistoryId({ kind: 'codex', threadId: 'thread-9' })).toBe('thread-9')
- expect(providerHistoryId({ kind: 'claude', sessionId: 'sess-9', leafUuid: null })).toBe(
- 'sess-9'
- )
- })
-})
-
-describe('openAgentSessionJournalWithRecovery', () => {
- it('opens a healthy journal untouched', async () => {
- await seedJournal(2)
- const opened = journals.track(
- await openAgentSessionJournalWithRecovery({
- identity: IDENTITY,
- database: openTestJournalHostDatabase(journalDir),
- fence: 1,
- historyFilePath
- }).then((result) => result.journal)
- )
- expect(opened.snapshot().items).toHaveLength(2)
- })
-
- it("reads the journal once: the store's replay is the open", async () => {
- await seedJournal(2)
- vi.mocked(replayJournal).mockClear()
- const opened = journals.track(
- await openAgentSessionJournalWithRecovery({
- identity: IDENTITY,
- database: openTestJournalHostDatabase(journalDir),
- fence: 1,
- historyFilePath
- }).then((result) => result.journal)
- )
- expect(opened.snapshot().items).toHaveLength(2)
- expect(replayJournal).toHaveBeenCalledOnce()
- })
-
- it('rebuilds a holed journal in place on a fresh epoch', async () => {
- await seedJournal(3)
- await deleteRow(3)
-
- const opened = await openAgentSessionJournalWithRecovery({
- identity: IDENTITY,
- database: openTestJournalHostDatabase(journalDir),
- fence: 1,
- historyFilePath
- })
- journals.track(opened.journal)
- expect(opened.recovery).toMatchObject({ trigger: 'journal_corrupt', reset: 'epoch_changed' })
- expect(opened.recovery?.imported).toBeGreaterThan(0)
- expect(opened.journal.isReadOnly).toBe(false)
- // The rebuilt timeline is the only content of its epoch — nothing from the
- // damaged prefix survives into it.
- const texts = opened.journal.snapshot().items.map((entry) => JSON.stringify(entry.body))
- expect(texts.some((text) => text.includes('item-1'))).toBe(false)
- expect(texts.some((text) => text.includes('add a retry'))).toBe(true)
- })
-
- // A newer build's row is never rewritten under it, and there is no sibling to write instead:
- // the chat reads what it can and refuses writes.
- it('opens a future row-body version read-only and leaves it exactly as found', async () => {
- const epoch = await seedJournal(1)
- await withJournalDatabase(journalDir, (db) => {
- insertTestJournalRowJson(
- db,
- CODEX_SESSION,
- 3,
- JSON.stringify({ v: 99, seq: 3, epoch, kind: 'item', fence: 1, ts: 1 })
- )
- })
-
- const opened = await openAgentSessionJournalWithRecovery({
- identity: IDENTITY,
- database: openTestJournalHostDatabase(journalDir),
- fence: 1,
- historyFilePath
- })
- journals.track(opened.journal)
-
- expect(opened.recovery).toBeNull()
- expect(opened.journal.isReadOnly).toBe(true)
- // A send says to update, as a database a newer Orca wrote does.
- const sent = await performSend(
- // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a refused append returns before anything but the journal and fence is read.
- { sessionId: CODEX_SESSION, journal: opened.journal, fence: 1 } as AgentSessionTurnContext,
- {
- clientMessageId: 'client-after-downgrade',
- payloadFingerprint: 'fp',
- body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }
- }
- )
- expect(sent).toMatchObject({
- ok: false,
- refusal: {
- code: 'agent_session_journal_unreadable',
- details: { reason: 'journalWrittenByNewerOrca' },
- message: JOURNAL_NEWER_SCHEMA_MESSAGE
- }
- })
- await withJournalDatabase(journalDir, (db) => {
- const rows = readTestJournalRows(db, CODEX_SESSION, epoch)
- expect(rows.some((entry) => entry.rowJson.includes('"v":99'))).toBe(true)
- expect(rows).toHaveLength(3)
- })
- })
-
- // A downgrade: the database a newer Orca stamped opens read-only. The chat shows its history, a
- // send says to update, and the file is left byte-identical.
- it('shows a chat from a database a newer Orca stamped, and a send says to update', async () => {
- await seedJournal(2)
- await withJournalDatabase(journalDir, (db) => {
- db.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION + 1}`)
- })
- closeTestJournalHostDatabases()
- const path = journalDatabasePath(journalDir)
- const before = await digest(path)
-
- const opened = await openAgentSessionJournalWithRecovery({
- identity: IDENTITY,
- database: openTestJournalHostDatabase(journalDir),
- fence: 1,
- historyFilePath
- })
- journals.track(opened.journal)
-
- expect(opened.recovery).toBeNull()
- expect(opened.journal.isReadOnly).toBe(true)
- const texts = opened.journal.snapshot().items.map((entry) => JSON.stringify(entry.body))
- expect(texts.filter((text) => /item-[12]/.test(text))).toHaveLength(2)
- const sent = await performSend(
- // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a refused append returns before anything but the journal and fence is read.
- { sessionId: CODEX_SESSION, journal: opened.journal, fence: 1 } as AgentSessionTurnContext,
- {
- clientMessageId: 'client-after-downgrade',
- payloadFingerprint: 'fp',
- body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }
- }
- )
- expect(sent).toMatchObject({
- ok: false,
- refusal: {
- code: 'agent_session_journal_unreadable',
- details: { reason: 'journalWrittenByNewerOrca' },
- message: JOURNAL_NEWER_SCHEMA_MESSAGE
- }
- })
- await journals.closeAll()
- expect(await digest(path)).toBe(before)
- })
-
- // T-logical-per-chat: one file holds every chat, and damage one chat's replay finds is still
- // that chat's alone. Only it rebuilds; a neighbour's rows and epoch are not touched.
- it('rebuilds only the damaged chat and leaves another chat in the same file untouched', async () => {
- const neighbour: AgentSessionJournalIdentity = {
- ...IDENTITY,
- sessionId: 'neighbour-session',
- providerHandle: { kind: 'codex', threadId: 'neighbour-thread' }
- }
- const other = await journals.open({ identity: neighbour, stateDirectory: journalDir })
- for (let ordinal = 1; ordinal <= 3; ordinal += 1) {
- await other.appendItem(
- item(ordinal),
- { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: `n-${ordinal}` }] },
- { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
- )
- }
- const neighbourEpoch = other.epoch
- await other.close()
- const database = openTestJournalHostDatabase(journalDir)
- const neighbourBefore = readTestJournalRows(database.db, neighbour.sessionId, neighbourEpoch)
- await seedJournal(3)
- await withJournalDatabase(journalDir, (db) => {
- updateTestJournalRowJson(db, CODEX_SESSION, 3, '}{')
- })
-
- const opened = await openAgentSessionJournalWithRecovery({
- identity: IDENTITY,
- database,
- fence: 1,
- historyFilePath
- })
- journals.track(opened.journal)
-
- expect(opened.recovery).toMatchObject({ trigger: 'journal_corrupt', reset: 'epoch_changed' })
- expect(opened.recovery?.imported).toBeGreaterThan(0)
- const reopened = await journals.open({ identity: neighbour, stateDirectory: journalDir })
- expect(reopened.epoch).toBe(neighbourEpoch)
- expect(readTestJournalRows(database.db, neighbour.sessionId, neighbourEpoch)).toEqual(
- neighbourBefore
- )
- })
-
- // The rehydrate deletes every live row to publish its replacement epoch, so
- // everything replay rejected is gone for good by the time the import runs.
- // Orca minted the submission, receipt and lifecycle identities; no provider
- // transcript can hand them back.
- it('rebuilds from provider history when the epoch row itself is gone', async () => {
- const journal = await journals.open({ identity: IDENTITY, stateDirectory: journalDir })
- await journal.appendSubmission({
- clientMessageId: 'client-message-1',
- payloadFingerprint: 'fingerprint-1',
- body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'add a retry' }] },
- fence: 1
- })
- await journal.resolveDispatch({
- clientMessageId: 'client-message-1',
- state: 'accepted',
- providerIdentity: item(1),
- fence: 1
- })
- await journal.appendLifecycleBatch({
- settlementId: 'settlement-1',
- fence: 1,
- mutations: [
- {
- kind: 'item',
- identity: { provider: 'orca', clientMessageId: 'approval-1' },
- body: { kind: 'status', text: 'approved' },
- turnScope: AGENT_JOURNAL_THREAD_SCOPE
- }
- ]
- })
- await journal.close()
- // Sequence 1 is the epoch row: everything behind it is valid but unanchored.
- await deleteRow(1)
-
- const opened = await openAgentSessionJournalWithRecovery({
- identity: IDENTITY,
- database: openTestJournalHostDatabase(journalDir),
- fence: 1,
- historyFilePath
- })
- journals.track(opened.journal)
- expect(opened.recovery).toMatchObject({ trigger: 'journal_corrupt' })
- expect(opened.recovery?.imported).toBeGreaterThan(0)
- expect(JSON.stringify(opened.journal.snapshot().items.map((entry) => entry.body))).toContain(
- 'add a retry'
- )
- })
-
- it('still opens the session when provider history cannot be read', async () => {
- await seedJournal(3)
- await deleteRow(3)
-
- const opened = await openAgentSessionJournalWithRecovery({
- identity: IDENTITY,
- database: openTestJournalHostDatabase(journalDir),
- fence: 1,
- historyFilePath: join(root, 'missing.jsonl')
- })
- journals.track(opened.journal)
- expect(opened.recovery).toMatchObject({ trigger: 'journal_corrupt', imported: 0 })
- expect(opened.recovery?.error).toBeTruthy()
- // A missing provider transcript must not clear the intact journal prefix.
- expect(opened.journal.snapshot().items.map((entry) => entry.body.kind)).toEqual(['message'])
- })
-
- // A repair that KEEPS a prefix has no emptied epoch to anchor, so nothing
- // about the surviving rows records that the deleted suffix was never rebuilt.
- // Unmarked, the next probe reads a contiguous anchored prefix, calls it clean,
- // and the dropped stretch of timeline is gone for good.
- it('keeps a partially repaired journal corrupt until provider history replaces it', async () => {
- await seedJournal(3)
- await deleteRow(3)
- const empty = join(root, 'empty.jsonl')
- await writeFile(empty, '', 'utf-8')
-
- const first = await openAgentSessionJournalWithRecovery({
- identity: IDENTITY,
- database: openTestJournalHostDatabase(journalDir),
- fence: 1,
- historyFilePath: empty
- })
- journals.track(first.journal)
- expect(first.recovery).toMatchObject({ trigger: 'journal_corrupt', imported: 0 })
- expect(first.recovery?.error).toBeTruthy()
- // Only the unanchored suffix went; the prefix the repair kept is still live.
- expect(first.journal.snapshot().items.map((entry) => entry.body.kind)).toEqual(['message'])
- await first.journal.close()
-
- // The deletion is durable, so the demand for a rebuild has to be too.
- expect(await loadTestJournal(journalDir, CODEX_SESSION)).toMatchObject({ corrupt: true })
-
- // A readable transcript rebuilds the epoch, and THAT is what retires it.
- const retried = await openAgentSessionJournalWithRecovery({
- identity: IDENTITY,
- database: openTestJournalHostDatabase(journalDir),
- fence: 1,
- historyFilePath
- })
- journals.track(retried.journal)
- expect(retried.recovery?.imported).toBeGreaterThan(0)
- await retried.journal.close()
- expect(await loadTestJournal(journalDir, CODEX_SESSION)).toMatchObject({ corrupt: false })
- })
-
- // The reproduced path. Deleting sequence 1 leaves every surviving row
- // unanchored, so the repair drops ALL of them — and provider history is not
- // there to publish a replacement. A journal in that state used to reopen as
- // clean: an append took sequence 1 as an ordinary row, replay accepted it,
- // and recovery never asked the provider for the timeline again.
- it('does not normalize an epoch a repair emptied while provider history was unavailable', async () => {
- await seedRepairableSession()
- const missing = join(root, 'missing.jsonl')
-
- const first = await openAgentSessionJournalWithRecovery({
- identity: IDENTITY,
- database: openTestJournalHostDatabase(journalDir),
- fence: 1,
- historyFilePath: missing
- })
- journals.track(first.journal)
- expect(first.recovery?.error).toBeTruthy()
- expect(first.recovery?.imported).toBe(0)
- await first.journal.close()
-
- // Reopen: the epoch still holds nothing but the repair, so recovery runs again.
- expect(await loadTestJournal(journalDir, CODEX_SESSION)).toMatchObject({ corrupt: true })
- const reopened = await openAgentSessionJournalWithRecovery({
- identity: IDENTITY,
- database: openTestJournalHostDatabase(journalDir),
- fence: 1,
- historyFilePath: missing
- })
- journals.track(reopened.journal)
- expect(reopened.recovery).toMatchObject({ trigger: 'journal_corrupt', imported: 0 })
- // Nothing but the anchor: the repair rebuilt no history of its own.
- expect(reopened.journal.snapshot().items).toEqual([])
-
- // The append lands ABOVE the epoch anchor, never on top of it.
- await reopened.journal.appendItem(
- item(2),
- { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'typed later' }] },
- { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
- )
- const epoch = reopened.journal.epoch
- await reopened.journal.close()
- await withJournalDatabase(journalDir, (db) => {
- const rows = readTestJournalRows(db, CODEX_SESSION, epoch)
- expect(JSON.parse(rows[0]?.rowJson ?? '{}')).toMatchObject({ kind: 'epoch', seq: 1 })
- })
- })
-
- // An empty transcript is a plausible transient provider state, and it used to
- // end recovery for good: the import published an empty replacement epoch that
- // deleted the repair's anchor, the next probe called that clean, and the user's
- // timeline was never rebuilt.
- it('does not retire the repair marker when provider history exists but holds no messages', async () => {
- await seedRepairableSession()
- const empty = join(root, 'empty.jsonl')
- await writeFile(empty, '', 'utf-8')
-
- const first = await openAgentSessionJournalWithRecovery({
- identity: IDENTITY,
- database: openTestJournalHostDatabase(journalDir),
- fence: 1,
- historyFilePath: empty
- })
- journals.track(first.journal)
- expect(first.recovery).toMatchObject({ trigger: 'journal_corrupt', imported: 0 })
- expect(first.recovery?.error).toBeTruthy()
- // The anchor the repair published is still the epoch; the empty import did
- // not replace it with a clean one.
- expect(first.journal.snapshot().items).toEqual([])
- const epoch = first.journal.epoch
- await first.journal.close()
- await withJournalDatabase(journalDir, (db) => {
- const rows = readTestJournalRows(db, CODEX_SESSION, epoch)
- expect(JSON.parse(rows[0]?.rowJson ?? '{}')).toMatchObject({
- kind: 'epoch',
- seq: 1,
- reason: 'unreconcilable_prefix'
- })
- })
-
- // The session still reports corrupt, so the next attach retries.
- expect(await loadTestJournal(journalDir, CODEX_SESSION)).toMatchObject({ corrupt: true })
-
- // And a transcript that DOES have content still rebuilds the timeline.
- const retried = await openAgentSessionJournalWithRecovery({
- identity: IDENTITY,
- database: openTestJournalHostDatabase(journalDir),
- fence: 1,
- historyFilePath
- })
- journals.track(retried.journal)
- expect(retried.recovery?.imported).toBeGreaterThan(0)
- expect(JSON.stringify(retried.journal.snapshot().items.map((entry) => entry.body))).toContain(
- 'add a retry'
- )
- })
-
- it('rebuilds the emptied epoch once provider history is readable again', async () => {
- await seedRepairableSession()
-
- const first = await openAgentSessionJournalWithRecovery({
- identity: IDENTITY,
- database: openTestJournalHostDatabase(journalDir),
- fence: 1,
- historyFilePath: join(root, 'missing.jsonl')
- })
- journals.track(first.journal)
- await first.journal.close()
-
- const retried = await openAgentSessionJournalWithRecovery({
- identity: IDENTITY,
- database: openTestJournalHostDatabase(journalDir),
- fence: 1,
- historyFilePath
- })
- journals.track(retried.journal)
- expect(retried.recovery?.imported).toBeGreaterThan(0)
- expect(JSON.stringify(retried.journal.snapshot().items.map((entry) => entry.body))).toContain(
- 'add a retry'
- )
- })
-})
diff --git a/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.ts b/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.ts
deleted file mode 100644
index 42da9a986dad..000000000000
--- a/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.ts
+++ /dev/null
@@ -1,99 +0,0 @@
-// Journal recovery: rehydrate a chat's timeline from provider history.
-//
-// One trigger: a chat whose replayed prefix is unusable (a gap, an unanchored prefix, a malformed
-// row). Its journal stays writable, so it is rebuilt in place on a fresh epoch, and only that chat
-// is touched. Damage SQLite itself reports is not recovered here: the open fails, and the chat
-// says it cannot be loaded.
-
-import type { AgentType } from '../../../shared/agent-status-types'
-import type {
- AgentJournalResetReason,
- AgentSessionJournalIdentity,
- AgentSessionProviderHandle
-} from '../../../shared/agent-session-journal-types'
-import type { JournalHostDatabase } from '../agent-session-journal/journal-host-database'
-import { importLegacyTranscriptIntoJournal } from '../agent-session-journal/journal-legacy-import'
-import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
-import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory'
-
-export type AgentSessionJournalRecovery = {
- trigger: 'journal_corrupt'
- /** What subscribers are told; it forces a clean snapshot reload. */
- reset: AgentJournalResetReason
- epoch: string
- imported: number
- /** Set when provider history could not be read, or held nothing to restore; the
- * intact journal prefix remains live. */
- error?: string
-}
-
-export type AgentSessionJournalOpened = {
- journal: AgentSessionJournal
- recovery: AgentSessionJournalRecovery | null
-}
-
-/** The provider's own session id, which is what the transcript readers index
- * by — never the Orca session id. */
-export function providerHistoryId(handle: AgentSessionProviderHandle): string {
- if (handle.kind === 'codex') {
- return handle.threadId
- }
- return handle.kind === 'claude' ? handle.sessionId : handle.value
-}
-
-export async function openAgentSessionJournalWithRecovery(input: {
- identity: AgentSessionJournalIdentity
- database: JournalHostDatabase
- fence: number
- /** Resolve directly to a transcript instead of discovering it by session id. */
- historyFilePath?: string | null
- deferPerSessionImport?: boolean
-}): Promise {
- const journal = await openAgentSessionJournal({
- identity: input.identity,
- database: input.database,
- deferPerSessionImport: input.deferPerSessionImport
- })
- if (!journal.needsRebuild) {
- return { journal, recovery: null }
- }
- // `open()` dropped the unusable suffix; a successful import rolls once more so
- // the rebuilt timeline is the only content of its epoch. A throw leaves nothing to release:
- // the store holds no connection.
- return { journal, recovery: await rehydrate({ ...input, journal, trigger: 'journal_corrupt' }) }
-}
-
-async function rehydrate(input: {
- identity: AgentSessionJournalIdentity
- journal: AgentSessionJournal
- fence: number
- historyFilePath?: string | null
- trigger: AgentSessionJournalRecovery['trigger']
-}): Promise {
- const reset: AgentJournalResetReason = 'epoch_changed'
- const result = await importLegacyTranscriptIntoJournal({
- journal: input.journal,
- agent: input.identity.agent satisfies AgentType,
- sessionId: providerHistoryId(input.identity.providerHandle),
- fence: input.fence,
- ...(input.historyFilePath ? { options: { filePath: input.historyFilePath } } : {})
- })
- // A transcript that held nothing is the same outcome as one that could not be
- // read: nothing was restored, so the repair's marker has to stand and be
- // retried on a later attach rather than being retired as a completed recovery.
- if (!result.ok || !result.replaced) {
- return {
- trigger: input.trigger,
- reset,
- epoch: input.journal.epoch,
- imported: 0,
- error: result.ok ? 'Provider history held no messages to restore' : result.error
- }
- }
- return {
- trigger: input.trigger,
- reset,
- epoch: result.epoch,
- imported: result.imported
- }
-}
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts
index c98854292d5c..d73cbb7e0bd6 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts
@@ -473,15 +473,8 @@ describe('structured session acquisition options', () => {
}
return cleanup
})
- const failingAdapter = {
- ...base,
- acquire,
- readOptions,
- releaseAcquisition,
- ...(failurePoint === 'journal'
- ? { historyFilePath: vi.fn().mockRejectedValueOnce(injected).mockResolvedValue(null) }
- : {})
- }
+ const failingAdapter = { ...base, acquire, readOptions, releaseAcquisition }
+ let journalFailure = failurePoint === 'journal' ? injected : null
const perform = (
target: AgentSessionRecordStore,
operationId: string,
@@ -491,10 +484,14 @@ describe('structured session acquisition options', () => {
logger: createStructuredAgentSessionLogger(),
store: target,
adapter: failingAdapter,
- openConversation: openTestAttachConversation(
- openTestJournalHostDatabase(root!),
- failingAdapter
- ),
+ openConversation: async (record) => {
+ const failure = journalFailure
+ journalFailure = null
+ if (failure) {
+ throw failure
+ }
+ return openTestAttachConversation(openTestJournalHostDatabase(root!))(record)
+ },
authority: {
spawnToken: operationId === CREATE_OPERATION ? 'spawn-a' : 'spawn-b',
claimKeyId: 'key-1',
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts
index b5d431c859a1..5edc3793d026 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts
@@ -163,9 +163,6 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi
readOptionRestoreFailures = (sessionId: string): readonly string[] =>
this.owner(sessionId).readOptionRestoreFailures?.(sessionId) ?? []
- historyFilePath = (input: { identity: AgentSessionJournalIdentity }) =>
- this.requireAgent(input.identity).historyFilePath?.(input) ?? Promise.resolve(null)
-
providerHistoryWindow = (input: {
identity: AgentSessionJournalIdentity
accountHome: AgentSessionAccountHome
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts
index 318bada1fe52..9c34c1841048 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts
@@ -363,9 +363,6 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop &
readOptions?(input: { sessionId: string; fence: number }): Promise
/** Option keys skipped after a provider rejected their persisted restore value. */
readOptionRestoreFailures?(sessionId: string): readonly string[]
- /** Transcript path for journal recovery. Omit to let the existing session-file
- * resolver discover it from the provider session id. */
- historyFilePath?(input: { identity: AgentSessionJournalIdentity }): Promise
/** Provider history for restart reconciliation, bounded to what the provider
* recorded after the journal's last committed item. Only the adapter can say
* whether the read has a proven start and whether a turn is still running, so
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-test-conversation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-test-conversation.ts
index 7a9f758858af..e6498597646f 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-test-conversation.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-test-conversation.ts
@@ -1,20 +1,18 @@
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import type { JournalHostDatabase } from '../agent-session-journal/journal-host-database'
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
-import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter'
import { openStructuredAgentSessionConversationJournal } from './structured-agent-session-conversation-open'
import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support'
/** For a test that attaches without a host: the conversation's journal, through the one open a
* host would take, so the attach under test adopts it the way it adopts the host's. */
export function openTestAttachConversation(
- journalDatabase: JournalHostDatabase,
- adapter: Pick = {}
+ journalDatabase: JournalHostDatabase
): (record: AgentSessionRecord) => Promise {
return async (record) =>
(
await openStructuredAgentSessionConversationJournal(
- { journalDatabase, adapter, logger: recordingStructuredAgentSessionLogger().logger },
+ { journalDatabase, logger: recordingStructuredAgentSessionLogger().logger },
record,
{
acquisition: true
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-command-publication.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-command-publication.test.ts
index a001eddb3f82..a18b29b8e921 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-command-publication.test.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-command-publication.test.ts
@@ -101,7 +101,7 @@ it('delivers catalog changes through existing frames without resending them on o
})
coalescer.flush()
expect(state.commands).toEqual(commands)
- subscribers.reset(sessionId, journal, 'epoch_changed', 8)
+ subscribers.snapshot(sessionId, journal, 8)
expect(state.commands).toEqual(commands)
commands = undefined
subscribers.open({
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts
index 73285ee1de4a..dc3d4fc42ae7 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts
@@ -1,17 +1,16 @@
// The one way a conversation's journal becomes open on this host: for a send, for a reader, and
// for an attach that finds none open.
//
-// It opens with recovery, so an unusable journal is rebuilt rather than refused, and it marks what
-// an earlier host process handed over and left unanswered as in doubt, and settles what it left
-// running — the crash boundary. That
+// A damaged journal fails the open, which refuses it as unloadable. It marks what an earlier host
+// process handed over and left unanswered as in doubt, and settles what it left running — the
+// crash boundary. That
// needs no lease: provider history decides such a row later, under a won lease, in the attach. A
// row an earlier process accepted and never handed over is the delivery loop's, which the open
// wakes. Nothing here starts a provider child.
-import type { AgentJournalResetReason } from '../../../shared/agent-session-journal-types'
import type { JournalHostDatabase } from '../agent-session-journal/journal-host-database'
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
-import { openAgentSessionJournalWithRecovery } from './agent-session-journal-recovery'
+import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory'
import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope'
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import {
@@ -20,7 +19,6 @@ import {
type AgentSessionAttachParams
} from './structured-agent-session-attach'
import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
-import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter'
import { settleStaleStructuredAgentSessionState } from './structured-agent-session-dead-generation-settlement'
import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation'
import type {
@@ -30,13 +28,10 @@ import type {
export type OpenedStructuredAgentSessionConversation = {
session: StructuredAgentSessionHostSession
- /** Set when the journal was rebuilt on the way; readers reload from a snapshot. */
- reset: AgentJournalResetReason | null
}
export type StructuredAgentSessionConversationOpenDeps = {
store: Pick
- adapter: Pick
journalDatabase: JournalHostDatabase
logger: StructuredAgentSessionHostDeps['logger']
}
@@ -92,17 +87,15 @@ export async function openStructuredAgentSessionConversationJournal(
expectedRuntimeFence: fence
})
const identity = journalIdentityFor(record, params)
- const opened = await openAgentSessionJournalWithRecovery({
+ const journal = await openAgentSessionJournal({
identity,
database: deps.journalDatabase,
- fence,
- historyFilePath: (await deps.adapter.historyFilePath?.({ identity })) ?? null,
deferPerSessionImport: options.deferPerSessionImport
})
try {
// A queued row found here is a leftover the delivery loop's first step rejects; a handed-over
// one is only doubt, which provider history decides under a won lease.
- await opened.journal.markPendingSubmissionsUnknown(fence)
+ await journal.markPendingSubmissionsUnknown(fence)
} catch (error) {
deps.logger.warn('marking pending sends unknown on open failed', {
scope: 'open-pending-unknown',
@@ -114,12 +107,9 @@ export async function openStructuredAgentSessionConversationJournal(
// belongs to a generation that is gone, whatever the lease still claims. Settled before any
// reader or child sees it.
if (!options.acquisition) {
- await settleGoneGeneration(deps, record, opened.journal)
- }
- return {
- session: { journal: opened.journal, params, child: null },
- reset: opened.recovery?.reset ?? null
+ await settleGoneGeneration(deps, record, journal)
}
+ return { session: { journal, params, child: null } }
}
/**
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts
index f2f885e07638..789bde694d07 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts
@@ -4,7 +4,6 @@
import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission'
import { abandonQueuedStructuredAgentSessionMessages } from './structured-agent-session-host-lifetime'
-import type { AgentJournalResetReason } from '../../../shared/agent-session-journal-types'
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
import {
openStructuredAgentSessionConversation,
@@ -54,7 +53,6 @@ export function createStructuredAgentSessionConversationDelivery(input: {
sessionId: string,
startedFor: string
) => Promise
- reset: (sessionId: string, journal: AgentSessionJournal, reset: AgentJournalResetReason) => void
clientDelivery: Pick
flushStreamedEvents: (sessionId: string) => Promise
}): StructuredAgentSessionConversationDelivery {
@@ -88,11 +86,8 @@ export function createStructuredAgentSessionConversationDelivery(input: {
sessionId: string,
opened: OpenedStructuredAgentSessionConversation
): Promise => {
- const { session, reset } = opened
+ const { session } = opened
sessions.set(sessionId, session)
- if (reset) {
- input.reset(sessionId, session.journal, reset)
- }
input.clientDelivery.publishRestored(sessionId)
await settleInterruptedCommands(deps, sessionId, session)
if (session.journal.submissions().some(isQueuedAgentJournalSubmission)) {
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts
index 44a576e2f781..b960bc66fb55 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts
@@ -126,13 +126,6 @@ export class StructuredAgentSessionHost {
trackStart: (start) => this.tasks.trackAttach(start),
ensureProviderChild: (sessionId, startedFor) =>
agentStart.ensureStructuredAgentSessionAgent(this.attachContext(), sessionId, startedFor),
- reset: (sessionId, journal, reset) =>
- this.subscribers.reset(
- sessionId,
- journal,
- reset,
- structuredAgentSessionConversationFence(deps.store, sessionId)
- ),
clientDelivery: this.clientDelivery,
flushStreamedEvents: (sessionId) => this.flushStreamedEvents(sessionId)
})
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts
index d5899c794e3c..fefd91a8478e 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts
@@ -349,9 +349,12 @@ describe('agentSessionRefusalOperationState host oracle', () => {
const unreadable = await createHarness()
await unreadable.host.close(SESSION, 'evict')
- unreadable.host.deps.adapter.historyFilePath = async () => {
- throw new Error('transcript unreadable')
- }
+ // The journal's open asks where the chat's per-chat file lives before it reads anything.
+ const unreadableOpen = vi
+ .spyOn(unreadable.host.deps.journalDatabase, 'legacyDirectoryFor')
+ .mockImplementation(() => {
+ throw new Error('journal path unreadable')
+ })
const unreadableSend = { method: 'agentSession.send' as const, operationId: operationId() }
record(
await assertHostAgreement(
@@ -359,7 +362,7 @@ describe('agentSessionRefusalOperationState host oracle', () => {
unreadableSend,
'agent_session_journal_unreadable',
async () => {
- delete unreadable.host.deps.adapter.historyFilePath
+ unreadableOpen.mockRestore()
return { harness: unreadable, spec: unreadableSend }
}
)
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts
index f14393df8740..429cb803794c 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts
@@ -131,15 +131,16 @@ function startAgent(): Promise {
describe('settled attach retry', () => {
it('settles a post-acquisition journal failure and retries without a restart', async () => {
- const historyFilePath = vi
- .fn>()
- .mockRejectedValueOnce(new Error('journal path unavailable'))
- .mockResolvedValue(null)
+ const journalDatabase = openTestJournalHostDatabase(root)
+ // The journal's open asks where the chat's per-chat file lives before it reads anything.
+ vi.spyOn(journalDatabase, 'legacyDirectoryFor').mockImplementationOnce(() => {
+ throw new Error('journal path unavailable')
+ })
host = new StructuredAgentSessionHost({
logger: createStructuredAgentSessionLogger(),
store,
- adapter: { ...adapter(), historyFilePath },
- journalDatabase: openTestJournalHostDatabase(root),
+ adapter: adapter(),
+ journalDatabase,
claimKeyId: 'key-1',
mintSpawnToken: () => 'spawn-a',
now: () => NOW
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts
index 3881e8c429f2..27ae0cb0608a 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts
@@ -125,7 +125,7 @@ describe('AgentSessionSubscribers', () => {
)
subscribers.publish(SESSION, journal)
subscribers.backgroundTasks(SESSION, null, 1)
- subscribers.reset(SESSION, journal, 'epoch_changed', 1)
+ subscribers.snapshot(SESSION, journal, 1)
expect(events.map((event) => ('hostNow' in event ? event.hostNow : null))).toEqual([
1_001, 1_002,
@@ -139,8 +139,8 @@ describe('AgentSessionSubscribers', () => {
'batch',
'batch',
'batch',
- 'reset',
- 'reset'
+ 'snapshot',
+ 'snapshot'
])
})
@@ -207,10 +207,9 @@ describe('AgentSessionSubscribers', () => {
})
subscribers.publish(SESSION, journal)
- subscribers.reset(SESSION, journal, 'epoch_changed', 1)
subscribers.snapshot(SESSION, journal, 1)
- expect(published).toEqual([SESSION, SESSION, SESSION])
+ expect(published).toEqual([SESSION, SESSION])
})
it('settles a session nobody is reading, from running to idle', async () => {
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts
index 2560ff1172d2..5795c3a729a9 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts
@@ -4,10 +4,7 @@
// ago gets a reset while a caught-up one gets a batch from the same publish.
// Nothing raw reaches a subscriber — every event carries reducer output.
-import type {
- AgentJournalCursor,
- AgentJournalResetReason
-} from '../../../shared/agent-session-journal-types'
+import type { AgentJournalCursor } from '../../../shared/agent-session-journal-types'
import type {
AgentSessionBackgroundTaskState,
AgentSessionSlashCommand,
@@ -141,39 +138,18 @@ export class AgentSessionSubscribers {
}
}
- /** Force every subscriber back to a bounded tail page — recovery, epoch
- * rollover, an unreadable schema. */
- reset(
- sessionId: string,
- journal: AgentSessionJournal,
- reason: AgentJournalResetReason,
- fence: number,
- backgroundTasks?: AgentSessionBackgroundTaskState | null
- ): void {
- this.replay(sessionId, journal, fence, backgroundTasks, { type: 'reset', reset: reason })
- }
-
+ /** Every subscriber back to a bounded tail page. */
snapshot(
sessionId: string,
journal: AgentSessionJournal,
fence: number,
backgroundTasks?: AgentSessionBackgroundTaskState | null
- ): void {
- this.replay(sessionId, journal, fence, backgroundTasks, { type: 'snapshot' })
- }
-
- private replay(
- sessionId: string,
- journal: AgentSessionJournal,
- fence: number,
- backgroundTasks: AgentSessionBackgroundTaskState | null | undefined,
- frame: { type: 'snapshot' } | { type: 'reset'; reset: AgentJournalResetReason }
): void {
const page = readAgentSessionHydrationPage(journal, fence)
const hostNow = this.now()
for (const subscriber of this.subscribers(sessionId)) {
this.emit(subscriber, {
- ...frame,
+ type: 'snapshot',
sessionId,
page,
fence,
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.ts
index a90a65c1c831..84339a7587d9 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.ts
@@ -106,7 +106,7 @@ export class StructuredAgentSessionTurnCompletionFeed {
return
}
if (baseline.epoch !== cursor.epoch || cursor.sequence < baseline.sequence) {
- // Epoch replacement (rewind, repair, or legacy import) republishes history with a new
+ // Epoch replacement (rewind or legacy import) republishes history with a new
// identity. It is not a provider edge, so re-baseline silently instead of announcing the
// newest settled row as a fresh completion.
baseline.epoch = cursor.epoch
diff --git a/src/main/runtime/structured-agent-session-integration-replay.test.ts b/src/main/runtime/structured-agent-session-integration-replay.test.ts
index b4d0399d10da..9926aaba2dce 100644
--- a/src/main/runtime/structured-agent-session-integration-replay.test.ts
+++ b/src/main/runtime/structured-agent-session-integration-replay.test.ts
@@ -7,9 +7,9 @@
// does and pushes the same notifications and blocking requests back.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import type { CodexStructuredSessionAdapter } from '../codex/codex-structured-session-adapter'
import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types'
import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-host-database-test-support'
+import type * as JournalStoreFactory from '../native-chat/agent-session-journal/journal-store-factory'
import {
ensureStructuredAgentSessionHost,
stopStructuredAgentSessionRuntime
@@ -22,6 +22,32 @@ import {
type StructuredCodexRpcHarness
} from './structured-codex-session-rpc-test-harness'
+const { journalOpenHold } = vi.hoisted(() => {
+ const journalOpenHold: { next: { entered: () => void; released: Promise } | null } = {
+ next: null
+ }
+ return { journalOpenHold }
+})
+
+// Holds the next journal open midway, so provider events land while the attach binds its journal.
+vi.mock('../native-chat/agent-session-journal/journal-store-factory', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ openAgentSessionJournal: async (
+ input: Parameters[0]
+ ) => {
+ const hold = journalOpenHold.next
+ journalOpenHold.next = null
+ if (hold) {
+ hold.entered()
+ await hold.released
+ }
+ return actual.openAgentSessionJournal(input)
+ }
+ }
+})
+
const journals = createTrackedJournalOpener()
const WORKSPACE = 'workspace-1'
@@ -72,23 +98,16 @@ describe('a structured codex session over agentSession.*', () => {
})
it('joins an acquired attach through journal bind before draining final rows', async () => {
- const host = await ensureStructuredAgentSessionHost(harness.hostConfig())
- const adapter = (host as unknown as { deps: { adapter: CodexStructuredSessionAdapter } }).deps
- .adapter
- const historyEntered = Promise.withResolvers()
- const historyGate = Promise.withResolvers()
- const originalHistoryFilePath = adapter.historyFilePath.bind(adapter)
- vi.spyOn(adapter, 'historyFilePath').mockImplementation(async (input) => {
- historyEntered.resolve()
- await historyGate.promise
- return originalHistoryFilePath(input)
- })
+ await ensureStructuredAgentSessionHost(harness.hostConfig())
+ const openEntered = Promise.withResolvers()
+ const openGate = Promise.withResolvers()
+ journalOpenHold.next = { entered: openEntered.resolve, released: openGate.promise }
const creating = harness.ok<{ fence: number }>(
'agentSession.create',
harness.createIntentParams()
)
- await historyEntered.promise
+ await openEntered.promise
harness.codex.notify('turn/started', { threadId: THREAD, turn: { id: TURN } })
harness.codex.notify('item/started', {
threadId: THREAD,
@@ -108,7 +127,7 @@ describe('a structured codex session over agentSession.*', () => {
})
await new Promise((resolve) => setImmediate(resolve))
const waitedForJournalBind = !stopped
- historyGate.resolve()
+ openGate.resolve()
await creating
await stopping
expect(waitedForJournalBind).toBe(true)
diff --git a/src/shared/agent-session-journal-body-admission.test.ts b/src/shared/agent-session-journal-body-admission.test.ts
index 82489e8850d3..44fc2777fb07 100644
--- a/src/shared/agent-session-journal-body-admission.test.ts
+++ b/src/shared/agent-session-journal-body-admission.test.ts
@@ -1,11 +1,8 @@
import { describe, expect, it } from 'vitest'
-import { z } from 'zod'
import {
- readAgentJournalContent,
readAgentJournalItemBody,
readAgentJournalMessageBody
} from './agent-session-journal-body-admission'
-import { openDiscriminatedUnion } from './agent-session-journal-open-union'
const RESOLUTION = { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null }
const QUESTION_ENTRY = {
@@ -79,39 +76,12 @@ describe('a body this build reads', () => {
})
})
-describe("a value outside a closed set this build knows: a newer build's", () => {
- // A turn's context usage is the closed set left in a body; the row reader drops it before this.
- const NEWER_USAGE = { used: { kind: 'measured' } }
+describe('anything else that fails: damage', () => {
it.each([
[
- 'a nested discriminant',
- { kind: 'turn', turnId: 't', state: 'done', contextUsage: NEWER_USAGE }
+ "a turn's context usage of a kind it does not know, which the row reader drops first",
+ { kind: 'turn', turnId: 't', state: 'done', contextUsage: { used: { kind: 'measured' } } }
],
- ['one beside damage', { kind: 'turn', turnId: 5, state: 'done', contextUsage: NEWER_USAGE }]
- ])('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 = openDiscriminatedUnion(
- z.discriminatedUnion('type', [
- z.object({ type: z.literal('text'), text: z.string(), tone: z.enum(['info']).optional() })
- ])
- )
-
- 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],
['a body that is a list', [{ kind: 'status' }]],
['no kind', { text: 'x' }],
diff --git a/src/shared/agent-session-journal-body-admission.ts b/src/shared/agent-session-journal-body-admission.ts
index c5eecfb39f66..2796ae605099 100644
--- a/src/shared/agent-session-journal-body-admission.ts
+++ b/src/shared/agent-session-journal-body-admission.ts
@@ -1,11 +1,11 @@
-// Reading a persisted body by the extension rule in agent-session-journal-schemas.ts: it parses
-// (a body or block of a kind this build does not know included), or it holds a value outside a
-// closed set this build knows (a newer build's), or it is damage.
+// Reading a persisted body by the extension rule in agent-session-journal-schemas.ts: it parses (a
+// body, block, goal state or approval subject of a kind this build does not know included), or it
+// is damage. A sent message is the one closed set: a kind this build does not know is a newer
+// build's.
-import type { z } from 'zod'
import {
AGENT_JOURNAL_ITEM_BODY_KINDS,
- AgentJournalItemBodySchema,
+ isAdmissibleAgentJournalItemBody,
isAdmissibleAgentJournalMessageBody
} from './agent-session-journal-schemas'
@@ -13,24 +13,11 @@ import {
export type AgentJournalContentVerdict = 'readable' | 'unreadable' | 'malformed'
export function readAgentJournalItemBody(body: unknown): AgentJournalContentVerdict {
- return readAgentJournalContent(AgentJournalItemBodySchema, body)
+ return isAdmissibleAgentJournalItemBody(body) ? 'readable' : 'malformed'
}
-/** 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 it wrote.
- return parsed.error.issues.some(isOutsideClosedSet) ? 'unreadable' : 'malformed'
-}
-
-/** The same for a submission's body, which only a user message may be. What was sent stays a
- * closed set: a kind this build does not know is a newer build's, never sent again here. */
+/** The same for a submission's body, which only a user message may be. A kind this build does not
+ * know is a newer build's, never sent again here. */
export function readAgentJournalMessageBody(body: unknown): AgentJournalContentVerdict {
const verdict = readAgentJournalItemBody(body)
if (verdict !== 'readable' || isAdmissibleAgentJournalMessageBody(body)) {
@@ -41,33 +28,3 @@ export function readAgentJournalMessageBody(body: unknown): AgentJournalContentV
? 'unreadable'
: 'malformed'
}
-
-/** A new string where a closed set of strings expects one of its own: the set's type, so never a
- * 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
- * (`openDiscriminatedUnion`), whose own failure is never a closed-set one. */
-function isOutsideClosedSet(issue: z.core.$ZodIssue): boolean {
- 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 {
- 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-schemas.ts b/src/shared/agent-session-journal-schemas.ts
index 75658a65f6c1..2a9bea906667 100644
--- a/src/shared/agent-session-journal-schemas.ts
+++ b/src/shared/agent-session-journal-schemas.ts
@@ -5,7 +5,7 @@
// guards. These schemas are the single deep validators for that render model:
// admission must reject a JSON-valid but structurally wrong item (a question
// whose `options` are null, a prompt without its `resolution`) so the row is
-// rejected at replay, where a repair can delete it, instead of throwing
+// refused at replay, where the chat fails to load, instead of throwing
// mid-render.
//
// Discriminants (`kind`, known block `type`s) are validated deeply. Open string
@@ -13,18 +13,17 @@
// 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).
//
-// A newer writer extends a body only by new keys, new kinds of an open union, new values in a
-// closed set, or a bumped row `v`, never by changing a field's type or a bound this build checks.
-// Open unions (a body's `kind`, a block's `type`, a goal's `state`, an approval subject's `kind`)
-// keep a value this build does not know as-is and nobody draws it; the chat stays writable. 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); a turn's context usage is dropped instead
-// (journal-row-unusable-annotations.ts). Any other failure is damage. A new value of an open
-// string (turn, resolution, tool or goal `state`) is read as-is too, except that an older build's
-// rewind replaces an unknown turn, tool or resolution state with a placeholder row and turns an
-// unknown block into a text block of its raw JSON (structured-rewind-journal-body.ts); an unknown
-// body kind is carried as-is. History pages carry no row `v`, so older clients see such a value
-// too. It must be safe for every older build.
+// A newer writer extends a body only by new keys, new kinds of an open union, or a bumped row `v`,
+// never by changing a field's type, a bound or a closed set this build checks (the closed sets are
+// pinned in journal-closed-sets.test.ts). Open unions (a body's `kind`, a block's `type`, a goal's
+// `state`, an approval subject's `kind`) keep a value this build does not know as-is and nobody
+// draws it; the chat stays writable. Unreadable context usage is dropped
+// (journal-row-unusable-annotations.ts). Any other failure is damage: the chat fails to load and
+// nothing is deleted. A new value of an open string (turn, resolution, tool or goal `state`) is
+// read as-is too, except that an older build's rewind replaces an unknown turn, tool or resolution
+// state with a placeholder row and turns an unknown block into a text block of its raw JSON
+// (structured-rewind-journal-body.ts); an unknown body kind is carried as-is. History pages carry
+// no row `v`, so older clients see such a value too. It must be safe for every older build.
import { z } from 'zod'
import { AgentSessionContextUsageSchema } from './agent-session-context-usage-schema'
diff --git a/src/shared/structured-agent-session-read-refusal.ts b/src/shared/structured-agent-session-read-refusal.ts
index 74279d7811d6..3f192441cdd1 100644
--- a/src/shared/structured-agent-session-read-refusal.ts
+++ b/src/shared/structured-agent-session-read-refusal.ts
@@ -47,9 +47,9 @@ export function isUnattachedAgentSessionReadRefusal(error: unknown): boolean {
}
/**
- * A read refusal no retry reads past: SQLite reported the chat's journal damaged. 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.
+ * 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
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 aaa730740b60..c96b86183778 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
@@ -261,7 +261,6 @@ describe('a chat a newer Orca saved, across versions', () => {
})
const reopened = await journals.open({ identity: IDENTITY, stateDirectory: directory })
expect(reopened.isReadOnly).toBe(false)
- expect(reopened.repair).toEqual({ malformedRows: 0 })
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 b31c8f008cd1..8c8784495478 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
@@ -264,7 +264,6 @@ test("this build keeps a newer build's row kind and goes read-only; a build befo
const reopened = await journals.open({ identity: IDENTITY, stateDirectory: directory })
expect(reopened.isReadOnly).toBe(true)
- expect(reopened.repair).toEqual({ malformedRows: 0 })
await expect(
reopened.appendItem(item(1), { kind: 'status', text: 'after' }, scope)
).rejects.toMatchObject({ code: 'journal_read_only' })
From 6612b07604c1022a014b0a79f81d0cb6a00aba7a Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Fri, 2 Oct 2026 11:29:39 -0700
Subject: [PATCH 32/52] chore(native-chat): no reset or adapter left on the
open's fixtures
---
.../structured-agent-session-attach-context.ts | 7 -------
.../structured-agent-session-forget-status.test.ts | 2 +-
.../structured-agent-session-read-restore.test.ts | 1 -
...tructured-agent-session-readable-restorer.test.ts | 1 -
.../structured-agent-session-restart-restore.test.ts | 12 ++++--------
5 files changed, 5 insertions(+), 18 deletions(-)
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts
index 90c638c23fb9..fe99ad90bc39 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts
@@ -7,7 +7,6 @@ import type {
AgentSessionTurnActivity,
AgentSessionWireRefusal
} from '../../../shared/agent-session-wire'
-import type { AgentJournalResetReason } from '../../../shared/agent-session-journal-types'
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
import type {
StructuredAgentSessionHostDeps,
@@ -22,12 +21,6 @@ export type StructuredAgentSessionAttachContext = {
runtimeState: StructuredAgentSessionHostRuntimeState
sessions: Map
subscribers: {
- reset: (
- sessionId: string,
- journal: AgentSessionJournal,
- reset: AgentJournalResetReason,
- fence: number
- ) => void
snapshot: (sessionId: string, journal: AgentSessionJournal, fence: number) => void
publish: (
sessionId: string,
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts
index c0e78b5a0c52..764d54377d02 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts
@@ -191,7 +191,7 @@ function attachContext(
discardEventSink: () => undefined
},
sessions,
- subscribers: { reset: () => undefined, snapshot: () => undefined, publish: () => undefined },
+ subscribers: { snapshot: () => undefined, publish: () => undefined },
tasks: { trackAttach: (task: Promise) => task },
reconcileLeases: async () => null,
serialize: (_sessionId: string, task: () => Promise) => task(),
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts
index 945f2cefbad2..6f59e5e7d5e3 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts
@@ -56,7 +56,6 @@ let journalRoot: string
const openDeps = () => ({
store,
journalDatabase: openTestJournalHostDatabase(journalRoot),
- adapter: {},
logger: recordingStructuredAgentSessionLogger().logger
})
const opened: AgentSessionJournal[] = []
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts
index 8f110c3e69a2..3c3cc124003a 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts
@@ -37,7 +37,6 @@ describe('StructuredAgentSessionReadableRestorer', () => {
openDeps: {
store: { getRecord: () => null, listRecords: () => records },
journalDatabase: openTestJournalHostDatabase(stateDirectory),
- adapter: {},
logger: recordingStructuredAgentSessionLogger().logger
},
supportsRecord: () => true,
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts
index 3488f5ade181..7df838d04e82 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts
@@ -30,7 +30,6 @@ afterAll(() => {
const NO_OPEN_DEPS = {
store: { getRecord: () => null, listRecords: () => [] },
journalDatabase: openTestJournalHostDatabase(stateDirectory),
- adapter: {},
logger: recordingStructuredAgentSessionLogger().logger
}
@@ -52,8 +51,7 @@ describe('restart journal restoration', () => {
params: { location: { workspaceId: 'workspace-1' }, provider: 'codex' },
child: null,
sessionId
- },
- reset: null
+ }
}
})
const records = Array.from(
@@ -139,8 +137,7 @@ describe('restart journal restoration', () => {
runtimeKind: 'native'
}
const restored = {
- session: { journal: {}, params, child: null },
- reset: null
+ session: { journal: {}, params, child: null }
}
// The open is what settles: it runs after recovery resolution and before the publish.
restoreRead.mockImplementation(async () => {
@@ -224,7 +221,7 @@ describe('restart journal restoration', () => {
if (sessionId === 'session-0') {
throw new Error('this chat cannot be opened')
}
- return { session: { journal: {}, params: {}, child: null, sessionId }, reset: null }
+ return { session: { journal: {}, params: {}, child: null, sessionId } }
})
const readable: string[] = []
const log = recordingStructuredAgentSessionLogger()
@@ -258,8 +255,7 @@ describe('restart journal restoration', () => {
it('does not settle again when a second restore finds the session already open', async () => {
restoreRead.mockResolvedValue({
- session: { journal: {}, params: {}, child: null },
- reset: null
+ session: { journal: {}, params: {}, child: null }
})
await restoreStructuredAgentSessionsOnRestart({
From 74f7afbf798de6df81a3a27d176bfe0d71623eec Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Fri, 2 Oct 2026 11:50:13 -0700
Subject: [PATCH 33/52] feat(native-chat): an approval of a newer Orca's
subject kind can only be cancelled
Desktop and phone show its detail and the line "This request needs a newer
version of Orca.", disable every answer, and keep the card's cancel, which ends
the turn. The chat stays writable.
---
.../MobileNativeChatPermission.test.ts | 52 ++++++++++++
.../session/MobileNativeChatPermission.tsx | 12 ++-
.../NativeChatApprovalCard.test.tsx | 85 +++++++++++++++----
.../native-chat/NativeChatApprovalCard.tsx | 13 ++-
...tStructuredSession.newer-approval.test.tsx | 73 ++++++++++++++++
src/renderer/src/i18n/locales/en.json | 1 +
src/renderer/src/i18n/locales/es.json | 1 +
src/renderer/src/i18n/locales/fr.json | 1 +
src/renderer/src/i18n/locales/ja.json | 1 +
src/renderer/src/i18n/locales/ko.json | 1 +
src/renderer/src/i18n/locales/zh.json | 1 +
.../agent-session-approval-subject.test.ts | 18 ++++
src/shared/agent-session-approval-subject.ts | 10 +++
src/shared/agent-session-journal-schemas.ts | 14 +--
14 files changed, 257 insertions(+), 26 deletions(-)
create mode 100644 src/renderer/src/components/native-chat/NativeChatStructuredSession.newer-approval.test.tsx
create mode 100644 src/shared/agent-session-approval-subject.test.ts
create mode 100644 src/shared/agent-session-approval-subject.ts
diff --git a/mobile/src/session/MobileNativeChatPermission.test.ts b/mobile/src/session/MobileNativeChatPermission.test.ts
index ef216aba7c7e..4641071ef508 100644
--- a/mobile/src/session/MobileNativeChatPermission.test.ts
+++ b/mobile/src/session/MobileNativeChatPermission.test.ts
@@ -146,6 +146,58 @@ describe('MobileNativeChatPermission', () => {
expect(actions.findAllByProps({ children: 'Approve plan' })).toHaveLength(1)
})
+ describe("a subject of a kind this build cannot draw: a newer Orca's", () => {
+ const NEEDS_NEWER_ORCA = 'This request needs a newer version of Orca.'
+
+ async function renderNewer(detail: string | undefined) {
+ const onRespond = vi.fn(async () => true)
+ const onCancel = vi.fn(async () => true)
+ await act(async () => {
+ renderer = create(
+ createElement(MobileNativeChatPermission, {
+ permission: {
+ title: 'Review proposed change',
+ ...(detail ? { detail } : {}),
+ // A subject kind a newer build wrote; this build draws only plans.
+ subject: JSON.parse('{"kind":"diff","path":"a.ts"}'),
+ prompt: { itemId: 'approval-1', expectedRevision: 2 },
+ options: [
+ { label: 'Approve', send: 'allow' },
+ { label: 'Deny', send: 'deny' }
+ ]
+ },
+ onRespond,
+ onCancel
+ })
+ )
+ })
+ const actions = renderer!.root.findByProps({ testID: 'native-chat-approval-actions' })
+ return { onRespond, onCancel, options: actions.findAllByType('Pressable') }
+ }
+
+ it('with no detail: says so, answers nothing, and the cancel ends the turn', async () => {
+ const { onRespond, onCancel, options } = await renderNewer(undefined)
+ expect(
+ renderer!.root.findByProps({ testID: 'native-chat-approval-needs-newer-orca' }).props
+ .children
+ ).toBe(NEEDS_NEWER_ORCA)
+ expect(options).toHaveLength(2)
+ expect(options.every((option) => option.props.disabled === true)).toBe(true)
+ const cancel = renderer!.root.findByProps({ accessibilityLabel: 'Cancel' })
+ expect(cancel.props.disabled).toBe(false)
+ await act(async () => cancel.props.onPress())
+ expect(onCancel).toHaveBeenCalledWith({ itemId: 'approval-1', expectedRevision: 2 })
+ expect(onRespond).not.toHaveBeenCalled()
+ })
+
+ it('with a detail: shows it, and still approves nothing', async () => {
+ const { options } = await renderNewer('# Release')
+ const content = renderer!.root.findByProps({ testID: 'native-chat-approval-content' })
+ expect(content.findAllByProps({ children: '# Release' })).toHaveLength(1)
+ expect(options.every((option) => option.props.disabled === true)).toBe(true)
+ })
+ })
+
it('a disabled card shows the request with nothing to press', async () => {
const onRespond = vi.fn(async () => true)
const onCancel = vi.fn(async () => true)
diff --git a/mobile/src/session/MobileNativeChatPermission.tsx b/mobile/src/session/MobileNativeChatPermission.tsx
index d74354a27a37..d4045143c27b 100644
--- a/mobile/src/session/MobileNativeChatPermission.tsx
+++ b/mobile/src/session/MobileNativeChatPermission.tsx
@@ -3,6 +3,7 @@ import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'
import { ShieldQuestion, X } from 'lucide-react-native'
import { MobileMarkdown } from '../components/MobileMarkdown'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
+import { isNewerApprovalSubject } from '../../../src/shared/agent-session-approval-subject'
import type { MobileChatPermission } from './mobile-native-chat-permission'
// Renders a detected agent permission ask as a card with tappable options.
@@ -23,6 +24,8 @@ function MobileNativeChatPermissionImpl({
const [inFlight, setSubmitting] = useState(false)
const submitting = inFlight || disabled
const submittingRef = useRef(false)
+ // A newer Orca's subject: its detail is shown, and only the cancel that ends the turn answers.
+ const newerSubject = isNewerApprovalSubject(permission.subject)
const hasContext = Boolean(
permission.description ||
permission.decisionReason ||
@@ -107,6 +110,11 @@ function MobileNativeChatPermissionImpl({
) : permission.detail ? (
{permission.detail}
) : null}
+ {newerSubject ? (
+
+ This request needs a newer version of Orca.
+
+ ) : null}
) : null}
@@ -119,11 +127,11 @@ function MobileNativeChatPermissionImpl({
styles.option,
isPrimary ? styles.optionPrimary : styles.optionSecondary,
pressed && !submitting && styles.optionPressed,
- disabled && styles.disabled
+ (disabled || newerSubject) && styles.disabled
]}
hitSlop={6}
onPress={() => respond(option.send)}
- disabled={submitting}
+ disabled={submitting || newerSubject}
>
{option.label}
diff --git a/src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx b/src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx
index 56a292436e77..973172aa726a 100644
--- a/src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx
@@ -7,22 +7,75 @@ import { NativeChatApprovalCard } from './NativeChatApprovalCard'
afterEach(cleanup)
describe('NativeChatApprovalCard', () => {
- it('shows the detail of a subject this build cannot draw, so nothing is approved unseen', () => {
- render(
- {}}
- />
- )
- expect(document.querySelector('[data-native-chat-approval-detail]')?.textContent).toContain(
- '# Release'
- )
+ describe("a subject of a kind this build cannot draw: a newer Orca's", () => {
+ // A subject kind a newer build wrote; this build draws only plans.
+ const NEWER_SUBJECT = JSON.parse('{"kind":"diff","path":"a.ts"}')
+ const NEEDS_NEWER_ORCA = 'This request needs a newer version of Orca.'
+
+ function renderNewer(detail: string | undefined) {
+ const onChoose = vi.fn()
+ const onCancel = vi.fn()
+ render(
+
+ )
+ return { onChoose, onCancel }
+ }
+
+ it('with no detail: says so, answers nothing, and the cancel ends the turn', () => {
+ const { onChoose, onCancel } = renderNewer(undefined)
+ expect(screen.getByText(NEEDS_NEWER_ORCA)).toBeTruthy()
+ for (const label of ['Approve', 'Deny']) {
+ const option = screen.getByRole('button', { name: label })
+ expect(option.hasAttribute('disabled')).toBe(true)
+ fireEvent.click(option)
+ }
+ expect(onChoose).not.toHaveBeenCalled()
+ const cancel = screen.getByRole('button', { name: 'Cancel' })
+ expect(cancel.hasAttribute('disabled')).toBe(false)
+ fireEvent.click(cancel)
+ fireEvent.keyDown(screen.getByRole('group'), { key: 'Escape' })
+ expect(onCancel).toHaveBeenCalledTimes(2)
+ })
+
+ it('with a detail: shows it, and still approves nothing', () => {
+ const { onChoose } = renderNewer('# Release\n- Run tests')
+ expect(document.querySelector('[data-native-chat-approval-detail]')?.textContent).toContain(
+ '# Release'
+ )
+ expect(screen.getByText(NEEDS_NEWER_ORCA)).toBeTruthy()
+ fireEvent.click(screen.getByRole('button', { name: 'Approve' }))
+ expect(screen.getByRole('button', { name: 'Approve' }).hasAttribute('disabled')).toBe(true)
+ expect(onChoose).not.toHaveBeenCalled()
+ })
+
+ it('leaves a plan answerable, with no such line', () => {
+ const onChoose = vi.fn()
+ render(
+
+ )
+ expect(screen.queryByText(NEEDS_NEWER_ORCA)).toBeNull()
+ fireEvent.click(screen.getByRole('button', { name: 'Approve plan' }))
+ expect(onChoose).toHaveBeenCalledWith('allow')
+ })
})
it('a disabled card shows the request with nothing to press, and Escape cancels nothing', () => {
diff --git a/src/renderer/src/components/native-chat/NativeChatApprovalCard.tsx b/src/renderer/src/components/native-chat/NativeChatApprovalCard.tsx
index 6a69926bd738..03764ce41f3c 100644
--- a/src/renderer/src/components/native-chat/NativeChatApprovalCard.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatApprovalCard.tsx
@@ -5,6 +5,7 @@ import { translate } from '@/i18n/i18n'
import CommentMarkdown, {
type CommentMarkdownLinkClickHandler
} from '@/components/sidebar/CommentMarkdown'
+import { isNewerApprovalSubject } from '../../../../shared/agent-session-approval-subject'
import { NativeChatCodeBlock } from './NativeChatCodeBlock'
import type { ChatApproval } from './native-chat-interactive-prompt'
@@ -37,6 +38,8 @@ export function NativeChatApprovalCard({
allowFileUriLinks = false
}: NativeChatApprovalCardProps): React.JSX.Element {
const cardRef = useRef(null)
+ // A newer Orca's subject: its detail is shown, and only the cancel that ends the turn answers.
+ const newerSubject = isNewerApprovalSubject(approval.subject)
const hasContext = Boolean(
approval.description ||
approval.decisionReason ||
@@ -153,6 +156,14 @@ export function NativeChatApprovalCard({
{approval.detail}
) : null}
+ {newerSubject ? (
+
+ {translate(
+ 'components.native-chat.approval.needsNewerOrca',
+ 'This request needs a newer version of Orca.'
+ )}
+
+ ) : null}
) : null}
@@ -160,7 +171,7 @@ export function NativeChatApprovalCard({
onChoose(opt.send)}
className={cn(
'rounded-md px-4 py-1.5 text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
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
new file mode 100644
index 000000000000..1af4519d0cc0
--- /dev/null
+++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.newer-approval.test.tsx
@@ -0,0 +1,73 @@
+// @vitest-environment happy-dom
+
+import { cleanup, render } 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()
+})
+
+// An approval whose subject a newer Orca wrote, with no detail to show.
+const NEWER_APPROVAL: AgentJournalRenderItem = JSON.parse(
+ JSON.stringify({
+ itemId: 'approval-item',
+ revision: 3,
+ sequence: 1,
+ observedAt: 1,
+ body: {
+ kind: 'approval',
+ title: 'Review proposed change',
+ detail: null,
+ subject: { kind: 'diff', path: 'a.ts' },
+ options: [{ id: 'allow', label: 'Approve' }],
+ resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null }
+ }
+ })
+)
+
+it("hands the card a newer Orca's subject in a writable chat, and its cancel ends the turn", () => {
+ mocks.turnId = 'turn-1'
+ mocks.promptItems = [NEWER_APPROVAL]
+ render(
+
+ )
+ expect(mocks.approvalCardProps).toMatchObject({
+ disabled: false,
+ approval: { subject: { kind: 'diff' } }
+ })
+ expect(mocks.approvalCardProps?.approval).not.toHaveProperty('detail')
+ mocks.approvalCardProps?.onCancel?.()
+ expect(mocks.cancel).toHaveBeenCalledWith('turn-1', {
+ itemId: 'approval-item',
+ expectedRevision: 3
+ })
+})
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index 1bfcd1022bd4..63e43ec00440 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -17904,6 +17904,7 @@
"reason": "Reason",
"blockedPath": "Blocked path",
"askRule": "Ask rule",
+ "needsNewerOrca": "This request needs a newer version of Orca.",
"plan": {
"file": "Plan file"
}
diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json
index 6d3800a456c9..be4f0740ef40 100644
--- a/src/renderer/src/i18n/locales/es.json
+++ b/src/renderer/src/i18n/locales/es.json
@@ -15170,6 +15170,7 @@
"sending": "Enviando…"
},
"approval": {
+ "needsNewerOrca": "Esta solicitud necesita una versión más reciente de Orca.",
"title": "¿Permitir {{value0}}?",
"allow": "Permitir",
"deny": "Denegar"
diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json
index 67d9f6223977..bbbd0d5d5d4c 100644
--- a/src/renderer/src/i18n/locales/fr.json
+++ b/src/renderer/src/i18n/locales/fr.json
@@ -17715,6 +17715,7 @@
"sending": "Envoi…"
},
"approval": {
+ "needsNewerOrca": "Cette demande nécessite une version plus récente d'Orca.",
"title": "Autoriser {{value0}} ?",
"allow": "Autoriser",
"deny": "Refuser",
diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json
index 6f4252202e06..0d922fadbbac 100644
--- a/src/renderer/src/i18n/locales/ja.json
+++ b/src/renderer/src/i18n/locales/ja.json
@@ -17651,6 +17651,7 @@
"sending": "送信中…"
},
"approval": {
+ "needsNewerOrca": "このリクエストには新しいバージョンの Orca が必要です。",
"title": "{{value0}} を許可しますか?",
"allow": "許可する",
"deny": "拒否",
diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json
index 3dda4d3a1a49..a4675b2398e7 100644
--- a/src/renderer/src/i18n/locales/ko.json
+++ b/src/renderer/src/i18n/locales/ko.json
@@ -17651,6 +17651,7 @@
"sending": "전송 중…"
},
"approval": {
+ "needsNewerOrca": "이 요청에는 최신 버전의 Orca가 필요합니다.",
"title": "{{value0}}을(를) 허용하시겠습니까?",
"allow": "허용",
"deny": "거부",
diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json
index 1e5e7b8ee9bc..12ba2ea6802d 100644
--- a/src/renderer/src/i18n/locales/zh.json
+++ b/src/renderer/src/i18n/locales/zh.json
@@ -17616,6 +17616,7 @@
"sending": "正在发送…"
},
"approval": {
+ "needsNewerOrca": "此请求需要更新版本的 Orca。",
"title": "允许 {{value0}}?",
"allow": "允许",
"deny": "拒绝",
diff --git a/src/shared/agent-session-approval-subject.test.ts b/src/shared/agent-session-approval-subject.test.ts
new file mode 100644
index 000000000000..c5020d0ece90
--- /dev/null
+++ b/src/shared/agent-session-approval-subject.test.ts
@@ -0,0 +1,18 @@
+import { expect, it } from 'vitest'
+import {
+ DRAWN_APPROVAL_SUBJECT_KINDS,
+ isNewerApprovalSubject
+} from './agent-session-approval-subject'
+import { AGENT_JOURNAL_APPROVAL_SUBJECT_KINDS } from './agent-session-journal-schemas'
+
+it('draws exactly the subject kinds the journal schema knows', () => {
+ expect([...DRAWN_APPROVAL_SUBJECT_KINDS].sort()).toEqual(
+ [...AGENT_JOURNAL_APPROVAL_SUBJECT_KINDS].sort()
+ )
+})
+
+it("reads only a subject of a kind it does not draw as a newer build's", () => {
+ expect(isNewerApprovalSubject(undefined)).toBe(false)
+ expect(isNewerApprovalSubject({ kind: 'plan' })).toBe(false)
+ expect(isNewerApprovalSubject({ kind: 'diff' })).toBe(true)
+})
diff --git a/src/shared/agent-session-approval-subject.ts b/src/shared/agent-session-approval-subject.ts
new file mode 100644
index 000000000000..d984bad1aeb4
--- /dev/null
+++ b/src/shared/agent-session-approval-subject.ts
@@ -0,0 +1,10 @@
+// An approval subject of a kind this build cannot draw is a newer Orca's. Its card shows the
+// approval's `detail` and offers only the cancel that ends the turn: nobody approves what this
+// build cannot show.
+
+/** The subject kinds this build draws: the known arms of the journal schema's approval subject. */
+export const DRAWN_APPROVAL_SUBJECT_KINDS: ReadonlySet = new Set(['plan'])
+
+export function isNewerApprovalSubject(subject: { kind: string } | undefined): boolean {
+ return subject !== undefined && !DRAWN_APPROVAL_SUBJECT_KINDS.has(subject.kind)
+}
diff --git a/src/shared/agent-session-journal-schemas.ts b/src/shared/agent-session-journal-schemas.ts
index 2a9bea906667..cb5e0e573d7d 100644
--- a/src/shared/agent-session-journal-schemas.ts
+++ b/src/shared/agent-session-journal-schemas.ts
@@ -166,13 +166,13 @@ const ApprovalMatchedAskRule = z.object({
ruleContent: z.string().optional()
})
-/** Open like blocks. An approval whose subject this build cannot draw shows its `detail`, which
- * Orca's writers fill with the subject's text, so it is never approved unseen. */
-const ApprovalSubject = openDiscriminatedUnion(
- z.discriminatedUnion('kind', [
- z.object({ kind: z.literal('plan'), text: z.string().min(1), filePath: z.string().optional() })
- ])
-)
+const KnownApprovalSubject = z.discriminatedUnion('kind', [
+ z.object({ kind: z.literal('plan'), text: z.string().min(1), filePath: z.string().optional() })
+])
+export const AGENT_JOURNAL_APPROVAL_SUBJECT_KINDS = knownTags(KnownApprovalSubject)
+/** Open like blocks; a subject this build cannot draw is never approvable here
+ * (agent-session-approval-subject.ts). */
+const ApprovalSubject = openDiscriminatedUnion(KnownApprovalSubject)
const MessageBody = z.object({
kind: z.literal('message'),
From 55b9cfde69b44ce7df93500d381d0db863681a73 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Fri, 2 Oct 2026 11:52:48 -0700
Subject: [PATCH 34/52] test(native-chat): the closed-set guard says a missed
version bump fails older builds' load
---
.../agent-session-journal/journal-closed-sets.test.ts | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/src/main/native-chat/agent-session-journal/journal-closed-sets.test.ts b/src/main/native-chat/agent-session-journal/journal-closed-sets.test.ts
index f982b9181924..8a59ea65c87a 100644
--- a/src/main/native-chat/agent-session-journal/journal-closed-sets.test.ts
+++ b/src/main/native-chat/agent-session-journal/journal-closed-sets.test.ts
@@ -102,7 +102,8 @@ const SNAPSHOT = {
it('changes no closed set of a journal row without the row version', () => {
expect(
journalClosedSets(),
- 'A new closed-set value makes older builds go read-only or fail to load the chat: ship the ' +
- 'reader first or bump v, then update this snapshot.'
+ 'A closed value added without bumping the row version makes older builds fail to load the ' +
+ 'chat (a new row or batch-change kind makes them read-only instead). Bump v, then update ' +
+ 'this snapshot.'
).toEqual(SNAPSHOT)
})
From 96d97c6299587951bd71b25cee1665dc929b0ef9 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Fri, 2 Oct 2026 11:53:42 -0700
Subject: [PATCH 35/52] test(native-chat): the closed-set guard names what a
missed version bump does
---
.../agent-session-journal/journal-closed-sets.test.ts | 5 ++---
1 file changed, 2 insertions(+), 3 deletions(-)
diff --git a/src/main/native-chat/agent-session-journal/journal-closed-sets.test.ts b/src/main/native-chat/agent-session-journal/journal-closed-sets.test.ts
index 8a59ea65c87a..3a42892b4b25 100644
--- a/src/main/native-chat/agent-session-journal/journal-closed-sets.test.ts
+++ b/src/main/native-chat/agent-session-journal/journal-closed-sets.test.ts
@@ -102,8 +102,7 @@ const SNAPSHOT = {
it('changes no closed set of a journal row without the row version', () => {
expect(
journalClosedSets(),
- 'A closed value added without bumping the row version makes older builds fail to load the ' +
- 'chat (a new row or batch-change kind makes them read-only instead). Bump v, then update ' +
- 'this snapshot.'
+ 'A new closed-set value without a row-version bump makes older builds fail to load the ' +
+ 'chat. Bump v, then update this snapshot.'
).toEqual(SNAPSHOT)
})
From d821d0756fe6be2bbafd42fbf3a92c6a9186942d Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Fri, 2 Oct 2026 12:48:05 -0700
Subject: [PATCH 36/52] fix(native-chat): a row the reader rejects is never
written, and a chat that cannot load keeps its tab
Every journal insert reads its serialized row back with the reader inside the
write's transaction and refuses one it rejects, so Orca's own writer can no
longer leave a chat that fails to load. A restored chat whose open fails keeps
its tab and says why when opened. An epoch named with no rows is founded afresh
again, deleting nothing. The at-rest test now expects a damaged chat to be
refused; the replay gate keeps its 2026-09-11 evidence as it was run.
---
config/reliability-gates.jsonc | 10 ++-
.../journal-closed-sets.test.ts | 3 +-
.../journal-damage.test.ts | 76 ++++++++++++++++
.../agent-session-journal/journal-open.ts | 12 ++-
.../journal-per-session-import.test.ts | 18 ++++
.../journal-per-session-import.ts | 7 +-
.../journal-row-table.ts | 12 ++-
.../journal-store-open.ts | 4 +-
.../agent-session-journal/journal-store.ts | 4 +-
.../journal-write-guards.ts | 3 +-
.../structured-agent-session-host-tabs.ts | 21 ++++-
.../structured-agent-session-host.ts | 3 +-
...tructured-agent-session-restart-restore.ts | 3 +
...red-agent-session-surface-lifetime.test.ts | 30 +++++++
...uctured-agent-session-unopened-tab.test.ts | 89 +++++++++++++++++++
.../structured-agent-session-at-rest.test.ts | 26 ++++--
16 files changed, 297 insertions(+), 24 deletions(-)
create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-unopened-tab.test.ts
diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc
index 002cf9ab4782..bdc99aa511f9 100644
--- a/config/reliability-gates.jsonc
+++ b/config/reliability-gates.jsonc
@@ -651,6 +651,8 @@
"invariant": "Replay preserves latest revisions, original item order, fences, aliases, submissions, read-only schema latching over damage and cursor cleanup while retaining live items rather than all historical bodies; damage fails the load and deletes nothing.",
"oracle": "Replay 2,048 16 KiB revisions into one latest item with less than 8 MiB sampled live heap growth; latch future-schema rows read past a gap, name the first damage, fail a damaged load with every row kept, and hold no SQLite read snapshot across reduction (a mid-replay checkpoint is not busy). Existing journal and subscriber tests cover replayed content.",
"commands": [
+ // As the 2026-09-11 evidence run ran it; the recovery test it names went with the read-time repair.
+ "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 src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts",
"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",
"ORCA_BACKGROUND_LAUNCH=1 pnpm test src/main/native-chat/agent-session-journal/journal-streaming-replay.test.ts src/main/native-chat/agent-session-journal/journal-damage.test.ts"
],
@@ -673,7 +675,9 @@
"file": "src/main/native-chat/agent-session-journal/journal-damage.test.ts",
"assertions": [
"fails to load and keeps every row: %s",
- "reads past damage to a newer build's row, which keeps the chat read-only instead"
+ "reads past damage to a newer build's row, which keeps the chat read-only instead",
+ "is refused when it is written, and the chat still loads with every other row",
+ "is founded afresh on open, with nothing deleted, and takes writes"
]
}
],
@@ -682,10 +686,10 @@
"date": "2026-09-11",
"runner": "local",
"platform": "macos",
- "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",
+ "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 src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts",
"result": "passed",
"durationSeconds": 7.71,
- "summary": "245 tests passed across 22 files. Retained-heap oracle fails on baseline at 68.6 MB and passes under 8 MiB with streaming; gap/schema and cursor-cleanup assertions passed. Recorded before the read-time repair was removed; its command then also named agent-session-journal-recovery.test.ts, which went with it."
+ "summary": "245 tests passed across 22 files. Retained-heap oracle fails on baseline at 68.6 MB and passes under 8 MiB with streaming; gap/schema and cursor-cleanup assertions passed."
},
{
"date": "2026-10-02",
diff --git a/src/main/native-chat/agent-session-journal/journal-closed-sets.test.ts b/src/main/native-chat/agent-session-journal/journal-closed-sets.test.ts
index 3a42892b4b25..a042abdecab1 100644
--- a/src/main/native-chat/agent-session-journal/journal-closed-sets.test.ts
+++ b/src/main/native-chat/agent-session-journal/journal-closed-sets.test.ts
@@ -103,6 +103,7 @@ it('changes no closed set of a journal row without the row version', () => {
expect(
journalClosedSets(),
'A new closed-set value without a row-version bump makes older builds fail to load the ' +
- 'chat. Bump v, then update this snapshot.'
+ 'chat (a new row or batch-change kind makes them read-only instead). Bump v, then update ' +
+ 'this snapshot.'
).toEqual(SNAPSHOT)
})
diff --git a/src/main/native-chat/agent-session-journal/journal-damage.test.ts b/src/main/native-chat/agent-session-journal/journal-damage.test.ts
index 9d0ebe3d4d66..10e0cd66fb33 100644
--- a/src/main/native-chat/agent-session-journal/journal-damage.test.ts
+++ b/src/main/native-chat/agent-session-journal/journal-damage.test.ts
@@ -169,3 +169,79 @@ function storedRowJson(db: Database.Database, seq: number): string {
}
return row.rowJson
}
+
+describe('a row the reader would reject', () => {
+ // TypeScript accepts it; the persisted reader rejects a call id that is only spaces.
+ const blankCallId: AgentJournalItemBody = {
+ kind: 'message',
+ role: 'assistant',
+ blocks: [{ type: 'tool-call', name: 'Bash', input: null, callId: ' ' }]
+ }
+ const scope = { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
+
+ it('is refused when it is written, and the chat still loads with every other row', async () => {
+ const journal = await open()
+ await journal.appendItem(item(0), body('before'), scope)
+ await expect(journal.appendItem(item(1), blankCallId, scope)).rejects.toMatchObject({
+ code: 'journal_row_rejected'
+ })
+ await journal.appendItem(item(2), body('after'), scope)
+ await journal.close()
+
+ const reopened = await open()
+ expect(reopened.isReadOnly).toBe(false)
+ expect(reopened.snapshot().items.map((entry) => entry.body)).toEqual([
+ body('before'),
+ body('after')
+ ])
+ expect(storedRows().map((row) => row.seq)).toEqual([1, 2, 3])
+ })
+
+ it('rolls back a whole epoch replacement that holds one, keeping the epoch it would replace', async () => {
+ const journal = await open()
+ await journal.appendItem(item(0), body('kept'), scope)
+ // The open journal's own database: closing it here would close the journal's too.
+ const liveRows = () =>
+ liveTestJournalRows(openTestJournalHostDatabase(root).db, IDENTITY.sessionId)
+ const before = liveRows()
+
+ await expect(
+ journal.replaceEpochItems('legacy_import', 1, [
+ { identity: item(5), body: body('replacement') },
+ { identity: item(6), body: blankCallId }
+ ])
+ ).rejects.toMatchObject({ code: 'journal_row_rejected' })
+
+ expect(liveRows()).toEqual(before)
+ await journal.appendItem(item(1), body('still writable'), scope)
+ await journal.close()
+ await expect(open()).resolves.toBeTruthy()
+ })
+})
+
+describe('an epoch named with no rows at all', () => {
+ // A crash inside an older build's repair: the suffix deleted, its new epoch not yet published.
+ it('is founded afresh on open, with nothing deleted, and takes writes', async () => {
+ await writeChat()
+ withJournalDatabase((db) => {
+ for (const row of liveTestJournalRows(db, IDENTITY.sessionId)) {
+ deleteTestJournalRow(db, IDENTITY.sessionId, row.seq)
+ }
+ })
+ expect(loadTestJournal(root, IDENTITY.sessionId)).toMatchObject({
+ readOnly: false,
+ damage: null,
+ state: { lastSequence: 0 }
+ })
+
+ const journal = await open()
+ await journal.appendItem(item(0), body('after'), {
+ fence: 1,
+ turnScope: AGENT_JOURNAL_THREAD_SCOPE
+ })
+ await journal.close()
+
+ const reopened = await open()
+ expect(reopened.snapshot().items.map((entry) => entry.body)).toEqual([body('after')])
+ })
+})
diff --git a/src/main/native-chat/agent-session-journal/journal-open.ts b/src/main/native-chat/agent-session-journal/journal-open.ts
index 475973762579..935a74f60238 100644
--- a/src/main/native-chat/agent-session-journal/journal-open.ts
+++ b/src/main/native-chat/agent-session-journal/journal-open.ts
@@ -63,8 +63,10 @@ export function startJournalRowFold(input: { sessionId: string; epoch: string })
let damage: JournalDamage | null = null
let anchored = false
let latched = false
+ let empty = true
const add = (entry: { seq: number; rowJson: string }): boolean => {
+ empty = false
const parsed = parseJournalRow(entry.rowJson)
if (!parsed.ok && parsed.unreadable) {
latched = true
@@ -100,17 +102,19 @@ export function startJournalRowFold(input: { sessionId: string; epoch: string })
}
const finish = (): JournalLoad => {
state.oldestSequence = FIRST_JOURNAL_SEQUENCE
- // An epoch with no rows at all never had its first row written.
+ // An epoch with no rows at all holds nothing to lose: the open founds a fresh one over it.
const found =
damage ??
- (anchored ? null : { sequence: FIRST_JOURNAL_SEQUENCE, cause: 'no-epoch-row' as const })
+ (anchored || empty
+ ? null
+ : { sequence: FIRST_JOURNAL_SEQUENCE, cause: 'no-epoch-row' as const })
return { state, readOnly: latched, damage: latched ? null : found }
}
return { add, finish }
}
-/** Rows after a cursor, in sequence order. Stops at the first row this build
- * cannot parse, exactly as replay does. */
+/** Rows after a cursor, in sequence order. Stops at the first row this build cannot parse: rows
+ * past it are never served. */
export function readJournalRowsAfterCursor(
db: Database.Database,
sessionId: string,
diff --git a/src/main/native-chat/agent-session-journal/journal-per-session-import.test.ts b/src/main/native-chat/agent-session-journal/journal-per-session-import.test.ts
index 5e91ae6a1f91..73dcb5a01a4f 100644
--- a/src/main/native-chat/agent-session-journal/journal-per-session-import.test.ts
+++ b/src/main/native-chat/agent-session-journal/journal-per-session-import.test.ts
@@ -410,6 +410,24 @@ describe('importing a per-chat journal', () => {
expect(rowCount(database.db)).toBe(rows.length)
})
+ it('opens a file whose chat has no rows as a fresh chat, losing nothing', async () => {
+ const { epoch } = await historyRows()
+ await writeLegacyJournal(epoch, [])
+ const database = openTestJournalHostDatabase(root)
+ const db = new Database(legacyJournalDatabaseFile(legacyDir()))
+ db.prepare('INSERT INTO journal_sessions VALUES (?, ?, ?)').run(IDENTITY.sessionId, epoch, 1)
+ db.close()
+
+ await expect(
+ previewPerSessionJournal({ database, identity: IDENTITY, legacyDirectory: legacyDir() })
+ ).resolves.toBeNull()
+ const journal = await openChat()
+
+ expect(journal.isReadOnly).toBe(false)
+ expect(journal.snapshot().items).toEqual([])
+ expect(journal.cursor().sequence).toBe(1)
+ })
+
it('keeps a damaged file whole, copies none of it, and refuses the chat on every open', async () => {
const { epoch, rows } = await historyRows()
// A gap: the reply's row is gone from the file.
diff --git a/src/main/native-chat/agent-session-journal/journal-per-session-import.ts b/src/main/native-chat/agent-session-journal/journal-per-session-import.ts
index ee370ea58070..4b6f892a9218 100644
--- a/src/main/native-chat/agent-session-journal/journal-per-session-import.ts
+++ b/src/main/native-chat/agent-session-journal/journal-per-session-import.ts
@@ -150,8 +150,8 @@ async function importOnce(input: ImportInput): Promise
@@ -174,7 +174,8 @@ export async function previewPerSessionJournal(
}
const loaded = await foldLegacyJournal(source, sessionId, legacy)
failLoadOnJournalDamage(sessionId, loaded)
- return loaded.readOnly ? null : loaded
+ // An empty one is copied now, so its open founds the chat's epoch in the host's database.
+ return loaded.readOnly || loaded.state.lastSequence === 0 ? null : loaded
} finally {
source.close()
}
diff --git a/src/main/native-chat/agent-session-journal/journal-row-table.ts b/src/main/native-chat/agent-session-journal/journal-row-table.ts
index 85eca03b8ac1..043ad44bddc5 100644
--- a/src/main/native-chat/agent-session-journal/journal-row-table.ts
+++ b/src/main/native-chat/agent-session-journal/journal-row-table.ts
@@ -5,7 +5,8 @@
// always named: `SELECT *` is uncacheable and can drop a column.
import type Database from '../../sqlite/sync-database'
-import { serializeJournalRow, type JournalRow } from './journal-row-schema'
+import { parseJournalRow, serializeJournalRow, type JournalRow } from './journal-row-schema'
+import { AgentSessionJournalError } from './journal-write-guards'
export type JournalStoredRow = { epoch: string; seq: number; ts: number; rowJson: string }
@@ -43,6 +44,15 @@ export function insertJournalRow(
row: JournalRow
): number {
const rowJson = serializeJournalRow(row)
+ // A row the reader rejects would fail the chat's next load, so it is never written: the throw
+ // rolls back the caller's transaction.
+ const readBack = parseJournalRow(rowJson)
+ if (!readBack.ok || readBack.row.seq !== row.seq) {
+ throw new AgentSessionJournalError(
+ 'journal_row_rejected',
+ `a ${row.kind} row for ${sessionId} would not read back, so it was not written`
+ )
+ }
db.prepare(INSERT_ROW).run(sessionId, row.epoch, row.seq, row.ts, rowJson)
return Buffer.byteLength(rowJson, 'utf8')
}
diff --git a/src/main/native-chat/agent-session-journal/journal-store-open.ts b/src/main/native-chat/agent-session-journal/journal-store-open.ts
index 1d12809136fe..29a3381e9b9a 100644
--- a/src/main/native-chat/agent-session-journal/journal-store-open.ts
+++ b/src/main/native-chat/agent-session-journal/journal-store-open.ts
@@ -36,7 +36,9 @@ export async function openJournalStoreState(input: {
readOnly: () => boolean
}): Promise {
const loaded = input.replay()
- if (!loaded) {
+ // An epoch named but holding no row (a crash inside an older build's repair) has nothing to
+ // keep, so it is founded afresh like a chat with no journal; no row is deleted.
+ if (!loaded || (!loaded.readOnly && !loaded.damage && loaded.state.lastSequence === 0)) {
input.start()
await discloseFileFormatRemnant(input)
return
diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts
index 55972aac2d8c..f817ebec6f04 100644
--- a/src/main/native-chat/agent-session-journal/journal-store.ts
+++ b/src/main/native-chat/agent-session-journal/journal-store.ts
@@ -359,8 +359,8 @@ export class AgentSessionJournal {
return rejectJournalQueuedSubmissions(this, fence, rejection, which)
}
- /** The escape hatch for corruption, an unreconcilable prefix, a forked handle,
- * and an unreadable schema. It invalidates every cursor; clients reload. */
+ /** The escape hatch for a forked handle and an unreadable schema. It invalidates every cursor;
+ * clients reload. */
async rollEpoch(reason: AgentJournalEpochReason, fence: number): Promise {
return this.epochController.roll(reason, fence)
}
diff --git a/src/main/native-chat/agent-session-journal/journal-write-guards.ts b/src/main/native-chat/agent-session-journal/journal-write-guards.ts
index 10e709d174f7..5ec2d0fbe907 100644
--- a/src/main/native-chat/agent-session-journal/journal-write-guards.ts
+++ b/src/main/native-chat/agent-session-journal/journal-write-guards.ts
@@ -9,7 +9,8 @@ export class AgentSessionJournalError extends Error {
| 'journal_read_only'
| 'journal_stale_fence'
| 'journal_closed'
- | 'journal_submission_exists',
+ | 'journal_submission_exists'
+ | 'journal_row_rejected',
message: string,
options?: ErrorOptions
) {
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts
index f74532ac9aef..763df3080c38 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts
@@ -70,15 +70,29 @@ export function createStructuredAgentSessionTabSurface(
deps: {
store: Pick<
AgentSessionRecordStore,
- 'getVisibleSessionTabIndex' | 'getSessionTabId' | 'showSessionTabs'
+ 'getVisibleSessionTabIndex' | 'getSessionTabId' | 'showSessionTabs' | 'getRecord'
>
}
},
sessions: TabSessions,
forgetStatus: (sessionId: string) => void
) {
+ // Restored chats that could not be opened. Each keeps its tab, whose read says why.
+ const unopened = new Set()
return {
- listSessionTabs: () => listStructuredAgentSessionTabs(sessions),
+ listSessionTabs: (): StructuredAgentSessionTab[] => [
+ ...listStructuredAgentSessionTabs(sessions),
+ ...[...unopened].flatMap((sessionId) => {
+ const record = sessions.has(sessionId) ? null : host.deps.store.getRecord(sessionId)
+ return record
+ ? [{ sessionId, workspaceId: record.location.workspaceId, agent: record.provider }]
+ : []
+ })
+ ],
+ /** A restore target with a record whose open failed. */
+ markUnopened: (sessionId: string): void => {
+ unopened.add(sessionId)
+ },
getPersistedVisibleSessionTabIndex: () => host.deps.store.getVisibleSessionTabIndex(),
getSessionTabId: (sessionId: string): string | null =>
host.deps.store.getSessionTabId(sessionId),
@@ -89,6 +103,9 @@ export function createStructuredAgentSessionTabSurface(
tabId?: string
): Promise => {
await setStructuredAgentSessionTabVisibility(host, sessionId, visible, tabId)
+ if (!visible) {
+ unopened.delete(sessionId)
+ }
// The tab edge of the row's lifetime; the handle close is the other.
if (!visible && !sessions.get(sessionId)?.child) {
forgetStatus(sessionId)
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts
index b960bc66fb55..0f6dabfac998 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts
@@ -136,7 +136,8 @@ export class StructuredAgentSessionHost {
hasSession: this.hasSession,
// Site 10: cannot overwrite a live entry — the restorer returns early on
// `hasSession` inside the same serialized step as this `set`.
- onReadable: this.conversationDelivery.adoptOpened
+ onReadable: this.conversationDelivery.adoptOpened,
+ onUnopened: (sessionId) => this.tabs.markUnopened(sessionId)
})
this.eventRecovery = new StructuredAgentSessionEventRecovery({
deps,
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts
index eed6ff7d54dc..6c6efc49d728 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts
@@ -34,6 +34,8 @@ export type StructuredAgentSessionReadRestoreDeps = {
resolveRecovery: (sessionId: string) => Promise
serialize: (sessionId: string, task: () => Promise) => Promise
hasSession: (sessionId: string) => boolean
+ /** A chat whose open failed: it still has its record, so it keeps its tab. */
+ onUnopened?: (sessionId: string) => void
onReadable: (
sessionId: string,
opened: OpenedStructuredAgentSessionConversation
@@ -100,6 +102,7 @@ export async function restoreStructuredAgentSessionsOnRestart(
sessionId,
error
})
+ input.onUnopened?.(sessionId)
}
)
})
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts
index 1f8fc80c863d..5a1ad69f6130 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts
@@ -544,6 +544,36 @@ describe('an unexpected provider exit', () => {
replaceFailedSink()
})
+ // TypeScript accepts the body; the persisted reader rejects a call id that is only spaces.
+ it('stops a provider whose output would not read back, and the next send starts a child', async () => {
+ await attach()
+
+ sink?.appendItem(
+ { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 },
+ {
+ kind: 'message',
+ role: 'assistant',
+ blocks: [{ type: 'tool-call', name: 'Bash', input: null, callId: ' ' }]
+ },
+ { turnScope: AGENT_JOURNAL_THREAD_SCOPE }
+ )
+ await vi.waitFor(() => {
+ expect(closeSession).toHaveBeenCalledWith(SESSION)
+ expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released' })
+ })
+ expect(hostErrors).toContainEqual(expect.objectContaining({ code: 'journal_row_rejected' }))
+ replaceFailedSink()
+
+ // Nothing restarts it on its own; the next send does, and the chat still loads.
+ expect(acquire).toHaveBeenCalledOnce()
+ await startAgent()
+ expect(acquire).toHaveBeenCalledTimes(2)
+ emitTurnLifecycle('running', 2)
+ await host.flushAllStreamedEvents()
+ await reboot()
+ expect((await host.history({ sessionId: SESSION, direction: 'tail' })).ok).toBe(true)
+ })
+
it('settles a journal sink failure whose stop saw the provider root exit', async () => {
await attach()
// The lease follows the root, so its seen exit settles like a proven one.
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
new file mode 100644
index 000000000000..a2b2cf15ddc3
--- /dev/null
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unopened-tab.test.ts
@@ -0,0 +1,89 @@
+// A restored chat whose open fails keeps its tab, so a phone on a headless host still lists it and
+// its read says why, instead of the chat disappearing.
+
+import { cp, rm } from 'node:fs/promises'
+import { afterEach, beforeEach, expect, it, vi } from 'vitest'
+import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness'
+import {
+ openTestJournalHostDatabase,
+ updateTestJournalRowJson
+} from '../agent-session-journal/journal-host-database-test-support'
+import { StructuredAgentSessionHost } from './structured-agent-session-host'
+import {
+ adapter,
+ attach,
+ CALLER,
+ envelope,
+ hostTestState,
+ replaceHostTestState
+} from './structured-agent-session-host-test-harness'
+import {
+ HOST_TEST_NOW as NOW,
+ HOST_TEST_SESSION as SESSION,
+ hostTestMessage
+} from './structured-agent-session-host-test-data'
+import { createStructuredAgentSessionLogger } from './structured-agent-session-logger'
+
+const relaunchedRoots: string[] = []
+
+beforeEach(() => {
+ vi.spyOn(console, 'warn').mockImplementation(() => undefined)
+})
+
+afterEach(async () => {
+ vi.restoreAllMocks()
+ await Promise.all(
+ relaunchedRoots.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))
+ )
+})
+
+/** A restarted process over the chat's files, whose epoch row no longer parses. */
+async function relaunchWithDamage(): Promise {
+ const before = hostTestState()
+ await attach()
+ await before.host.flushStreamedEvents(SESSION)
+ await before.store.renewLeases([])
+ const relaunched = `${before.root}-relaunched`
+ relaunchedRoots.push(relaunched)
+ // A dead process holds no lock.
+ await cp(before.root, relaunched, {
+ recursive: true,
+ filter: (source) => !source.includes('.lock')
+ })
+ updateTestJournalRowJson(openTestJournalHostDatabase(relaunched).db, SESSION, 1, '}{')
+ const store = await openTestAgentSessionRecordStore(relaunched)
+ const host = new StructuredAgentSessionHost({
+ logger: createStructuredAgentSessionLogger(),
+ store,
+ adapter: adapter(),
+ journalDatabase: openTestJournalHostDatabase(relaunched),
+ claimKeyId: 'key-1',
+ mintSpawnToken: () => 'spawn-next',
+ now: () => NOW
+ })
+ replaceHostTestState({ store, host })
+ return host
+}
+
+it('lists a restored chat that could not be opened, and its send is refused as unloadable', async () => {
+ const host = await relaunchWithDamage()
+ const workspaceId = hostTestState().store.getRecord(SESSION)?.location.workspaceId
+
+ await host.restoreReadableSessions([SESSION])
+
+ expect(host.hasSession(SESSION)).toBe(false)
+ expect(host.listSessionTabs()).toEqual([
+ { sessionId: SESSION, workspaceId, agent: expect.any(String) }
+ ])
+ const body = hostTestMessage('sent to a damaged chat')
+ await expect(
+ host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body })
+ ).resolves.toMatchObject({
+ ok: false,
+ refusal: { code: 'agent_session_journal_unreadable', details: { reason: 'journalCorrupt' } }
+ })
+
+ await host.setSessionTabVisibility(SESSION, false)
+ expect(host.listSessionTabs()).toEqual([])
+ await host.flushAllStreamedEvents()
+})
diff --git a/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts
index b846be96dd22..457e745cd0c6 100644
--- a/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts
+++ b/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts
@@ -29,6 +29,7 @@ import { closeStructuredAgentSessionChild } from '../../structured-agent-session
import { discardStructuredWorkerSession } from './orchestration-structured-worker-session'
import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session'
import {
+ liveTestJournalRows,
openTestJournalHostDatabase,
updateTestJournalRowJson
} from '../../../native-chat/agent-session-journal/journal-host-database-test-support'
@@ -311,19 +312,34 @@ describe('the accessor', () => {
expect(logged()).toEqual([denied, exhausted])
})
- it('opens a corrupt journal through the recovering open and still accepts a send (P2-03)', async () => {
+ it('refuses a damaged journal as unloadable at subscribe and send, and keeps its rows (P2-03)', async () => {
await foundRestTestChat(rig)
await rig.host.flushAllStreamedEvents()
- // A row that no longer parses: the recovering open keeps the readable prefix and rebuilds.
updateTestJournalRowJson(openTestJournalHostDatabase(rig.root).db, SESSION, 2, '}{')
await rig.restart()
setStructuredAgentSessionHost(rig.host)
const frames = await call('agentSession.subscribe', { sessionId: SESSION })
- expect(frames.some((frame) => !frame.ok)).toBe(false)
- expect(frames.find((frame) => frame.ok)).toMatchObject({ result: { type: 'snapshot' } })
+ expect(frames.find((frame) => !frame.ok)).toMatchObject({
+ error: {
+ data: {
+ refusal: {
+ code: 'agent_session_journal_unreadable',
+ details: { reason: 'journalCorrupt' }
+ }
+ }
+ }
+ })
const fence = rig.store.getRecord(SESSION)!.lease.runtimeFence
- expect((await rig.host.send(CALLER, restTestSend('after the repair', fence))).ok).toBe(true)
+ expect(await rig.host.send(CALLER, restTestSend('after the damage', fence))).toMatchObject({
+ ok: false,
+ refusal: { code: 'agent_session_journal_unreadable', details: { reason: 'journalCorrupt' } }
+ })
+ expect(
+ liveTestJournalRows(openTestJournalHostDatabase(rig.root).db, SESSION).find(
+ (row) => row.seq === 2
+ )?.rowJson
+ ).toBe('}{')
})
it('subscribes an old mobile client that holds first even when no agent can start (P2-06)', async () => {
From 1c0f103041f8d626992395160fe6fb14a1a75a86 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Fri, 2 Oct 2026 14:08:01 -0700
Subject: [PATCH 37/52] fix(native-chat): a newer Orca's approval subject is
carried as it was, and its card cancels with or without a turn
The approval subject's type is open, so code that reads a plan narrows first; the
host's prompt bounding carries an unknown subject instead of rebuilding it as a
plan, which threw in every start's stale settlement. A Codex rewind keeps a
newer Orca's item in its place. A whitespace-only tag is damage. The card's
cancel reaches the host without a running turn on hosts that take that. A
provider row the reader rejects ends its turn as interrupted and the next send
works. A guard fails if an approval subject kind is added before clients can be
gated (STA-9262).
---
.../MobileNativeChatPermission.test.ts | 2 +-
.../session/MobileNativeChatPermission.tsx | 9 +-
.../mobile-structured-agent-session-cancel.ts | 12 +-
...uctured-agent-session-host-support.test.ts | 5 +
...e-structured-agent-session-host-support.ts | 4 +
...structured-operation-id-retirement.test.ts | 1 +
...tured-agent-session-prompt-cancel.test.tsx | 33 +++-
...tured-agent-session-queued.test-fixture.ts | 1 +
.../use-mobile-structured-agent-session.ts | 3 +
.../claude/claude-structured-prompt-items.ts | 3 +-
.../journal-approval-subject-kinds.test.ts | 22 +++
.../journal-prompt-body-bounds.ts | 19 +-
.../journal-row-schema.ts | 5 +-
.../journal-row-table.ts | 2 +-
.../journal-unknown-row-kind.test.ts | 1 +
...d-agent-session-newer-content-host.test.ts | 166 ++++++++++++++++++
...ctured-agent-session-prompt-cancel.test.ts | 44 ++++-
.../structured-agent-session-rewind.test.ts | 47 ++++-
.../structured-rewind-retained-host-rows.ts | 12 +-
.../NativeChatApprovalCard.test.tsx | 2 +-
.../native-chat/NativeChatApprovalCard.tsx | 9 +-
...tStructuredSession.newer-approval.test.tsx | 21 ++-
.../NativeChatStructuredSession.tsx | 2 +-
...tured-agent-session-prompt-cancel.test.tsx | 41 +++++
.../use-structured-agent-session.ts | 8 +-
src/shared/agent-session-approval-subject.ts | 21 ++-
...ent-session-journal-body-admission.test.ts | 7 +
.../agent-session-journal-body-admission.ts | 5 +-
.../agent-session-journal-open-union.ts | 11 +-
src/shared/agent-session-journal-types.ts | 15 +-
30 files changed, 485 insertions(+), 48 deletions(-)
create mode 100644 src/main/native-chat/agent-session-journal/journal-approval-subject-kinds.test.ts
create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-newer-content-host.test.ts
diff --git a/mobile/src/session/MobileNativeChatPermission.test.ts b/mobile/src/session/MobileNativeChatPermission.test.ts
index 4641071ef508..26a3a0602373 100644
--- a/mobile/src/session/MobileNativeChatPermission.test.ts
+++ b/mobile/src/session/MobileNativeChatPermission.test.ts
@@ -175,7 +175,7 @@ describe('MobileNativeChatPermission', () => {
return { onRespond, onCancel, options: actions.findAllByType('Pressable') }
}
- it('with no detail: says so, answers nothing, and the cancel ends the turn', async () => {
+ it('with no detail: says so, answers nothing, and only its cancel reaches the host', async () => {
const { onRespond, onCancel, options } = await renderNewer(undefined)
expect(
renderer!.root.findByProps({ testID: 'native-chat-approval-needs-newer-orca' }).props
diff --git a/mobile/src/session/MobileNativeChatPermission.tsx b/mobile/src/session/MobileNativeChatPermission.tsx
index d4045143c27b..a5e90a274d2f 100644
--- a/mobile/src/session/MobileNativeChatPermission.tsx
+++ b/mobile/src/session/MobileNativeChatPermission.tsx
@@ -3,7 +3,10 @@ import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'
import { ShieldQuestion, X } from 'lucide-react-native'
import { MobileMarkdown } from '../components/MobileMarkdown'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
-import { isNewerApprovalSubject } from '../../../src/shared/agent-session-approval-subject'
+import {
+ isNewerApprovalSubject,
+ isPlanApprovalSubject
+} from '../../../src/shared/agent-session-approval-subject'
import type { MobileChatPermission } from './mobile-native-chat-permission'
// Renders a detected agent permission ask as a card with tappable options.
@@ -24,7 +27,7 @@ function MobileNativeChatPermissionImpl({
const [inFlight, setSubmitting] = useState(false)
const submitting = inFlight || disabled
const submittingRef = useRef(false)
- // A newer Orca's subject: its detail is shown, and only the cancel that ends the turn answers.
+ // A newer Orca's subject: its detail is shown, and only the card's cancel answers.
const newerSubject = isNewerApprovalSubject(permission.subject)
const hasContext = Boolean(
permission.description ||
@@ -100,7 +103,7 @@ function MobileNativeChatPermissionImpl({
{permission.matchedAskRule.source}
) : null}
- {permission.subject?.kind === 'plan' ? (
+ {isPlanApprovalSubject(permission.subject) ? (
{permission.subject.filePath ? (
diff --git a/mobile/src/session/mobile-structured-agent-session-cancel.ts b/mobile/src/session/mobile-structured-agent-session-cancel.ts
index 3a3286510529..5a63ffd1f83f 100644
--- a/mobile/src/session/mobile-structured-agent-session-cancel.ts
+++ b/mobile/src/session/mobile-structured-agent-session-cancel.ts
@@ -27,6 +27,8 @@ export async function requestMobileStructuredAgentSessionCancel(args: {
enabled: boolean
stateRef: { readonly current: StructuredAgentSessionState }
promptCancelSupported: boolean | null
+ /** Whether the host takes a cancel that names no turn. */
+ conversationStopSupported: boolean | null
prompt?: PromptIdentity
/** Whether the host answers a repeated Stop of a turn quietly; null until the status probe answers. */
hostAnswersRepeatedStops: boolean | null
@@ -38,15 +40,15 @@ export async function requestMobileStructuredAgentSessionCancel(args: {
const { client, enabled, inFlight, onSendError, sessionId, stateRef } = args
const current = stateRef.current
const turnId = activeStructuredAgentSessionTurnId(current.items)
- if (!client || !sessionId || !enabled || current.fence === null || !turnId) {
+ const prompt = args.prompt && args.promptCancelSupported === true ? args.prompt : undefined
+ // A card that outlived its turn: the host declines it without a turn to name.
+ const turnless = !turnId && prompt !== undefined && args.conversationStopSupported === true
+ if (!client || !sessionId || !enabled || current.fence === null || (!turnId && !turnless)) {
onSendError('Stop not sent')
return false
}
// Check the capability before fields enter the fingerprint.
- const fields = {
- turnId,
- ...(args.prompt && args.promptCancelSupported === true ? { prompt: args.prompt } : {})
- }
+ const fields = { ...(turnId ? { turnId } : {}), ...(prompt ? { prompt } : {}) }
// Every press is its own Stop: a kept id would be answered from the last one and stop nothing.
const fence = current.fence
const stop = () => sendStop({ client, sessionId, fence, fields, onSendError })
diff --git a/mobile/src/session/mobile-structured-agent-session-host-support.test.ts b/mobile/src/session/mobile-structured-agent-session-host-support.test.ts
index 0902f318ae3b..b34a47af05a5 100644
--- a/mobile/src/session/mobile-structured-agent-session-host-support.test.ts
+++ b/mobile/src/session/mobile-structured-agent-session-host-support.test.ts
@@ -10,6 +10,7 @@ import { structuredAgentSessionHostSupport } from './mobile-structured-agent-ses
describe('structuredAgentSessionHostSupport', () => {
it('reads each structured-session feature from the host capability list', () => {
expect(structuredAgentSessionHostSupport([])).toEqual({
+ conversationStop: false,
promptCancel: false,
questionAnswers: false,
queuedMessages: false,
@@ -18,6 +19,7 @@ describe('structuredAgentSessionHostSupport', () => {
expect(
structuredAgentSessionHostSupport([AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY])
).toEqual({
+ conversationStop: false,
promptCancel: false,
questionAnswers: true,
queuedMessages: false,
@@ -26,6 +28,7 @@ describe('structuredAgentSessionHostSupport', () => {
expect(
structuredAgentSessionHostSupport([AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY])
).toEqual({
+ conversationStop: false,
promptCancel: true,
questionAnswers: false,
queuedMessages: false,
@@ -34,6 +37,7 @@ describe('structuredAgentSessionHostSupport', () => {
expect(
structuredAgentSessionHostSupport([AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY])
).toEqual({
+ conversationStop: false,
promptCancel: false,
questionAnswers: false,
queuedMessages: true,
@@ -42,6 +46,7 @@ describe('structuredAgentSessionHostSupport', () => {
expect(
structuredAgentSessionHostSupport([AGENT_SESSION_REPEATED_STOP_RUNTIME_CAPABILITY])
).toEqual({
+ conversationStop: false,
promptCancel: false,
questionAnswers: false,
queuedMessages: false,
diff --git a/mobile/src/session/mobile-structured-agent-session-host-support.ts b/mobile/src/session/mobile-structured-agent-session-host-support.ts
index 9ae1d1e90b1a..e2647782eca3 100644
--- a/mobile/src/session/mobile-structured-agent-session-host-support.ts
+++ b/mobile/src/session/mobile-structured-agent-session-host-support.ts
@@ -1,4 +1,5 @@
import {
+ AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY,
AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY,
AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY,
AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY,
@@ -7,6 +8,8 @@ import {
/** Structured-session features the connected host advertised; null until the status probe answers. */
export type StructuredAgentSessionHostSupport = {
+ /** A cancel may name no turn, as for a card that outlived its turn. */
+ conversationStop: boolean
promptCancel: boolean
questionAnswers: boolean
/** Mid-turn sends queue as host-held drafts; an older host keeps today's immediate path. */
@@ -19,6 +22,7 @@ export function structuredAgentSessionHostSupport(
capabilities: readonly string[]
): StructuredAgentSessionHostSupport {
return {
+ conversationStop: capabilities.includes(AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY),
promptCancel: capabilities.includes(AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY),
questionAnswers: capabilities.includes(AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY),
queuedMessages: capabilities.includes(AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY),
diff --git a/mobile/src/session/mobile-structured-operation-id-retirement.test.ts b/mobile/src/session/mobile-structured-operation-id-retirement.test.ts
index 6ca270745684..19c3e23b9f79 100644
--- a/mobile/src/session/mobile-structured-operation-id-retirement.test.ts
+++ b/mobile/src/session/mobile-structured-operation-id-retirement.test.ts
@@ -56,6 +56,7 @@ function cancelArgs(client: RpcClient, hostAnswersRepeatedStops: boolean | null
enabled: true,
stateRef: { current: runningState() },
promptCancelSupported: null,
+ conversationStopSupported: null,
hostAnswersRepeatedStops,
inFlight: new Map>(),
onSendError: vi.fn()
diff --git a/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx
index 0bb7a8f74285..5c82ba44d88c 100644
--- a/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx
+++ b/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx
@@ -109,10 +109,12 @@ const client: RpcClient = {
function Harness({
promptCancelSupported,
- questionAnswersSupported = false
+ questionAnswersSupported = false,
+ conversationStopSupported = false
}: {
promptCancelSupported: boolean
questionAnswersSupported?: boolean
+ conversationStopSupported?: boolean
}): null {
hook = useMobileStructuredAgentSession({
client,
@@ -122,6 +124,7 @@ function Harness({
connected: true,
agent: 'codex',
hostSupport: {
+ conversationStop: conversationStopSupported,
promptCancel: promptCancelSupported,
questionAnswers: questionAnswersSupported,
queuedMessages: false,
@@ -206,6 +209,34 @@ describe('mobile structured prompt cancellation', () => {
expect(call?.[1]).not.toHaveProperty('prompt')
})
+ it('cancels a card that outlived its turn on a host that takes a cancel naming no turn', async () => {
+ state = { ...state, items: [pendingApproval()] }
+ act(() => {
+ renderer = create(
+ createElement(Harness, { promptCancelSupported: true, conversationStopSupported: true })
+ )
+ })
+ await act(async () => {
+ expect(await hook.cancelPrompt()).toBe(true)
+ })
+ const call = mocks.sendRequest.mock.calls.find(([method]) => method === 'agentSession.cancel')
+ expect(call?.[1]).toMatchObject({ prompt: { itemId: 'approval-1', expectedRevision: 4 } })
+ expect(call?.[1]).not.toHaveProperty('turnId')
+ })
+
+ it('sends nothing for a card with no turn to an older host', async () => {
+ state = { ...state, items: [pendingApproval()] }
+ act(() => {
+ renderer = create(createElement(Harness, { promptCancelSupported: true }))
+ })
+ await act(async () => {
+ expect(await hook.cancelPrompt()).toBe(false)
+ })
+ expect(mocks.sendRequest.mock.calls.some(([method]) => method === 'agentSession.cancel')).toBe(
+ false
+ )
+ })
+
it('cancels a question card with its item identity', async () => {
state = { ...state, items: [runningTurn(), pendingQuestion()] }
act(() => {
diff --git a/mobile/src/session/use-mobile-structured-agent-session-queued.test-fixture.ts b/mobile/src/session/use-mobile-structured-agent-session-queued.test-fixture.ts
index bbfe297ef7f3..fc374e28e29b 100644
--- a/mobile/src/session/use-mobile-structured-agent-session-queued.test-fixture.ts
+++ b/mobile/src/session/use-mobile-structured-agent-session-queued.test-fixture.ts
@@ -13,6 +13,7 @@ import type { StructuredAgentSessionHostSupport } from './mobile-structured-agen
export const SESSION_ID = 'session-1'
export const CAPABLE: StructuredAgentSessionHostSupport = {
+ conversationStop: false,
promptCancel: false,
questionAnswers: false,
queuedMessages: true,
diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts
index fd7632018df7..4dd91bb6ff4f 100644
--- a/mobile/src/session/use-mobile-structured-agent-session.ts
+++ b/mobile/src/session/use-mobile-structured-agent-session.ts
@@ -98,6 +98,7 @@ export function useMobileStructuredAgentSession(args: {
// Old host ⇒ exactly today's behavior: no delivery field, no cards, plain Stop.
const queueCapable = hostSupport?.queuedMessages === true
const promptCancelSupported = hostSupport?.promptCancel ?? null
+ const conversationStopSupported = hostSupport?.conversationStop ?? null
const hostAnswersRepeatedStops = hostSupport?.quietRepeatedStop ?? null
const sessionKey = encodeNativeChatTranscriptIdentity([sourceIdentity, agent, sessionId])
const commandPendingRef = useRef(false)
@@ -204,6 +205,7 @@ export function useMobileStructuredAgentSession(args: {
onSendError,
prompt,
promptCancelSupported,
+ conversationStopSupported,
sessionId,
stateRef
}),
@@ -213,6 +215,7 @@ export function useMobileStructuredAgentSession(args: {
hostAnswersRepeatedStops,
onSendError,
promptCancelSupported,
+ conversationStopSupported,
sessionId,
stateRef
]
diff --git a/src/main/claude/claude-structured-prompt-items.ts b/src/main/claude/claude-structured-prompt-items.ts
index c74e4741fc1a..cfb30d7363a0 100644
--- a/src/main/claude/claude-structured-prompt-items.ts
+++ b/src/main/claude/claude-structured-prompt-items.ts
@@ -6,6 +6,7 @@ import type {
AgentJournalQuestionItem
} from '../../shared/agent-session-journal-types'
import { formatToolInput, truncateToolDetail } from '../../shared/native-chat-tool-summary'
+import { isPlanApprovalSubject } from '../../shared/agent-session-approval-subject'
import { boundJournalPromptBody } from '../native-chat/agent-session-journal/journal-prompt-body-bounds'
import { claudeRecord, claudeText } from './claude-structured-item-translation'
import {
@@ -44,7 +45,7 @@ export function claudePromptIdentity(input: {
}
export function claudeApprovalItem(prompt: ClaudePendingPrompt): AgentJournalApprovalItem {
- const planSubject = prompt.subject?.kind === 'plan' ? prompt.subject : null
+ const planSubject = isPlanApprovalSubject(prompt.subject) ? prompt.subject : null
const detail = truncateToolDetail(planSubject?.text ?? formatToolInput(prompt.input))
return boundJournalPromptBody({
kind: 'approval',
diff --git a/src/main/native-chat/agent-session-journal/journal-approval-subject-kinds.test.ts b/src/main/native-chat/agent-session-journal/journal-approval-subject-kinds.test.ts
new file mode 100644
index 000000000000..063e5d48f825
--- /dev/null
+++ b/src/main/native-chat/agent-session-journal/journal-approval-subject-kinds.test.ts
@@ -0,0 +1,22 @@
+// Every approval subject kind the journal schema knows, against the kinds every supported client
+// draws. A client from before unknown subjects were refused leaves Approve live for a kind it cannot
+// draw, so a new kind ships only behind a client capability that withholds it from such clients.
+
+import { expect, it } from 'vitest'
+import { AGENT_JOURNAL_APPROVAL_SUBJECT_KINDS } from '../../../shared/agent-session-journal-schemas'
+
+/** Drawn by every supported client. */
+const SUBJECT_KINDS_EVERY_CLIENT_DRAWS = ['plan']
+/** Kinds the host withholds from clients without the capability; none exists yet (STA-9262). */
+const CAPABILITY_GATED_SUBJECT_KINDS: string[] = []
+
+it('adds no approval subject kind without a client-capability gate', () => {
+ const ungated = [...AGENT_JOURNAL_APPROVAL_SUBJECT_KINDS]
+ .filter((kind) => !CAPABILITY_GATED_SUBJECT_KINDS.includes(kind))
+ .sort()
+ expect(
+ ungated,
+ 'New approval subject kinds need a client-capability gate first: older clients would let ' +
+ 'users approve it blind. See https://linear.app/stably/issue/STA-9262'
+ ).toEqual(SUBJECT_KINDS_EVERY_CLIENT_DRAWS)
+})
diff --git a/src/main/native-chat/agent-session-journal/journal-prompt-body-bounds.ts b/src/main/native-chat/agent-session-journal/journal-prompt-body-bounds.ts
index fe7b974d77eb..4aa275994342 100644
--- a/src/main/native-chat/agent-session-journal/journal-prompt-body-bounds.ts
+++ b/src/main/native-chat/agent-session-journal/journal-prompt-body-bounds.ts
@@ -5,6 +5,7 @@ import type {
AgentJournalQuestion,
AgentJournalQuestionItem
} from '../../../shared/agent-session-journal-types'
+import { isPlanApprovalSubject } from '../../../shared/agent-session-approval-subject'
import {
boundInlineText,
boundPayload,
@@ -68,16 +69,20 @@ export function boundJournalPromptBody(
: { ruleContent: boundPromptText(body.matchedAskRule.ruleContent) })
}
}),
+ // A newer Orca's subject is carried as it was: rebuilt as a plan, it would be a different
+ // request, and its fields are not this build's to bound.
...(body.subject === undefined
? {}
: {
- subject: {
- kind: 'plan',
- text: boundPromptText(body.subject.text),
- ...(body.subject.filePath === undefined
- ? {}
- : { filePath: boundPromptText(body.subject.filePath) })
- }
+ subject: isPlanApprovalSubject(body.subject)
+ ? {
+ kind: 'plan',
+ text: boundPromptText(body.subject.text),
+ ...(body.subject.filePath === undefined
+ ? {}
+ : { filePath: boundPromptText(body.subject.filePath) })
+ }
+ : body.subject
}),
detail: body.detail === null ? null : boundPromptText(body.detail),
options: boundPromptOptions(body.options)
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 9bdc9746dd0c..b145477c69a7 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
@@ -36,6 +36,7 @@ import {
readAgentJournalMessageBody,
type AgentJournalContentVerdict
} from '../../../shared/agent-session-journal-body-admission'
+import { isJournalTag } from '../../../shared/agent-session-journal-open-union'
import type { StructuredAgentSessionStopCause } from '../agent-session-wire/structured-agent-session-stop-cause'
import { dropUnusableRowAnnotations } from './journal-row-unusable-annotations'
@@ -267,7 +268,7 @@ function journalRowContent(record: Record): AgentJournalContent
return contentCheck(record)
}
// A newer build's kind is placed by the envelope every row keeps.
- return typeof kind === 'string' && kind !== '' ? 'unreadable' : 'malformed'
+ return isJournalTag(kind) ? 'unreadable' : 'malformed'
}
function isJournalRow(record: Record): record is JournalRow {
@@ -373,7 +374,7 @@ function lifecycleMutationContent(value: unknown): AgentJournalContentVerdict {
if (contentCheck) {
return contentCheck(value)
}
- return typeof kind === 'string' && kind !== '' ? 'unreadable' : 'malformed'
+ return isJournalTag(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-row-table.ts b/src/main/native-chat/agent-session-journal/journal-row-table.ts
index 043ad44bddc5..a558252a8994 100644
--- a/src/main/native-chat/agent-session-journal/journal-row-table.ts
+++ b/src/main/native-chat/agent-session-journal/journal-row-table.ts
@@ -50,7 +50,7 @@ export function insertJournalRow(
if (!readBack.ok || readBack.row.seq !== row.seq) {
throw new AgentSessionJournalError(
'journal_row_rejected',
- `a ${row.kind} row for ${sessionId} would not read back, so it was not written`
+ `the ${row.kind} row for ${sessionId} would not read back, so it was not written`
)
}
db.prepare(INSERT_ROW).run(sessionId, row.epoch, row.seq, row.ts, rowJson)
diff --git a/src/main/native-chat/agent-session-journal/journal-unknown-row-kind.test.ts b/src/main/native-chat/agent-session-journal/journal-unknown-row-kind.test.ts
index 28135b7204e1..9e78ffce0d7e 100644
--- a/src/main/native-chat/agent-session-journal/journal-unknown-row-kind.test.ts
+++ b/src/main/native-chat/agent-session-journal/journal-unknown-row-kind.test.ts
@@ -171,6 +171,7 @@ describe('parsing a row of a kind this build does not know', () => {
['a string timestamp', { ts: '5007' }],
['an empty epoch', { epoch: '' }],
['an empty kind', { kind: '' }],
+ ['a kind of only whitespace', { kind: ' \t' }],
['a kind that is not a string', { kind: 7 }]
])('reads one with %s as malformed', (_name, broken) => {
const parsed = parseJournalRow(JSON.stringify(newerRow('epoch-1', 7, broken)))
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-newer-content-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-newer-content-host.test.ts
new file mode 100644
index 000000000000..eaccdf968343
--- /dev/null
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-newer-content-host.test.ts
@@ -0,0 +1,166 @@
+// On a real host: what a newer Orca left in a chat, and a provider row the reader would reject,
+// never leave the chat unable to take its next message.
+
+import { cp, rm } from 'node:fs/promises'
+import { afterEach, beforeEach, expect, it, vi } from 'vitest'
+import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types'
+import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types'
+import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness'
+import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support'
+import { StructuredAgentSessionHost } from './structured-agent-session-host'
+import {
+ adapter,
+ attach,
+ CALLER,
+ envelope,
+ hostTestState,
+ replaceHostTestState
+} from './structured-agent-session-host-test-harness'
+import {
+ HOST_TEST_NOW as NOW,
+ HOST_TEST_SESSION as SESSION,
+ HOST_TEST_THREAD as THREAD,
+ hostTestMessage
+} from './structured-agent-session-host-test-data'
+import { createStructuredAgentSessionLogger } from './structured-agent-session-logger'
+
+const relaunchedRoots: string[] = []
+
+beforeEach(() => {
+ vi.spyOn(console, 'warn').mockImplementation(() => undefined)
+})
+
+afterEach(async () => {
+ vi.restoreAllMocks()
+ await Promise.all(
+ relaunchedRoots.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))
+ )
+})
+
+function providerEvents() {
+ const events = hostTestState().acquire.mock.calls.at(-1)?.[0].events
+ if (!events) {
+ throw new Error('no acquired provider')
+ }
+ return events
+}
+
+function emit(ordinal: number, body: AgentJournalItemBody): void {
+ providerEvents().appendItem(
+ { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal },
+ body,
+ { turnScope: AGENT_JOURNAL_THREAD_SCOPE }
+ )
+}
+
+function send(host: StructuredAgentSessionHost, text: string) {
+ const body = hostTestMessage(text)
+ return host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body })
+}
+
+async function historyBodies(host: StructuredAgentSessionHost): Promise {
+ const page = await host.history({ sessionId: SESSION, direction: 'tail' })
+ if (!page.ok) {
+ throw new Error('history refused')
+ }
+ return page.page.items.map((item) => item.body)
+}
+
+/** A restarted process over the same files, holding no chat open and owning no provider. */
+async function relaunch(): Promise {
+ const before = hostTestState()
+ await before.host.flushAllStreamedEvents()
+ await before.store.renewLeases([])
+ const relaunched = `${before.root}-relaunched`
+ relaunchedRoots.push(relaunched)
+ // A dead process holds no lock.
+ await cp(before.root, relaunched, {
+ recursive: true,
+ filter: (source) => !source.includes('.lock')
+ })
+ const store = await openTestAgentSessionRecordStore(relaunched)
+ const host = new StructuredAgentSessionHost({
+ logger: createStructuredAgentSessionLogger(),
+ store,
+ adapter: adapter(),
+ journalDatabase: openTestJournalHostDatabase(relaunched),
+ claimKeyId: 'key-1',
+ mintSpawnToken: () => 'spawn-next',
+ now: () => NOW
+ })
+ replaceHostTestState({ store, host })
+ return host
+}
+
+it("settles a newer Orca's pending approval as it was, and the chat takes its next message", async () => {
+ await attach()
+ // A subject kind this build does not know, still pending when its provider went away.
+ const newerApproval: AgentJournalItemBody = JSON.parse(
+ JSON.stringify({
+ kind: 'approval',
+ title: 'Review proposed change',
+ detail: null,
+ subject: { kind: 'diff', path: 'a.ts', text: 'not a plan' },
+ options: [{ id: 'allow', label: 'Approve' }],
+ resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null }
+ })
+ )
+ emit(7, newerApproval)
+ const host = await relaunch()
+
+ expect(await send(host, 'after the newer approval')).toMatchObject({ ok: true })
+ await vi.waitFor(() => expect(hostTestState().dispatch).toHaveBeenCalled())
+
+ const approval = (await historyBodies(host)).find((body) => body.kind === 'approval')
+ expect(approval).toMatchObject({
+ subject: { kind: 'diff', path: 'a.ts', text: 'not a plan' },
+ resolution: { state: 'cancelled' }
+ })
+ await host.flushAllStreamedEvents()
+})
+
+it('ends a turn whose provider output would not read back as failed, and the next send works', async () => {
+ const closeSession = vi.fn(async () => true)
+ Object.assign(hostTestState().host.deps.adapter, { closeSession })
+ // A provider that names its generation, as the real adapters do, so its stop settles the turn.
+ const { acquire } = hostTestState()
+ const acquired = acquire.getMockImplementation()!
+ acquire.mockImplementation(async (input) => ({
+ ...(await acquired(input)),
+ acquisitionGeneration: `generation-${acquire.mock.calls.length}`
+ }))
+ await attach()
+ emit(1, {
+ kind: 'status',
+ text: 'Turn started',
+ turnLifecycle: { turnId: 'turn-1', state: 'running', startedAt: NOW }
+ })
+ emit(2, { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'written first' }] })
+ // TypeScript accepts it; the persisted reader rejects a call id that is only spaces.
+ emit(3, {
+ kind: 'message',
+ role: 'assistant',
+ blocks: [{ type: 'tool-call', name: 'Bash', input: null, callId: ' ' }]
+ })
+ const { host, store } = hostTestState()
+ await vi.waitFor(() => {
+ expect(closeSession).toHaveBeenCalledWith(SESSION)
+ expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released' })
+ })
+
+ // The turn is over, with the notice any failed journal write gives.
+ expect(await historyBodies(host)).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ kind: 'turn', turnId: 'turn-1', state: 'interrupted' }),
+ expect.objectContaining({ kind: 'status', failure: { kind: 'hostFault' } })
+ ])
+ )
+ expect(await send(host, 'the next message')).toMatchObject({ ok: true })
+ await vi.waitFor(() => expect(hostTestState().acquire).toHaveBeenCalledTimes(2))
+
+ const reopened = await relaunch()
+ expect(await historyBodies(reopened)).toContainEqual(
+ expect.objectContaining({ blocks: [{ type: 'text', text: 'written first' }] })
+ )
+ await reopened.flushAllStreamedEvents()
+})
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts
index 8d67ba0aa7c0..b5a83e5087b2 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts
@@ -3,7 +3,10 @@ import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
-import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types'
+import type {
+ AgentJournalApprovalSubject,
+ AgentSessionJournalIdentity
+} from '../../../shared/agent-session-journal-types'
import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support'
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
import {
@@ -43,7 +46,8 @@ afterEach(async () => {
async function pendingPrompt(
options = [{ id: 'allow', label: 'Allow' }],
/** Raise the card in a turn that is still running, rather than on the conversation. */
- inLiveTurn = false
+ inLiveTurn = false,
+ subject?: AgentJournalApprovalSubject
): Promise<{ journal: AgentSessionJournal; itemId: string }> {
root = await mkdtemp(join(tmpdir(), 'orca-prompt-cancel-'))
const journal = await journals.open({ identity: IDENTITY, stateDirectory: root })
@@ -60,6 +64,7 @@ async function pendingPrompt(
kind: 'approval',
title: 'Approve?',
detail: null,
+ ...(subject ? { subject } : {}),
options,
resolution: {
state: 'pending',
@@ -309,14 +314,16 @@ describe("a card's own Cancel, as its provider answers it", () => {
answer: AgentSessionPromptCancelRoute | undefined,
revision = 1,
endsSession = true,
- inLiveTurn = true
+ inLiveTurn = true,
+ subject?: AgentJournalApprovalSubject
) {
const { journal, itemId } = await pendingPrompt(
[
{ id: 'allow', label: 'Allow' },
{ id: 'deny', label: 'Deny' }
],
- inLiveTurn
+ inLiveTurn,
+ subject
)
const ctx = context(
journal,
@@ -348,6 +355,35 @@ describe("a card's own Cancel, as its provider answers it", () => {
return { result, routes, answerPrompt, dismissPrompt, card }
}
+ describe('on an approval whose subject a newer Orca wrote, which only its Cancel answers', () => {
+ const NEWER_SUBJECT: AgentJournalApprovalSubject = JSON.parse('{"kind":"diff","path":"a.ts"}')
+
+ it('has Claude decline the request, the turn going on, and keeps the subject as it was', async () => {
+ const { routes, dismissPrompt, card } = await cancelCard(
+ { kind: 'dismiss' },
+ 1,
+ true,
+ true,
+ NEWER_SUBJECT
+ )
+
+ expect(dismissPrompt).toHaveBeenCalledWith(expect.objectContaining({ answer: true }))
+ expect(routes.interrupt).not.toHaveBeenCalled()
+ expect(routes.stop).not.toHaveBeenCalled()
+ expect(card).toMatchObject({
+ subject: { kind: 'diff', path: 'a.ts' },
+ resolution: { state: 'cancelled' }
+ })
+ })
+
+ it('has Codex end the turn holding it', async () => {
+ const { routes, dismissPrompt } = await cancelCard(undefined, 1, true, true, NEWER_SUBJECT)
+
+ expect(routes.interrupt).toHaveBeenCalledOnce()
+ expect(dismissPrompt).not.toHaveBeenCalled()
+ })
+ })
+
it('interrupts the turn holding the card for a provider that gives no answer', async () => {
const { routes, answerPrompt } = await cancelCard(undefined)
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts
index 6cc37ccb724f..9553b689b027 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts
@@ -1,4 +1,7 @@
-import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types'
+import {
+ AGENT_JOURNAL_THREAD_SCOPE,
+ type AgentJournalItemBody
+} from '../../../shared/agent-session-journal-types'
import { AgentSessionJournal } from '../agent-session-journal/journal-store'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
@@ -458,6 +461,48 @@ describe('host rewind', () => {
expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('completed')
})
+ it("keeps a newer Orca's item of an unknown kind, in its place, through a Codex provider hydration", async () => {
+ expect(await host.attach(caller, hostTestAttachParams(null))).toMatchObject({ ok: true })
+ const message = (turnId: string) => ({
+ provider: 'codex' as const,
+ threadId: HOST_TEST_THREAD,
+ turnId,
+ ordinal: 0
+ })
+ const newerRow = { provider: 'orca' as const, clientMessageId: 'newer-card' }
+ // A kind this build does not know; no provider's history holds it.
+ const newerBody: AgentJournalItemBody = JSON.parse(
+ JSON.stringify({ kind: 'plan-card', steps: [{ text: 'by a newer build' }] })
+ )
+ sink.appendItem(message('kept'), hostTestMessage('kept'), {
+ turnScope: AGENT_JOURNAL_THREAD_SCOPE
+ })
+ sink.appendItem(newerRow, newerBody, { turnScope: AGENT_JOURNAL_THREAD_SCOPE })
+ sink.appendItem(message('drop'), hostTestMessage('drop'), {
+ turnScope: AGENT_JOURNAL_THREAD_SCOPE
+ })
+ await host.flushStreamedEvents(HOST_TEST_SESSION)
+ const items = [{ identity: message('kept'), body: hostTestMessage('kept from provider') }]
+ rewind.mockImplementationOnce(async (input) => {
+ await input.onPrepared?.(items)
+ return { ok: true, items }
+ })
+
+ expect(
+ await host.rewind(caller, await params(agentJournalItemKey(message('drop'))))
+ ).toMatchObject({ ok: true })
+
+ expect(
+ (await host.journalSnapshot(HOST_TEST_SESSION)).items.map(({ itemId, body }) => ({
+ itemId,
+ body
+ }))
+ ).toEqual([
+ { itemId: agentJournalItemKey(message('kept')), body: hostTestMessage('kept from provider') },
+ { itemId: agentJournalItemKey(newerRow), body: newerBody }
+ ])
+ })
+
it('keeps each kept turn opened by the message that opened it, under its provider key', async () => {
expect(await host.attach(caller, hostTestAttachParams(null))).toMatchObject({ ok: true })
const message = (turnId: string, ordinal = 0) => ({
diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.ts b/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.ts
index 6496518b50bd..2c80d9150afb 100644
--- a/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.ts
+++ b/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.ts
@@ -1,10 +1,12 @@
// Provider preflight returns provider items only. The host's lifecycle rows are its own record, so
// a rewind that takes the provider list as the new epoch must splice those rows back beside the
-// provider item each one followed. Provider items carry neither turn scope nor producer, so each
-// keeps the ones its retained row held.
+// provider item each one followed; so is an item of a kind a newer Orca wrote, which no provider
+// holds. Provider items carry neither turn scope nor producer, so each keeps the ones its retained
+// row held.
import { parseCodexGoalJournalItemId } from '../../codex/codex-goal-journal-identity'
import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key'
+import { AGENT_JOURNAL_ITEM_BODY_KINDS } from '../../../shared/agent-session-journal-schemas'
import type {
AgentJournalItemBody,
AgentJournalItemIdentity,
@@ -28,7 +30,7 @@ export function isRetainedHostLifecycleRow(item: RetainedRow): boolean {
)
}
-/** `reference` fixes where each host row sits; provider items are the ordered spine. */
+/** `reference` fixes where each carried row sits; provider items are the ordered spine. */
export function mergeRetainedHostLifecycleRows(
reference: readonly RetainedRow[],
providerItems: readonly RetainedRow[]
@@ -38,7 +40,9 @@ export function mergeRetainedHostLifecycleRows(
const rowsAfter = new Map()
let anchor = -1
for (const item of reference) {
- if (!isRetainedHostLifecycleRow(item)) {
+ const carried =
+ isRetainedHostLifecycleRow(item) || !AGENT_JOURNAL_ITEM_BODY_KINDS.has(item.body.kind)
+ if (!carried) {
anchor = spineIndex.get(item.itemId) ?? anchor
} else if (!spineIndex.has(item.itemId)) {
rowsAfter.set(anchor, [...(rowsAfter.get(anchor) ?? []), item])
diff --git a/src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx b/src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx
index 973172aa726a..82d04d625371 100644
--- a/src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx
@@ -33,7 +33,7 @@ describe('NativeChatApprovalCard', () => {
return { onChoose, onCancel }
}
- it('with no detail: says so, answers nothing, and the cancel ends the turn', () => {
+ it('with no detail: says so, answers nothing, and only its cancel reaches the host', () => {
const { onChoose, onCancel } = renderNewer(undefined)
expect(screen.getByText(NEEDS_NEWER_ORCA)).toBeTruthy()
for (const label of ['Approve', 'Deny']) {
diff --git a/src/renderer/src/components/native-chat/NativeChatApprovalCard.tsx b/src/renderer/src/components/native-chat/NativeChatApprovalCard.tsx
index 03764ce41f3c..9e213f0161a6 100644
--- a/src/renderer/src/components/native-chat/NativeChatApprovalCard.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatApprovalCard.tsx
@@ -5,7 +5,10 @@ import { translate } from '@/i18n/i18n'
import CommentMarkdown, {
type CommentMarkdownLinkClickHandler
} from '@/components/sidebar/CommentMarkdown'
-import { isNewerApprovalSubject } from '../../../../shared/agent-session-approval-subject'
+import {
+ isNewerApprovalSubject,
+ isPlanApprovalSubject
+} from '../../../../shared/agent-session-approval-subject'
import { NativeChatCodeBlock } from './NativeChatCodeBlock'
import type { ChatApproval } from './native-chat-interactive-prompt'
@@ -38,7 +41,7 @@ export function NativeChatApprovalCard({
allowFileUriLinks = false
}: NativeChatApprovalCardProps): React.JSX.Element {
const cardRef = useRef(null)
- // A newer Orca's subject: its detail is shown, and only the cancel that ends the turn answers.
+ // A newer Orca's subject: its detail is shown, and only the card's cancel answers.
const newerSubject = isNewerApprovalSubject(approval.subject)
const hasContext = Boolean(
approval.description ||
@@ -128,7 +131,7 @@ export function NativeChatApprovalCard({
) : null}
- {approval.subject?.kind === 'plan' ? (
+ {isPlanApprovalSubject(approval.subject) ? (
{
+it("hands the card a newer Orca's subject in a writable chat, and its cancel goes to the host with the card", () => {
mocks.turnId = 'turn-1'
mocks.promptItems = [NEWER_APPROVAL]
render(
@@ -71,3 +71,22 @@ it("hands the card a newer Orca's subject in a writable chat, and its cancel end
expectedRevision: 3
})
})
+
+it('sends the card to the host even when no turn is running, so its cancel still answers it', () => {
+ mocks.promptItems = [NEWER_APPROVAL]
+ render(
+
+ )
+ mocks.approvalCardProps?.onCancel?.()
+ expect(mocks.cancel).toHaveBeenCalledWith(null, {
+ itemId: 'approval-item',
+ expectedRevision: 3
+ })
+})
diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx
index 3cab06aece3d..9edcb6df9832 100644
--- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx
@@ -154,7 +154,7 @@ export function NativeChatStructuredSession(
}
: null
const cancelPrompt = () => {
- if (controller.turnId && prompt) {
+ if (prompt) {
void controller.cancel(controller.turnId, {
itemId: prompt.itemId,
expectedRevision: prompt.revision
diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-prompt-cancel.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-prompt-cancel.test.tsx
index 8e1259ad0d94..5972f49fa471 100644
--- a/src/renderer/src/components/native-chat/use-structured-agent-session-prompt-cancel.test.tsx
+++ b/src/renderer/src/components/native-chat/use-structured-agent-session-prompt-cancel.test.tsx
@@ -2,13 +2,20 @@
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type * as HostCapability from '@/runtime/structured-agent-session-host-capability'
const mocks = vi.hoisted(() => ({
call: vi.fn(),
promptCancelSupported: vi.fn(),
+ stopsConversation: false,
operationId: vi.fn(() => 'operation-1')
}))
+vi.mock('@/runtime/structured-agent-session-host-capability', async (importOriginal) => ({
+ ...(await importOriginal()),
+ useStructuredAgentSessionHostStopsConversation: () => mocks.stopsConversation
+}))
+
vi.mock('@/runtime/structured-agent-session-client', () => ({
callStructuredAgentSession: mocks.call,
supportsStructuredAgentSessionQuietRepeatedStop: vi.fn(async () => false),
@@ -83,6 +90,7 @@ function runningTurn(): AgentJournalRenderItem {
describe('desktop structured prompt cancellation', () => {
beforeEach(() => {
vi.clearAllMocks()
+ mocks.stopsConversation = false
items = [runningTurn(), pendingApproval()]
mocks.promptCancelSupported.mockResolvedValue(false)
mocks.call.mockResolvedValue({ ok: true, value: { turnId: 'turn-1', cancelled: true } })
@@ -120,6 +128,39 @@ describe('desktop structured prompt cancellation', () => {
expect(call?.[2]).not.toHaveProperty('prompt')
})
+ it('cancels a card that outlived its turn on a host that takes a cancel naming no turn', async () => {
+ items = [pendingApproval()]
+ mocks.promptCancelSupported.mockResolvedValue(true)
+ mocks.stopsConversation = true
+ const { result } = renderHook(() =>
+ useStructuredAgentSession({ sessionId: 'session-1', target, agent: 'codex', isVisible: true })
+ )
+
+ await act(async () => {
+ await result.current.cancel(null, { itemId: 'approval-1', expectedRevision: 2 })
+ })
+
+ const call = mocks.call.mock.calls.find(([, method]) => method === 'agentSession.cancel')
+ expect(call?.[2]).toMatchObject({ prompt: { itemId: 'approval-1', expectedRevision: 2 } })
+ expect(call?.[2]).not.toHaveProperty('turnId')
+ })
+
+ it('sends nothing for a card with no turn to a host that needs one', async () => {
+ items = [pendingApproval()]
+ mocks.promptCancelSupported.mockResolvedValue(true)
+ const { result } = renderHook(() =>
+ useStructuredAgentSession({ sessionId: 'session-1', target, agent: 'codex', isVisible: true })
+ )
+
+ await act(async () => {
+ expect(
+ await result.current.cancel(null, { itemId: 'approval-1', expectedRevision: 2 })
+ ).toBeNull()
+ })
+
+ expect(mocks.call.mock.calls.some(([, method]) => method === 'agentSession.cancel')).toBe(false)
+ })
+
it('keeps ordinary composer stop turn-only without a capability probe', async () => {
const { result } = renderHook(() =>
useStructuredAgentSession({ sessionId: 'session-1', target, agent: 'codex', isVisible: true })
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 87b4a9428c2a..8e80392e1959 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
@@ -247,11 +247,17 @@ export function useStructuredAgentSession(args: {
: Promise.resolve(null)
},
queuedMessages: queuedController,
- cancel: async (turnId: string, prompt?: StructuredPromptCancelTarget) => {
+ cancel: async (turnId: string | null, prompt?: StructuredPromptCancelTarget) => {
// Capability negotiation must complete before mutate fingerprints the payload:
// older hosts reject the strict prompt field.
const promptSupported =
prompt !== undefined && (await supportsStructuredAgentSessionPromptCancel(target))
+ // A card that outlived its turn: the host declines it without a turn to name.
+ if (turnId === null) {
+ return promptSupported && stopsConversation
+ ? mutate('agentSession.cancel', 'agentSession.cancel', { prompt })
+ : null
+ }
return mutate('agentSession.cancel', 'agentSession.cancel', {
turnId,
...(promptSupported ? { prompt } : {})
diff --git a/src/shared/agent-session-approval-subject.ts b/src/shared/agent-session-approval-subject.ts
index d984bad1aeb4..4fdad6415a32 100644
--- a/src/shared/agent-session-approval-subject.ts
+++ b/src/shared/agent-session-approval-subject.ts
@@ -1,10 +1,23 @@
-// An approval subject of a kind this build cannot draw is a newer Orca's. Its card shows the
-// approval's `detail` and offers only the cancel that ends the turn: nobody approves what this
-// build cannot show.
+// An approval subject of a kind this build cannot draw is a newer Orca's. It is carried as it was,
+// its card shows the approval's `detail`, and only the card's cancel answers it: nobody approves
+// what this build cannot show. On Claude the cancel denies the request and the turn goes on; on
+// Codex it ends the turn.
-/** The subject kinds this build draws: the known arms of the journal schema's approval subject. */
+import type {
+ AgentJournalApprovalSubject,
+ AgentJournalPlanApprovalSubject
+} from './agent-session-journal-types'
+
+/** The subject kinds this build draws: the known arms of the journal schema's approval subject.
+ * A copy, kept equal by a test, so clients that never load the schema module can read it. */
export const DRAWN_APPROVAL_SUBJECT_KINDS: ReadonlySet = new Set(['plan'])
+export function isPlanApprovalSubject(
+ subject: AgentJournalApprovalSubject | undefined
+): subject is AgentJournalPlanApprovalSubject {
+ return subject?.kind === 'plan'
+}
+
export function isNewerApprovalSubject(subject: { kind: string } | undefined): boolean {
return subject !== undefined && !DRAWN_APPROVAL_SUBJECT_KINDS.has(subject.kind)
}
diff --git a/src/shared/agent-session-journal-body-admission.test.ts b/src/shared/agent-session-journal-body-admission.test.ts
index 44fc2777fb07..d5edff6dc856 100644
--- a/src/shared/agent-session-journal-body-admission.test.ts
+++ b/src/shared/agent-session-journal-body-admission.test.ts
@@ -86,6 +86,12 @@ describe('anything else that fails: damage', () => {
['a body that is a list', [{ kind: 'status' }]],
['no kind', { text: 'x' }],
['an empty kind', { kind: '' }],
+ ['a kind of only whitespace', { kind: ' ' }],
+ ['a block type of only whitespace', { kind: 'message', role: 'user', blocks: [{ type: ' ' }] }],
+ [
+ 'a plan subject kind of only whitespace',
+ { ...PLAN_APPROVAL, subject: { kind: ' ', text: 'x' } }
+ ],
['a kind that is not a string', { kind: 7 }],
[
'a nested literal that is not a string',
@@ -142,6 +148,7 @@ describe("a submission's body", () => {
'readable'
)
expect(readAgentJournalMessageBody({ kind: 'voice-note', clip: 'x' })).toBe('unreadable')
+ expect(readAgentJournalMessageBody({ kind: ' ' })).toBe('malformed')
expect(readAgentJournalMessageBody({ kind: 'status', text: 'x' })).toBe('malformed')
expect(
readAgentJournalMessageBody({ kind: 'message', role: 'user', blocks: [{ type: 'text' }] })
diff --git a/src/shared/agent-session-journal-body-admission.ts b/src/shared/agent-session-journal-body-admission.ts
index 2796ae605099..a5462cfb87c8 100644
--- a/src/shared/agent-session-journal-body-admission.ts
+++ b/src/shared/agent-session-journal-body-admission.ts
@@ -8,6 +8,7 @@ import {
isAdmissibleAgentJournalItemBody,
isAdmissibleAgentJournalMessageBody
} 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`. */
export type AgentJournalContentVerdict = 'readable' | 'unreadable' | 'malformed'
@@ -24,7 +25,5 @@ export function readAgentJournalMessageBody(body: unknown): AgentJournalContentV
return verdict
}
const kind = typeof body === 'object' && body !== null && 'kind' in body ? body.kind : undefined
- return typeof kind === 'string' && kind !== '' && !AGENT_JOURNAL_ITEM_BODY_KINDS.has(kind)
- ? 'unreadable'
- : 'malformed'
+ return isJournalTag(kind) && !AGENT_JOURNAL_ITEM_BODY_KINDS.has(kind) ? 'unreadable' : 'malformed'
}
diff --git a/src/shared/agent-session-journal-open-union.ts b/src/shared/agent-session-journal-open-union.ts
index 7d3d07abc9f8..60500173838a 100644
--- a/src/shared/agent-session-journal-open-union.ts
+++ b/src/shared/agent-session-journal-open-union.ts
@@ -3,6 +3,11 @@
import { z } from 'zod'
+/** A tag a newer build could have written: a string with something other than whitespace in it. */
+export function isJournalTag(value: unknown): value is string {
+ return typeof value === 'string' && value.trim() !== ''
+}
+
/** Tags a discriminated union knows, read off it so the two can never disagree. */
export function knownTags(union: z.ZodDiscriminatedUnion): ReadonlySet {
const key = union.def.discriminator
@@ -15,13 +20,13 @@ export function knownTags(union: z.ZodDiscriminatedUnion): ReadonlySet {
}
/** A discriminated union a tag this build does not know still reads through: kept as-is, for a
- * reader to skip. The catch-all aborts, so a known arm's own failure still reaches the reader
- * (agent-session-journal-body-admission.ts). */
+ * reader to skip. A tag of only whitespace is damage, as an empty one is. The catch-all aborts, so
+ * a known arm's own failure still reaches the reader. */
export function openDiscriminatedUnion(known: T) {
const key = known.def.discriminator
const tags = knownTags(known)
const unknown = z
- .object({ [key]: z.string().min(1) })
+ .object({ [key]: z.string().regex(/\S/) })
.refine((value) => !tags.has(value[key] ?? ''), { abort: true })
return z.union([known, unknown])
}
diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts
index e5f8125a5c0f..0f0a9f751bd8 100644
--- a/src/shared/agent-session-journal-types.ts
+++ b/src/shared/agent-session-journal-types.ts
@@ -167,12 +167,25 @@ export type AgentJournalApprovalMatchedAskRule = {
ruleContent?: string
}
-export type AgentJournalApprovalSubject = {
+export type AgentJournalPlanApprovalSubject = {
kind: 'plan'
text: string
filePath?: string
}
+declare const agentJournalUnknownKind: unique symbol
+/** A kind tag this build does not know. Branded, so it never stands in for a known tag. */
+export type AgentJournalUnknownKind = string & { readonly [agentJournalUnknownKind]: true }
+
+/** A subject of a kind a newer Orca wrote: carried as it was, with whatever fields it holds, and
+ * never drawn or approved here. */
+export type AgentJournalUnknownApprovalSubject = { readonly kind: AgentJournalUnknownKind }
+
+/** Open, as the journal schema reads it: narrow with `isPlanApprovalSubject` before reading it. */
+export type AgentJournalApprovalSubject =
+ | AgentJournalPlanApprovalSubject
+ | AgentJournalUnknownApprovalSubject
+
export type AgentJournalApprovalItem = {
kind: 'approval'
title: string
From 0670e4b6d6ad3996364b942b41c9d9158d9b0eff Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Fri, 2 Oct 2026 16:30:50 -0700
Subject: [PATCH 38/52] fix(native-chat): a card's cancel sends nothing without
a turn again, and rewind keeps a newer row after any held row
The host's cancel request has to name a turn on every version, so the turnless
prompt cancel is reverted on desktop and phone. The rewind merge moves its anchor
for every row the provider still holds, so a newer Orca's row stays after one
whose kind this build does not know.
---
.../mobile-structured-agent-session-cancel.ts | 12 +++---
...uctured-agent-session-host-support.test.ts | 5 ---
...e-structured-agent-session-host-support.ts | 4 --
...structured-operation-id-retirement.test.ts | 1 -
...tured-agent-session-prompt-cancel.test.tsx | 33 +--------------
...tured-agent-session-queued.test-fixture.ts | 1 -
.../use-mobile-structured-agent-session.ts | 3 --
...ructured-rewind-retained-host-rows.test.ts | 24 +++++++++++
.../structured-rewind-retained-host-rows.ts | 13 +++---
...tStructuredSession.newer-approval.test.tsx | 8 ++--
.../NativeChatStructuredSession.tsx | 2 +-
...tured-agent-session-prompt-cancel.test.tsx | 41 -------------------
.../use-structured-agent-session.ts | 8 +---
13 files changed, 43 insertions(+), 112 deletions(-)
diff --git a/mobile/src/session/mobile-structured-agent-session-cancel.ts b/mobile/src/session/mobile-structured-agent-session-cancel.ts
index 5a63ffd1f83f..3a3286510529 100644
--- a/mobile/src/session/mobile-structured-agent-session-cancel.ts
+++ b/mobile/src/session/mobile-structured-agent-session-cancel.ts
@@ -27,8 +27,6 @@ export async function requestMobileStructuredAgentSessionCancel(args: {
enabled: boolean
stateRef: { readonly current: StructuredAgentSessionState }
promptCancelSupported: boolean | null
- /** Whether the host takes a cancel that names no turn. */
- conversationStopSupported: boolean | null
prompt?: PromptIdentity
/** Whether the host answers a repeated Stop of a turn quietly; null until the status probe answers. */
hostAnswersRepeatedStops: boolean | null
@@ -40,15 +38,15 @@ export async function requestMobileStructuredAgentSessionCancel(args: {
const { client, enabled, inFlight, onSendError, sessionId, stateRef } = args
const current = stateRef.current
const turnId = activeStructuredAgentSessionTurnId(current.items)
- const prompt = args.prompt && args.promptCancelSupported === true ? args.prompt : undefined
- // A card that outlived its turn: the host declines it without a turn to name.
- const turnless = !turnId && prompt !== undefined && args.conversationStopSupported === true
- if (!client || !sessionId || !enabled || current.fence === null || (!turnId && !turnless)) {
+ if (!client || !sessionId || !enabled || current.fence === null || !turnId) {
onSendError('Stop not sent')
return false
}
// Check the capability before fields enter the fingerprint.
- const fields = { ...(turnId ? { turnId } : {}), ...(prompt ? { prompt } : {}) }
+ const fields = {
+ turnId,
+ ...(args.prompt && args.promptCancelSupported === true ? { prompt: args.prompt } : {})
+ }
// Every press is its own Stop: a kept id would be answered from the last one and stop nothing.
const fence = current.fence
const stop = () => sendStop({ client, sessionId, fence, fields, onSendError })
diff --git a/mobile/src/session/mobile-structured-agent-session-host-support.test.ts b/mobile/src/session/mobile-structured-agent-session-host-support.test.ts
index b34a47af05a5..0902f318ae3b 100644
--- a/mobile/src/session/mobile-structured-agent-session-host-support.test.ts
+++ b/mobile/src/session/mobile-structured-agent-session-host-support.test.ts
@@ -10,7 +10,6 @@ import { structuredAgentSessionHostSupport } from './mobile-structured-agent-ses
describe('structuredAgentSessionHostSupport', () => {
it('reads each structured-session feature from the host capability list', () => {
expect(structuredAgentSessionHostSupport([])).toEqual({
- conversationStop: false,
promptCancel: false,
questionAnswers: false,
queuedMessages: false,
@@ -19,7 +18,6 @@ describe('structuredAgentSessionHostSupport', () => {
expect(
structuredAgentSessionHostSupport([AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY])
).toEqual({
- conversationStop: false,
promptCancel: false,
questionAnswers: true,
queuedMessages: false,
@@ -28,7 +26,6 @@ describe('structuredAgentSessionHostSupport', () => {
expect(
structuredAgentSessionHostSupport([AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY])
).toEqual({
- conversationStop: false,
promptCancel: true,
questionAnswers: false,
queuedMessages: false,
@@ -37,7 +34,6 @@ describe('structuredAgentSessionHostSupport', () => {
expect(
structuredAgentSessionHostSupport([AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY])
).toEqual({
- conversationStop: false,
promptCancel: false,
questionAnswers: false,
queuedMessages: true,
@@ -46,7 +42,6 @@ describe('structuredAgentSessionHostSupport', () => {
expect(
structuredAgentSessionHostSupport([AGENT_SESSION_REPEATED_STOP_RUNTIME_CAPABILITY])
).toEqual({
- conversationStop: false,
promptCancel: false,
questionAnswers: false,
queuedMessages: false,
diff --git a/mobile/src/session/mobile-structured-agent-session-host-support.ts b/mobile/src/session/mobile-structured-agent-session-host-support.ts
index e2647782eca3..9ae1d1e90b1a 100644
--- a/mobile/src/session/mobile-structured-agent-session-host-support.ts
+++ b/mobile/src/session/mobile-structured-agent-session-host-support.ts
@@ -1,5 +1,4 @@
import {
- AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY,
AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY,
AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY,
AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY,
@@ -8,8 +7,6 @@ import {
/** Structured-session features the connected host advertised; null until the status probe answers. */
export type StructuredAgentSessionHostSupport = {
- /** A cancel may name no turn, as for a card that outlived its turn. */
- conversationStop: boolean
promptCancel: boolean
questionAnswers: boolean
/** Mid-turn sends queue as host-held drafts; an older host keeps today's immediate path. */
@@ -22,7 +19,6 @@ export function structuredAgentSessionHostSupport(
capabilities: readonly string[]
): StructuredAgentSessionHostSupport {
return {
- conversationStop: capabilities.includes(AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY),
promptCancel: capabilities.includes(AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY),
questionAnswers: capabilities.includes(AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY),
queuedMessages: capabilities.includes(AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY),
diff --git a/mobile/src/session/mobile-structured-operation-id-retirement.test.ts b/mobile/src/session/mobile-structured-operation-id-retirement.test.ts
index 19c3e23b9f79..6ca270745684 100644
--- a/mobile/src/session/mobile-structured-operation-id-retirement.test.ts
+++ b/mobile/src/session/mobile-structured-operation-id-retirement.test.ts
@@ -56,7 +56,6 @@ function cancelArgs(client: RpcClient, hostAnswersRepeatedStops: boolean | null
enabled: true,
stateRef: { current: runningState() },
promptCancelSupported: null,
- conversationStopSupported: null,
hostAnswersRepeatedStops,
inFlight: new Map>(),
onSendError: vi.fn()
diff --git a/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx
index 5c82ba44d88c..0bb7a8f74285 100644
--- a/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx
+++ b/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx
@@ -109,12 +109,10 @@ const client: RpcClient = {
function Harness({
promptCancelSupported,
- questionAnswersSupported = false,
- conversationStopSupported = false
+ questionAnswersSupported = false
}: {
promptCancelSupported: boolean
questionAnswersSupported?: boolean
- conversationStopSupported?: boolean
}): null {
hook = useMobileStructuredAgentSession({
client,
@@ -124,7 +122,6 @@ function Harness({
connected: true,
agent: 'codex',
hostSupport: {
- conversationStop: conversationStopSupported,
promptCancel: promptCancelSupported,
questionAnswers: questionAnswersSupported,
queuedMessages: false,
@@ -209,34 +206,6 @@ describe('mobile structured prompt cancellation', () => {
expect(call?.[1]).not.toHaveProperty('prompt')
})
- it('cancels a card that outlived its turn on a host that takes a cancel naming no turn', async () => {
- state = { ...state, items: [pendingApproval()] }
- act(() => {
- renderer = create(
- createElement(Harness, { promptCancelSupported: true, conversationStopSupported: true })
- )
- })
- await act(async () => {
- expect(await hook.cancelPrompt()).toBe(true)
- })
- const call = mocks.sendRequest.mock.calls.find(([method]) => method === 'agentSession.cancel')
- expect(call?.[1]).toMatchObject({ prompt: { itemId: 'approval-1', expectedRevision: 4 } })
- expect(call?.[1]).not.toHaveProperty('turnId')
- })
-
- it('sends nothing for a card with no turn to an older host', async () => {
- state = { ...state, items: [pendingApproval()] }
- act(() => {
- renderer = create(createElement(Harness, { promptCancelSupported: true }))
- })
- await act(async () => {
- expect(await hook.cancelPrompt()).toBe(false)
- })
- expect(mocks.sendRequest.mock.calls.some(([method]) => method === 'agentSession.cancel')).toBe(
- false
- )
- })
-
it('cancels a question card with its item identity', async () => {
state = { ...state, items: [runningTurn(), pendingQuestion()] }
act(() => {
diff --git a/mobile/src/session/use-mobile-structured-agent-session-queued.test-fixture.ts b/mobile/src/session/use-mobile-structured-agent-session-queued.test-fixture.ts
index fc374e28e29b..bbfe297ef7f3 100644
--- a/mobile/src/session/use-mobile-structured-agent-session-queued.test-fixture.ts
+++ b/mobile/src/session/use-mobile-structured-agent-session-queued.test-fixture.ts
@@ -13,7 +13,6 @@ import type { StructuredAgentSessionHostSupport } from './mobile-structured-agen
export const SESSION_ID = 'session-1'
export const CAPABLE: StructuredAgentSessionHostSupport = {
- conversationStop: false,
promptCancel: false,
questionAnswers: false,
queuedMessages: true,
diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts
index 4dd91bb6ff4f..fd7632018df7 100644
--- a/mobile/src/session/use-mobile-structured-agent-session.ts
+++ b/mobile/src/session/use-mobile-structured-agent-session.ts
@@ -98,7 +98,6 @@ export function useMobileStructuredAgentSession(args: {
// Old host ⇒ exactly today's behavior: no delivery field, no cards, plain Stop.
const queueCapable = hostSupport?.queuedMessages === true
const promptCancelSupported = hostSupport?.promptCancel ?? null
- const conversationStopSupported = hostSupport?.conversationStop ?? null
const hostAnswersRepeatedStops = hostSupport?.quietRepeatedStop ?? null
const sessionKey = encodeNativeChatTranscriptIdentity([sourceIdentity, agent, sessionId])
const commandPendingRef = useRef(false)
@@ -205,7 +204,6 @@ export function useMobileStructuredAgentSession(args: {
onSendError,
prompt,
promptCancelSupported,
- conversationStopSupported,
sessionId,
stateRef
}),
@@ -215,7 +213,6 @@ export function useMobileStructuredAgentSession(args: {
hostAnswersRepeatedStops,
onSendError,
promptCancelSupported,
- conversationStopSupported,
sessionId,
stateRef
]
diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.test.ts b/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.test.ts
index 5b47be7f9f87..43a2beee24c1 100644
--- a/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.test.ts
+++ b/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.test.ts
@@ -96,6 +96,30 @@ describe('rewind keeps each retained row attributed', () => {
expect(merged.at(-1)?.turnScope).toEqual({ kind: 'turn', turnItemId: commandTurn })
})
+ it("keeps a newer Orca's row after a row the provider still holds, whatever that row's kind", () => {
+ // A kind this build does not know, which the provider now returns in its own words.
+ const future: AgentJournalItemBody = JSON.parse('{"kind":"future-item"}')
+ const newer: AgentJournalItemBody = JSON.parse('{"kind":"plan-card","n":4}')
+ const merged = mergeRetainedHostLifecycleRows(
+ [
+ retained(codexKey('turn-a', 2), prose('ok')),
+ retained(codexKey('turn-a', 9), future),
+ retained(orcaKey('u4'), newer)
+ ],
+ [
+ providerItem(codexKey('turn-a', 2), prose('ok')),
+ providerItem(codexKey('turn-a', 9), prose('provider now renders it'))
+ ]
+ )
+
+ expect(merged.map((row) => row.itemId)).toEqual([
+ codexKey('turn-a', 2),
+ codexKey('turn-a', 9),
+ orcaKey('u4')
+ ])
+ expect(merged[2]?.body).toEqual(newer)
+ })
+
it('places a provider item the old epoch never held in the turn record for its provider turn', () => {
const commandTurn = orcaKey('command-turn:cmd-1')
const reference = [
diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.ts b/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.ts
index 2c80d9150afb..f3e725787097 100644
--- a/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.ts
+++ b/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.ts
@@ -40,11 +40,14 @@ export function mergeRetainedHostLifecycleRows(
const rowsAfter = new Map()
let anchor = -1
for (const item of reference) {
- const carried =
- isRetainedHostLifecycleRow(item) || !AGENT_JOURNAL_ITEM_BODY_KINDS.has(item.body.kind)
- if (!carried) {
- anchor = spineIndex.get(item.itemId) ?? anchor
- } else if (!spineIndex.has(item.itemId)) {
+ // Any row the provider still holds moves the anchor, carried or not, so a later row follows it.
+ const spine = spineIndex.get(item.itemId)
+ if (spine !== undefined) {
+ anchor = spine
+ } else if (
+ isRetainedHostLifecycleRow(item) ||
+ !AGENT_JOURNAL_ITEM_BODY_KINDS.has(item.body.kind)
+ ) {
rowsAfter.set(anchor, [...(rowsAfter.get(anchor) ?? []), item])
}
}
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 366dd12279b4..eab8215290f4 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
@@ -72,7 +72,8 @@ it("hands the card a newer Orca's subject in a writable chat, and its cancel goe
})
})
-it('sends the card to the host even when no turn is running, so its cancel still answers it', () => {
+// The host's cancel names its turn, so with none running nothing is sent.
+it('sends nothing for the card when no turn is running', () => {
mocks.promptItems = [NEWER_APPROVAL]
render(
)
mocks.approvalCardProps?.onCancel?.()
- expect(mocks.cancel).toHaveBeenCalledWith(null, {
- itemId: 'approval-item',
- expectedRevision: 3
- })
+ expect(mocks.cancel).not.toHaveBeenCalled()
})
diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx
index 9edcb6df9832..3cab06aece3d 100644
--- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx
@@ -154,7 +154,7 @@ export function NativeChatStructuredSession(
}
: null
const cancelPrompt = () => {
- if (prompt) {
+ if (controller.turnId && prompt) {
void controller.cancel(controller.turnId, {
itemId: prompt.itemId,
expectedRevision: prompt.revision
diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-prompt-cancel.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-prompt-cancel.test.tsx
index 5972f49fa471..8e1259ad0d94 100644
--- a/src/renderer/src/components/native-chat/use-structured-agent-session-prompt-cancel.test.tsx
+++ b/src/renderer/src/components/native-chat/use-structured-agent-session-prompt-cancel.test.tsx
@@ -2,20 +2,13 @@
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
-import type * as HostCapability from '@/runtime/structured-agent-session-host-capability'
const mocks = vi.hoisted(() => ({
call: vi.fn(),
promptCancelSupported: vi.fn(),
- stopsConversation: false,
operationId: vi.fn(() => 'operation-1')
}))
-vi.mock('@/runtime/structured-agent-session-host-capability', async (importOriginal) => ({
- ...(await importOriginal()),
- useStructuredAgentSessionHostStopsConversation: () => mocks.stopsConversation
-}))
-
vi.mock('@/runtime/structured-agent-session-client', () => ({
callStructuredAgentSession: mocks.call,
supportsStructuredAgentSessionQuietRepeatedStop: vi.fn(async () => false),
@@ -90,7 +83,6 @@ function runningTurn(): AgentJournalRenderItem {
describe('desktop structured prompt cancellation', () => {
beforeEach(() => {
vi.clearAllMocks()
- mocks.stopsConversation = false
items = [runningTurn(), pendingApproval()]
mocks.promptCancelSupported.mockResolvedValue(false)
mocks.call.mockResolvedValue({ ok: true, value: { turnId: 'turn-1', cancelled: true } })
@@ -128,39 +120,6 @@ describe('desktop structured prompt cancellation', () => {
expect(call?.[2]).not.toHaveProperty('prompt')
})
- it('cancels a card that outlived its turn on a host that takes a cancel naming no turn', async () => {
- items = [pendingApproval()]
- mocks.promptCancelSupported.mockResolvedValue(true)
- mocks.stopsConversation = true
- const { result } = renderHook(() =>
- useStructuredAgentSession({ sessionId: 'session-1', target, agent: 'codex', isVisible: true })
- )
-
- await act(async () => {
- await result.current.cancel(null, { itemId: 'approval-1', expectedRevision: 2 })
- })
-
- const call = mocks.call.mock.calls.find(([, method]) => method === 'agentSession.cancel')
- expect(call?.[2]).toMatchObject({ prompt: { itemId: 'approval-1', expectedRevision: 2 } })
- expect(call?.[2]).not.toHaveProperty('turnId')
- })
-
- it('sends nothing for a card with no turn to a host that needs one', async () => {
- items = [pendingApproval()]
- mocks.promptCancelSupported.mockResolvedValue(true)
- const { result } = renderHook(() =>
- useStructuredAgentSession({ sessionId: 'session-1', target, agent: 'codex', isVisible: true })
- )
-
- await act(async () => {
- expect(
- await result.current.cancel(null, { itemId: 'approval-1', expectedRevision: 2 })
- ).toBeNull()
- })
-
- expect(mocks.call.mock.calls.some(([, method]) => method === 'agentSession.cancel')).toBe(false)
- })
-
it('keeps ordinary composer stop turn-only without a capability probe', async () => {
const { result } = renderHook(() =>
useStructuredAgentSession({ sessionId: 'session-1', target, agent: 'codex', isVisible: true })
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 8e80392e1959..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
@@ -247,17 +247,11 @@ export function useStructuredAgentSession(args: {
: Promise.resolve(null)
},
queuedMessages: queuedController,
- cancel: async (turnId: string | null, prompt?: StructuredPromptCancelTarget) => {
+ cancel: async (turnId: string, prompt?: StructuredPromptCancelTarget) => {
// Capability negotiation must complete before mutate fingerprints the payload:
// older hosts reject the strict prompt field.
const promptSupported =
prompt !== undefined && (await supportsStructuredAgentSessionPromptCancel(target))
- // A card that outlived its turn: the host declines it without a turn to name.
- if (turnId === null) {
- return promptSupported && stopsConversation
- ? mutate('agentSession.cancel', 'agentSession.cancel', { prompt })
- : null
- }
return mutate('agentSession.cancel', 'agentSession.cancel', {
turnId,
...(promptSupported ? { prompt } : {})
From 6bfb9e89e100abeb5f1a70c32f4d5e0ea1fedb26 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Fri, 2 Oct 2026 17:49:27 -0700
Subject: [PATCH 39/52] fix(native-chat): a card this build cannot answer
leaves the composer open, and a send starts a turn
An approval of a subject kind this build cannot draw, raised by a newer host's background agent
while no turn runs, left nothing to press: its answers are disabled, its cancel needs a turn, and
the desktop composer gave the card its slot. A send the host queues waits behind any pending
prompt, so even the phone's open composer only queued.
While every pending prompt is one this build cannot answer, the desktop composer stays open, and
desktop and phone send without asking to queue: the send starts a turn, and the card's cancel then
settles it. A chat a dead run left is unaffected: opening it already cancels those prompts.
---
...agent-session-newer-approval-send.test.tsx | 129 ++++++++++++++++++
...use-mobile-structured-send-with-outcome.ts | 9 +-
...d-agent-session-newer-content-host.test.ts | 81 +++++++++--
...tStructuredSession.newer-approval.test.tsx | 35 +++++
.../NativeChatStructuredSession.tsx | 10 +-
...tured-agent-session.queued-gating.test.tsx | 40 ++++++
.../use-structured-agent-session.ts | 20 +--
.../agent-session-approval-subject.test.ts | 43 +++++-
src/shared/agent-session-approval-subject.ts | 22 +++
9 files changed, 363 insertions(+), 26 deletions(-)
create mode 100644 mobile/src/session/use-mobile-structured-agent-session-newer-approval-send.test.tsx
diff --git a/mobile/src/session/use-mobile-structured-agent-session-newer-approval-send.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-newer-approval-send.test.tsx
new file mode 100644
index 000000000000..594a84acf5fe
--- /dev/null
+++ b/mobile/src/session/use-mobile-structured-agent-session-newer-approval-send.test.tsx
@@ -0,0 +1,129 @@
+// A host's queue waits on any pending prompt. While every pending prompt is an approval this build
+// cannot answer, nothing here can settle it, so a send goes out without `delivery` and starts a
+// turn, whose card cancel then works.
+
+import { createElement } from 'react'
+import { act, create, type ReactTestRenderer } from 'react-test-renderer'
+import { afterEach, beforeEach, expect, it, vi } from 'vitest'
+import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types'
+import type { RpcClient } from '../transport/rpc-client'
+import { resetMobileStructuredSendOperationJournalForTests } from './mobile-structured-send-operation-journal'
+import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session'
+import {
+ CAPABLE,
+ SESSION_ID,
+ fieldsOf,
+ mutationOk,
+ ok,
+ snapshotEvent
+} from './use-mobile-structured-agent-session-queued.test-fixture'
+
+const asyncStorage = vi.hoisted(() => ({
+ getItem: vi.fn(async () => null),
+ setItem: vi.fn(async () => undefined),
+ removeItem: vi.fn(async () => undefined)
+}))
+
+vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage }))
+
+let renderer: ReactTestRenderer | null = null
+let hook: ReturnType | null = null
+let listener: ((value: unknown) => void) | null = null
+const sendRequest = vi.fn()
+const client: RpcClient = {
+ sendRequest,
+ subscribe: vi.fn((_method, _params, onData) => {
+ listener = onData
+ return vi.fn()
+ }),
+ updateTerminalSubscriptionViewport: () => {},
+ getState: () => 'connected',
+ getReconnectAttempt: () => 0,
+ getLastConnectedAt: () => null,
+ onStateChange: () => () => {},
+ notifyForeground: () => {},
+ close: () => {}
+}
+
+function Harness(): null {
+ hook = useMobileStructuredAgentSession({
+ client,
+ sessionId: SESSION_ID,
+ sourceIdentity: 'host-a\0workspace-a',
+ enabled: true,
+ connected: true,
+ agent: 'claude',
+ hostSupport: CAPABLE,
+ appendComposerText: () => true,
+ onSendError: vi.fn()
+ })
+ return null
+}
+
+function approval(subject: Record): AgentJournalRenderItem {
+ return JSON.parse(
+ JSON.stringify({
+ itemId: `approval-${String(subject.kind)}`,
+ revision: 1,
+ sequence: 1,
+ observedAt: 1,
+ body: {
+ kind: 'approval',
+ title: 'Review',
+ detail: null,
+ subject,
+ options: [{ id: 'allow', label: 'Approve' }],
+ resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null }
+ }
+ })
+ )
+}
+
+async function sentDelivery(items: AgentJournalRenderItem[]): Promise {
+ act(() => {
+ renderer = create(createElement(Harness))
+ })
+ await vi.waitFor(() => expect(listener).toEqual(expect.any(Function)))
+ const event = snapshotEvent()
+ if (event.type === 'snapshot') {
+ event.page.items = items
+ }
+ act(() => listener?.(event))
+ await act(async () => {
+ await hook!.sendWithOutcome('carry on')
+ })
+ const send = sendRequest.mock.calls.find(([method]) => method === 'agentSession.send')
+ return fieldsOf(send?.[1]).delivery
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ resetMobileStructuredSendOperationJournalForTests()
+ sendRequest.mockImplementation(async (method) =>
+ method === 'agentSession.send'
+ ? mutationOk({ clientMessageId: 'client-1' })
+ : method === 'agentSession.options'
+ ? ok({ models: [], current: {} })
+ : ok({})
+ )
+})
+
+afterEach(() => {
+ act(() => renderer?.unmount())
+ renderer = null
+ hook = null
+ listener = null
+})
+
+it('sends without queueing while every pending prompt is one this build cannot answer', async () => {
+ expect(await sentDelivery([approval({ kind: 'diff', path: 'a.ts' })])).toBeUndefined()
+})
+
+it('still queues beside a prompt this build can answer', async () => {
+ expect(
+ await sentDelivery([
+ approval({ kind: 'diff', path: 'a.ts' }),
+ approval({ kind: 'plan', text: 'do it' })
+ ])
+ ).toBe('queue-if-active')
+})
diff --git a/mobile/src/session/use-mobile-structured-send-with-outcome.ts b/mobile/src/session/use-mobile-structured-send-with-outcome.ts
index 44c94f777a21..613dd14fe084 100644
--- a/mobile/src/session/use-mobile-structured-send-with-outcome.ts
+++ b/mobile/src/session/use-mobile-structured-send-with-outcome.ts
@@ -1,10 +1,11 @@
// The structured composer's one send seam: a slash command dispatches as a
// conversation command, and everything else goes out as an
// `agentSession.send` — carrying `delivery: 'queue-if-active'` only when the
-// host advertises the queue.
+// host advertises the queue and no pending prompt is one this build cannot answer.
import { useCallback } from 'react'
import { activeStructuredAgentSessionTurnId } from '../../../src/shared/structured-agent-session-live-turn'
+import { pendingPromptsAllUnanswerableHere } from '../../../src/shared/agent-session-approval-subject'
import {
structuredAgentSessionSendBody,
type StructuredAgentSessionAttachment
@@ -116,7 +117,11 @@ export function useMobileStructuredSendWithOutcome(args: {
expectedRuntimeFence: currentFence,
text,
attachments: sendAttachments,
- ...(queueCapable ? { delivery: 'queue-if-active' as const } : {}),
+ // The host's queue waits on any pending prompt; one this build cannot answer would hold
+ // the send forever, so it starts a turn, whose card cancel then works.
+ ...(queueCapable && !pendingPromptsAllUnanswerableHere(stateRef.current.items)
+ ? { delivery: 'queue-if-active' as const }
+ : {}),
deadline,
onError: onSendError
})
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-newer-content-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-newer-content-host.test.ts
index eaccdf968343..f3ce31243dad 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-newer-content-host.test.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-newer-content-host.test.ts
@@ -92,20 +92,22 @@ async function relaunch(): Promise {
return host
}
+// A subject kind this build does not know.
+const NEWER_APPROVAL: AgentJournalItemBody = JSON.parse(
+ JSON.stringify({
+ kind: 'approval',
+ title: 'Review proposed change',
+ detail: null,
+ subject: { kind: 'diff', path: 'a.ts', text: 'not a plan' },
+ options: [{ id: 'allow', label: 'Approve' }],
+ resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null }
+ })
+)
+
it("settles a newer Orca's pending approval as it was, and the chat takes its next message", async () => {
await attach()
- // A subject kind this build does not know, still pending when its provider went away.
- const newerApproval: AgentJournalItemBody = JSON.parse(
- JSON.stringify({
- kind: 'approval',
- title: 'Review proposed change',
- detail: null,
- subject: { kind: 'diff', path: 'a.ts', text: 'not a plan' },
- options: [{ id: 'allow', label: 'Approve' }],
- resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null }
- })
- )
- emit(7, newerApproval)
+ // Still pending when its provider went away.
+ emit(7, NEWER_APPROVAL)
const host = await relaunch()
expect(await send(host, 'after the newer approval')).toMatchObject({ ok: true })
@@ -119,6 +121,61 @@ it("settles a newer Orca's pending approval as it was, and the chat takes its ne
await host.flushAllStreamedEvents()
})
+// A newer host's background agent can hold such an approval while no turn runs. A queued send would
+// wait behind it forever; the plain send the client makes instead starts a turn, whose card cancel
+// then settles it.
+it("takes a send past a newer Orca's live approval, and the next turn's card cancel settles it", async () => {
+ // A provider whose card Cancel goes through the Stop: a card no live turn raised is declined.
+ const dismissPrompt = vi.fn(async ({ commit }: { commit: () => Promise }) => commit())
+ Object.assign(hostTestState().host.deps.adapter, {
+ routePromptCancel: () => ({ kind: 'stop' }),
+ dismissPrompt
+ })
+ await attach()
+ emit(7, NEWER_APPROVAL)
+ const { host } = hostTestState()
+ await host.flushStreamedEvents(SESSION)
+
+ const body = hostTestMessage('queued behind it')
+ const queuedFields = { body, delivery: 'queue-if-active' as const }
+ expect(
+ await host.send(CALLER, {
+ envelope: envelope('agentSession.send', queuedFields),
+ ...queuedFields,
+ userSend: true
+ })
+ ).toMatchObject({ ok: true, value: { queued: { state: 'waiting' } } })
+ expect(hostTestState().dispatch).not.toHaveBeenCalled()
+
+ const sent = await send(host, 'carry on')
+ expect(sent).toMatchObject({ ok: true, value: { submission: expect.any(Object) } })
+ await vi.waitFor(() => expect(hostTestState().dispatch).toHaveBeenCalledOnce())
+ providerEvents().appendItem(
+ { provider: 'codex', threadId: THREAD, turnId: 'turn-2', ordinal: 8 },
+ { kind: 'turn', turnId: 'turn-2', state: 'running', startedAt: NOW },
+ { turnScope: AGENT_JOURNAL_THREAD_SCOPE }
+ )
+ await host.flushStreamedEvents(SESSION)
+
+ const card = (await host.journalSnapshot(SESSION)).items.find(
+ (item) => item.body.kind === 'approval'
+ )!
+ const fields = {
+ turnId: 'turn-2',
+ prompt: { itemId: card.itemId, expectedRevision: card.revision }
+ }
+ expect(
+ await host.cancel(CALLER, { envelope: envelope('agentSession.cancel', fields), ...fields })
+ ).toMatchObject({ ok: true })
+ expect(dismissPrompt).toHaveBeenCalledWith(expect.objectContaining({ answer: true }))
+ expect(hostTestState().cancelTurn).not.toHaveBeenCalled()
+ expect((await historyBodies(host)).find((entry) => entry.kind === 'approval')).toMatchObject({
+ subject: { kind: 'diff' },
+ resolution: { state: 'cancelled' }
+ })
+ await host.flushAllStreamedEvents()
+})
+
it('ends a turn whose provider output would not read back as failed, and the next send works', async () => {
const closeSession = vi.fn(async () => true)
Object.assign(hostTestState().host.deps.adapter, { closeSession })
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 eab8215290f4..363c5c15e54a 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
@@ -88,3 +88,38 @@ it('sends nothing for the card when no turn is running', () => {
mocks.approvalCardProps?.onCancel?.()
expect(mocks.cancel).not.toHaveBeenCalled()
})
+
+function renderSession() {
+ render(
+
+ )
+}
+
+// Nothing here answers it, so a send is the way on: it starts a turn, whose card cancel works.
+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 })
+})
+
+it('gives a card this build can answer the composer slot', () => {
+ const plan: AgentJournalRenderItem = JSON.parse(
+ JSON.stringify(NEWER_APPROVAL).replace(
+ '{"kind":"diff","path":"a.ts"}',
+ '{"kind":"plan","text":"do it"}'
+ )
+ )
+ mocks.promptItems = [plan]
+ renderSession()
+ expect(mocks.composerProps).toBeNull()
+ expect(mocks.approvalCardProps).toMatchObject({ shouldFocus: true })
+})
diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx
index 3cab06aece3d..415b8026f597 100644
--- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx
@@ -29,6 +29,7 @@ import { structuredAgentLabel } from '@/lib/structured-agent-session-launch-labe
import { NativeChatThreadGoalBanner } from './NativeChatThreadGoalBanner'
import { structuredAgentSessionReadFailureNotice } from './structured-agent-session-read-failure-notice'
import { useStructuredAgentSessionDeliveryNotices } from './use-structured-agent-session-delivery-notices'
+import { pendingPromptsAllUnanswerableHere } from '../../../../shared/agent-session-approval-subject'
export function NativeChatStructuredSession(
props: Omit
@@ -134,8 +135,11 @@ export function NativeChatStructuredSession(
{ sessionId: props.sessionId, isVisible: props.isVisible }
)
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 || readOnly
+ // 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.
+ const promptsUnanswerable = pendingPromptsAllUnanswerableHere(controller.prompts)
+ const composerShown = prompt === null || readOnly || promptsUnanswerable
const approvalBody = prompt?.body.kind === 'approval' ? prompt.body : null
const approval = approvalBody
? {
@@ -325,7 +329,7 @@ export function NativeChatStructuredSession(
onChoose={(optionId) => void controller.respond(prompt, { kind: 'option', optionId })}
onCancel={cancelPrompt}
disabled={readOnly}
- shouldFocus={!readOnly && props.isVisible && props.isFocusedGroup}
+ shouldFocus={!readOnly && !promptsUnanswerable && props.isVisible && props.isFocusedGroup}
onLinkClick={onLinkClick}
allowFileUriLinks={onLinkClick !== undefined}
/>
diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx
index ebfd23060bbf..bef56bf7f81e 100644
--- a/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx
+++ b/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx
@@ -158,6 +158,46 @@ describe('against a capable host', () => {
})
})
+ // The host's queue would hold a send behind a prompt nothing here can settle.
+ it('sends immediately while every pending prompt is one this build cannot answer', () => {
+ const approval = (subject: Record): AgentJournalRenderItem =>
+ JSON.parse(
+ JSON.stringify({
+ itemId: `approval-${String(subject.kind)}`,
+ revision: 1,
+ sequence: 2,
+ observedAt: 1,
+ body: {
+ kind: 'approval',
+ title: 'Review',
+ detail: null,
+ subject,
+ options: [{ id: 'allow', label: 'Approve' }],
+ resolution: {
+ state: 'pending',
+ selectedOptionId: null,
+ resolvedBy: null,
+ resolvedAt: null
+ }
+ }
+ })
+ )
+ const newer = approval({ kind: 'diff', path: 'a.ts' })
+ items = [newer]
+ render()
+ expect(mocks.outboxArgs.at(-1)?.queueDelivery).toEqual({
+ capability: 'supported',
+ enabled: false
+ })
+ mocks.outboxArgs.length = 0
+ items = [newer, approval({ kind: 'plan', text: 'do it' })]
+ render()
+ expect(mocks.outboxArgs.at(-1)?.queueDelivery).toEqual({
+ capability: 'supported',
+ enabled: true
+ })
+ })
+
it('Stop is a plain cancel: drafts stay as cards and no text lands in the composer', async () => {
queuedMessages = [{ ...draft('draft-1'), paused: true }]
const { result } = render(false)
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 87b4a9428c2a..87731a9939df 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
@@ -38,6 +38,7 @@ import { useStructuredAgentSessionQueuedMessages } from './use-structured-agent-
import { outboxOutsideQueuedCards } from './structured-agent-session-queued-cards'
import { structuredAgentSessionStartFailureFacts } from './structured-agent-session-delivery-notices'
import { hostStatesTurnScopes } from '../../../../shared/native-chat-turn-membership'
+import { pendingPromptsAllUnanswerableHere } from '../../../../shared/agent-session-approval-subject'
export type { StructuredPromptItem } from './structured-agent-session-message-projection'
@@ -115,13 +116,21 @@ export function useStructuredAgentSession(args: {
() => (transportState.queuedMessages ?? []).map((message) => message.messageId),
[transportState.queuedMessages]
)
+ const prompts = pendingStructuredSessionPrompts(transportState.journalItems)
+ // A host's queue waits on any pending prompt, and nothing here can settle one this build cannot
+ // answer: the send must start a turn, after which the card's cancel works.
+ const queueEnabled = queueFollowUps && !pendingPromptsAllUnanswerableHere(prompts)
+ const queueDelivery = useMemo(
+ () => ({ capability: queueCapability, enabled: queueEnabled }),
+ [queueCapability, queueEnabled]
+ )
const outboxController = useStructuredAgentSessionOutbox({
sessionId,
target,
fence: transportState.fence,
submissions: transportState.submissions,
composerScopeKey,
- queueDelivery: { capability: queueCapability, enabled: queueFollowUps },
+ queueDelivery,
queuedMessageIds
})
@@ -143,7 +152,6 @@ export function useStructuredAgentSession(args: {
enabled: providerVisible
})
- const prompts = pendingStructuredSessionPrompts(transportState.journalItems)
const { outbox } = outboxController
// A host that takes a Stop naming no turn gets Stop from the send until the work settles; every
// Stop before a turn opens needs that form. An older host can stop only a turn it has opened.
@@ -158,12 +166,8 @@ export function useStructuredAgentSession(args: {
// A queued send is a card, never a transcript bubble.
const isWorking = transportState.isWorking
const transcriptOutbox = useMemo(
- () =>
- outboxOutsideQueuedCards(outbox, queuedMessageIds, isWorking, {
- capability: queueCapability,
- enabled: queueFollowUps
- }),
- [isWorking, outbox, queueCapability, queueFollowUps, queuedMessageIds]
+ () => outboxOutsideQueuedCards(outbox, queuedMessageIds, isWorking, queueDelivery),
+ [isWorking, outbox, queueDelivery, queuedMessageIds]
)
const messages = useStructuredAgentSessionMessages(
transportState.journalItems,
diff --git a/src/shared/agent-session-approval-subject.test.ts b/src/shared/agent-session-approval-subject.test.ts
index c5020d0ece90..2ad176e6932b 100644
--- a/src/shared/agent-session-approval-subject.test.ts
+++ b/src/shared/agent-session-approval-subject.test.ts
@@ -1,8 +1,10 @@
import { expect, it } from 'vitest'
import {
DRAWN_APPROVAL_SUBJECT_KINDS,
- isNewerApprovalSubject
+ isNewerApprovalSubject,
+ pendingPromptsAllUnanswerableHere
} from './agent-session-approval-subject'
+import type { AgentJournalItemBody } from './agent-session-journal-types'
import { AGENT_JOURNAL_APPROVAL_SUBJECT_KINDS } from './agent-session-journal-schemas'
it('draws exactly the subject kinds the journal schema knows', () => {
@@ -16,3 +18,42 @@ it("reads only a subject of a kind it does not draw as a newer build's", () => {
expect(isNewerApprovalSubject({ kind: 'plan' })).toBe(false)
expect(isNewerApprovalSubject({ kind: 'diff' })).toBe(true)
})
+
+function pending(body: Record): { body: AgentJournalItemBody } {
+ return JSON.parse(
+ JSON.stringify({
+ body: {
+ ...body,
+ resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null }
+ }
+ })
+ )
+}
+const NEWER = pending({
+ kind: 'approval',
+ title: 'Review',
+ detail: null,
+ subject: { kind: 'diff' },
+ options: [{ id: 'allow', label: 'Approve' }]
+})
+const PLAN = pending({
+ kind: 'approval',
+ title: 'Plan',
+ detail: null,
+ subject: { kind: 'plan', text: 'do it' },
+ options: [{ id: 'allow', label: 'Approve' }]
+})
+const QUESTION = pending({ kind: 'question', title: 'Which?', options: [] })
+
+it('holds only while every pending prompt is an approval this build cannot answer', () => {
+ expect(pendingPromptsAllUnanswerableHere([])).toBe(false)
+ expect(pendingPromptsAllUnanswerableHere([NEWER])).toBe(true)
+ expect(pendingPromptsAllUnanswerableHere([NEWER, NEWER])).toBe(true)
+ expect(pendingPromptsAllUnanswerableHere([NEWER, PLAN])).toBe(false)
+ expect(pendingPromptsAllUnanswerableHere([QUESTION, NEWER])).toBe(false)
+ const cancelled = JSON.parse(
+ JSON.stringify(NEWER).replace('"state":"pending"', '"state":"cancelled"')
+ )
+ expect(pendingPromptsAllUnanswerableHere([cancelled, PLAN])).toBe(false)
+ expect(pendingPromptsAllUnanswerableHere([cancelled])).toBe(false)
+})
diff --git a/src/shared/agent-session-approval-subject.ts b/src/shared/agent-session-approval-subject.ts
index 4fdad6415a32..0c6354f83981 100644
--- a/src/shared/agent-session-approval-subject.ts
+++ b/src/shared/agent-session-approval-subject.ts
@@ -5,6 +5,7 @@
import type {
AgentJournalApprovalSubject,
+ AgentJournalItemBody,
AgentJournalPlanApprovalSubject
} from './agent-session-journal-types'
@@ -21,3 +22,24 @@ export function isPlanApprovalSubject(
export function isNewerApprovalSubject(subject: { kind: string } | undefined): boolean {
return subject !== undefined && !DRAWN_APPROVAL_SUBJECT_KINDS.has(subject.kind)
}
+
+/** Every pending prompt is an approval this build cannot answer. Nothing here can settle them, so
+ * the composer stays open and a send starts a turn now instead of queueing behind them; with a
+ * turn running, the card's cancel works. */
+export function pendingPromptsAllUnanswerableHere(
+ items: readonly { body: AgentJournalItemBody }[]
+): boolean {
+ let found = false
+ for (const { body } of items) {
+ if (
+ (body.kind === 'approval' || body.kind === 'question') &&
+ body.resolution.state === 'pending'
+ ) {
+ if (body.kind === 'question' || !isNewerApprovalSubject(body.subject)) {
+ return false
+ }
+ found = true
+ }
+ }
+ return found
+}
From f369bef34d08cbe225c7927051c01ea54bef373d Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Fri, 2 Oct 2026 18:40:41 -0700
Subject: [PATCH 40/52] feat(native-chat): a chat a newer Orca saved fails to
load and says to update, with nothing to send into
Opening a chat a newer Orca saved (a row kind, batch-change kind or sent-message kind this build does
not know, a newer row version, or a newer history store) used to open it read-only: the history
shown, the composer locked with a reason, every control greyed. Now it fails the load, like a
damaged chat, with its own words: "This chat was saved by a newer Orca. Update Orca to open it."
Every row is kept. The read is final, so the pane stops retrying; reopening the tab reads again.
A chat whose load failed for good (damaged, or a newer Orca's) offers no composer under the error,
on desktop and on the phone, so the failure is said once and no send can be refused a second time.
A restart offer for a newer Orca's chat is spent without a word: no failure filed, nothing counted.
Removed with the read-only mode: the journal's read-only latch and every check of it, the history
page's readOnly field (never released), the cursorless reconnect, read-only paging, the composer
lock and placeholder, the greyed controls, and the resume exclusion.
---
config/reliability-gates.jsonc | 8 +-
.../session/MobileNativeChatComposer.test.ts | 45 ----
.../src/session/MobileNativeChatComposer.tsx | 12 +-
.../src/session/MobileNativeChatOverlay.tsx | 7 +-
.../MobileNativeChatPermission.test.ts | 18 --
.../session/MobileNativeChatPermission.tsx | 10 +-
.../session/MobileNativeChatPromptCard.tsx | 7 +-
.../src/session/MobileNativeChatQuestion.tsx | 16 +-
.../MobileNativeChatQueuedMessages.test.tsx | 15 --
.../MobileNativeChatQueuedMessages.tsx | 9 +-
.../MobileNativeChatSessionOptionPickers.tsx | 11 +-
.../src/session/MobileNativeChatView.test.ts | 27 ++-
mobile/src/session/MobileNativeChatView.tsx | 85 ++++----
.../mobile-native-chat-read-only.test.tsx | 200 ------------------
.../mobile-structured-agent-session-rpc.ts | 9 +-
...tured-agent-session-thrown-refusal.test.ts | 13 ++
.../mobile-structured-read-only-notice.ts | 13 --
.../use-mobile-native-chat-queued-slot.ts | 17 +-
.../session/use-mobile-native-chat-session.ts | 5 +-
...ctured-agent-session-read-failure.test.tsx | 104 +++++++++
.../use-mobile-structured-agent-session.ts | 15 +-
.../use-mobile-structured-agent-state.ts | 21 +-
.../claude-background-wake-retention.test.ts | 1 -
.../journal-closed-sets.test.ts | 14 +-
.../agent-session-journal/journal-cursor.ts | 7 +-
.../journal-damage.test.ts | 14 +-
.../journal-epoch-controller.ts | 6 +-
.../journal-epoch-replacement.ts | 2 +-
.../journal-epoch-rollover.ts | 2 +-
.../journal-file-format-remnant.test.ts | 16 +-
.../journal-host-database-test-support.ts | 8 +
.../journal-open-failure.ts | 16 +-
.../agent-session-journal/journal-open.ts | 16 +-
.../journal-per-session-import.test.ts | 33 ++-
.../journal-per-session-import.ts | 10 +-
.../journal-stop-and-resume-rows.test.ts | 2 +-
.../journal-store-collaborators.ts | 2 -
.../journal-store-open.ts | 22 +-
.../journal-store-restore.ts | 47 +---
.../journal-store-schema.test.ts | 63 ++----
.../agent-session-journal/journal-store.ts | 17 +-
.../journal-streaming-replay.test.ts | 8 +-
.../journal-unknown-content.test.ts | 40 ++--
.../journal-unknown-row-kind.test.ts | 40 ++--
.../journal-write-guards.ts | 4 +-
...queued-message-bookkeeping-failure.test.ts | 1 -
.../queued-message-store.test.ts | 1 -
.../agent-session-history-page.ts | 19 +-
.../agent-session-read-only-page.test.ts | 188 ----------------
.../agent-session-subscriber-catch-up.ts | 2 +-
.../structured-agent-session-host-types.ts | 7 +-
...tructured-agent-session-queued-messages.ts | 6 +-
...ed-agent-session-queued-newer-orca.test.ts | 39 ++--
.../structured-agent-session-queued-wiring.ts | 2 +-
...ctured-agent-session-restart-candidates.ts | 8 +-
...ed-agent-session-restart-failure-ledger.ts | 27 +--
...t-session-restart-offer-newer-orca.test.ts | 122 +++++++++++
...nt-session-restart-offer-read-only.test.ts | 165 ---------------
...tured-agent-session-restart-resume-host.ts | 42 ++--
...ctured-agent-session-restart-resume-set.ts | 13 +-
...ent-session-restart-resume-test-harness.ts | 11 +-
...red-agent-session-restart-resume-wiring.ts | 9 +-
...tured-agent-session-restart-resume.test.ts | 34 ++-
.../structured-agent-session-reveal.ts | 10 +-
.../structured-agent-session-rewind.ts | 3 -
...ructured-agent-session-status-feed.test.ts | 8 +-
.../structured-agent-session-status-feed.ts | 6 +-
...agent-session-status-journal-projection.ts | 18 +-
...uctured-agent-session-unopened-tab.test.ts | 65 ++++--
...tured-agent-session-working-at-teardown.ts | 3 +-
.../structured-agent-session-reveal.ts | 4 +-
...ructured-agent-session-rpc.test-fixture.ts | 1 -
...ured-agent-session-failure-logging.test.ts | 2 +-
.../NativeChatApprovalCard.test.tsx | 26 ---
.../native-chat/NativeChatApprovalCard.tsx | 10 +-
.../native-chat/NativeChatComposer.test.tsx | 129 ++++++-----
.../native-chat/NativeChatComposer.tsx | 10 +-
.../native-chat/NativeChatComposerActions.tsx | 3 -
...tiveChatComposerField.placeholder.test.tsx | 94 --------
.../native-chat/NativeChatComposerField.tsx | 8 +-
.../native-chat/NativeChatPromptEditor.tsx | 15 +-
.../NativeChatQuestionCard.test.tsx | 60 ------
.../native-chat/NativeChatQuestionCard.tsx | 14 +-
.../NativeChatQueuedMessageCard.tsx | 8 +-
.../NativeChatQueuedMessageList.test.tsx | 17 --
.../NativeChatQueuedMessageList.tsx | 9 +-
...atSessionOptionPickers.lock-lands.test.tsx | 62 ------
...hatSessionOptionPickers.read-only.test.tsx | 91 --------
.../NativeChatSessionOptionPickers.test.tsx | 19 --
.../NativeChatSessionOptionPickers.tsx | 16 +-
...tStructuredSession.newer-approval.test.tsx | 4 +-
...eChatStructuredSession.read-error.test.tsx | 14 +-
...veChatStructuredSession.read-only.test.tsx | 81 -------
...tiveChatStructuredSession.test-harness.tsx | 6 -
.../NativeChatStructuredSession.tsx | 63 ++++--
.../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 | 3 -
...red-agent-session-delivery-notices.test.ts | 44 ----
...ructured-agent-session-delivery-notices.ts | 34 +--
.../structured-agent-session-read-owner.ts | 3 +-
...tured-agent-session-read-transport.test.ts | 76 +++----
...ructured-agent-session-delivery-notices.ts | 50 -----
...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 | 67 ------
...tructured-agent-session-transport-state.ts | 10 +-
.../use-structured-agent-session.ts | 13 +-
.../src/i18n/en-runtime-required.json | 4 +-
src/renderer/src/i18n/locales/en.json | 4 +-
src/renderer/src/i18n/locales/es.json | 4 +-
src/renderer/src/i18n/locales/fr.json | 4 +-
src/renderer/src/i18n/locales/ja.json | 4 +-
src/renderer/src/i18n/locales/ko.json | 4 +-
src/renderer/src/i18n/locales/zh.json | 4 +-
src/shared/agent-session-read-only.ts | 18 --
.../agent-session-refusal-notice.test.ts | 7 +-
src/shared/agent-session-refusal-notice.ts | 193 +----------------
.../agent-session-refusal-reason-words.ts | 189 +++++++++++++++++
src/shared/agent-session-wire.ts | 5 -
src/shared/agent-session-write-notice-copy.ts | 12 +-
.../structured-agent-session-read-refusal.ts | 10 +-
...ed-agent-session-reducer.read-only.test.ts | 74 -------
.../structured-agent-session-reducer.ts | 5 -
...t-session-newer-chat-refusal.unit.test.ts} | 180 ++++++----------
...-session-stop-event-downgrade.unit.test.ts | 15 +-
...t-session-read-only-reconnect.unit.test.ts | 118 -----------
131 files changed, 1168 insertions(+), 2691 deletions(-)
delete mode 100644 mobile/src/session/mobile-native-chat-read-only.test.tsx
delete mode 100644 mobile/src/session/mobile-structured-read-only-notice.ts
create mode 100644 mobile/src/session/use-mobile-structured-agent-session-read-failure.test.tsx
delete mode 100644 src/main/native-chat/agent-session-wire/agent-session-read-only-page.test.ts
create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-newer-orca.test.ts
delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-read-only.test.ts
delete mode 100644 src/renderer/src/components/native-chat/NativeChatComposerField.placeholder.test.tsx
delete mode 100644 src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.lock-lands.test.tsx
delete mode 100644 src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.read-only.test.tsx
delete mode 100644 src/renderer/src/components/native-chat/NativeChatStructuredSession.read-only.test.tsx
delete mode 100644 src/renderer/src/components/native-chat/native-chat-composer-target.test.ts
delete mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.ts
delete mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-transport-state.test.tsx
delete mode 100644 src/shared/agent-session-read-only.ts
create mode 100644 src/shared/agent-session-refusal-reason-words.ts
delete mode 100644 src/shared/structured-agent-session-reducer.read-only.test.ts
rename tests/e2e/cross-version-wire/{agent-session-read-only.unit.test.ts => agent-session-newer-chat-refusal.unit.test.ts} (50%)
delete mode 100644 tests/e2e/structured-agent-session-read-only-reconnect.unit.test.ts
diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc
index 7f04e63b6e69..4ea9b9714fe6 100644
--- a/config/reliability-gates.jsonc
+++ b/config/reliability-gates.jsonc
@@ -648,8 +648,8 @@
"motivatingLinks": [
"https://github.com/stablyai/orca/blob/main/src/main/native-chat/agent-session-journal/journal-open.ts"
],
- "invariant": "Replay preserves latest revisions, original item order, fences, aliases, submissions, read-only schema latching over damage and cursor cleanup while retaining live items rather than all historical bodies; damage fails the load and deletes nothing.",
- "oracle": "Replay 2,048 16 KiB revisions into one latest item with less than 8 MiB sampled live heap growth; latch future-schema rows read past a gap, name the first damage, fail a damaged load with every row kept, and hold no SQLite read snapshot across reduction (a mid-replay checkpoint is not busy). Existing journal and subscriber tests cover replayed content.",
+ "invariant": "Replay preserves latest revisions, original item order, fences, aliases, submissions, a newer schema winning over damage and cursor cleanup while retaining live items rather than all historical bodies; damage fails the load and deletes nothing.",
+ "oracle": "Replay 2,048 16 KiB revisions into one latest item with less than 8 MiB sampled live heap growth; name a future-schema row read past a gap, name the first damage, fail a damaged load with every row kept, and hold no SQLite read snapshot across reduction (a mid-replay checkpoint is not busy). Existing journal and subscriber tests cover replayed content.",
"commands": [
// As the 2026-09-11 evidence run ran it; the recovery test it names went with the read-time repair.
"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 src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts",
@@ -666,7 +666,7 @@
"assertions": [
"releases superseded revision bodies while reducing a long journal",
"holds no read snapshot while reducing, so a checkpoint can pass mid-replay",
- "reads past a gap to a future row, which latches read-only with no damage named",
+ "reads past a gap to a future row, which names the newer row and no damage",
"names the first damage when a later row is malformed too",
"names a missing epoch row before a later gap"
]
@@ -675,7 +675,7 @@
"file": "src/main/native-chat/agent-session-journal/journal-damage.test.ts",
"assertions": [
"fails to load and keeps every row: %s",
- "reads past damage to a newer build's row, which keeps the chat read-only instead",
+ "reads past damage to a newer build's row, which fails the load as a newer Orca's instead",
"is refused when it is written, and the chat still loads with every other row",
"is founded afresh on open, with nothing deleted, and takes writes"
]
diff --git a/mobile/src/session/MobileNativeChatComposer.test.ts b/mobile/src/session/MobileNativeChatComposer.test.ts
index 0a047a50bc5b..902022ab03e7 100644
--- a/mobile/src/session/MobileNativeChatComposer.test.ts
+++ b/mobile/src/session/MobileNativeChatComposer.test.ts
@@ -691,49 +691,4 @@ 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 1bc900c077b6..b04e09d80e25 100644
--- a/mobile/src/session/MobileNativeChatComposer.tsx
+++ b/mobile/src/session/MobileNativeChatComposer.tsx
@@ -68,9 +68,6 @@ 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
@@ -97,7 +94,6 @@ export function MobileNativeChatComposer({
onMicPressIn,
onMicPressOut,
disabled = false,
- readOnly = false,
placeholder = 'Message, @files, /commands',
filePaths = NO_FILE_PATHS,
onNeedFiles
@@ -271,10 +267,9 @@ export function MobileNativeChatComposer({
placeholderTextColor={colors.textMuted}
selectionColor={colors.accentBlue}
multiline
- // 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}
+ // 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.
textAlignVertical="top"
/>
@@ -296,7 +291,6 @@ export function MobileNativeChatComposer({
) : null}
diff --git a/mobile/src/session/MobileNativeChatOverlay.tsx b/mobile/src/session/MobileNativeChatOverlay.tsx
index fbdc41837a44..d3d6632b68e9 100644
--- a/mobile/src/session/MobileNativeChatOverlay.tsx
+++ b/mobile/src/session/MobileNativeChatOverlay.tsx
@@ -53,8 +53,6 @@ export function MobileNativeChatOverlay({
keyboardInset
}: Props): React.JSX.Element | null {
const session = controller.nativeChatSession
- // 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(
folded,
@@ -70,8 +68,7 @@ export function MobileNativeChatOverlay({
onEdit: queued.edit,
pause: queued.pause,
onResume: queued.resume,
- sessionKey: queued.sessionKey,
- disabled: readOnlyNotice !== null
+ sessionKey: queued.sessionKey
})
if (!controller.showNativeChat) {
return null
@@ -83,6 +80,7 @@ export function MobileNativeChatOverlay({
folded={folded}
status={session.status}
error={session.error}
+ readFailedFinally={session.readFailedFinally === true}
agent={controller.nativeChatAgent}
agentWorking={controller.nativeChatAgentWorking}
canStop={controller.nativeChatCanStop}
@@ -128,7 +126,6 @@ export function MobileNativeChatOverlay({
inputLockReason={inputLockReason}
sendErrorMessage={sendErrorMessage}
onClearSendError={onClearSendError}
- readOnlyNotice={readOnlyNotice}
filePaths={controller.nativeChatFilePaths}
onNeedFiles={controller.loadNativeChatFiles}
sessionOptions={controller.nativeChatSessionOptions}
diff --git a/mobile/src/session/MobileNativeChatPermission.test.ts b/mobile/src/session/MobileNativeChatPermission.test.ts
index 26a3a0602373..9d82f254ffce 100644
--- a/mobile/src/session/MobileNativeChatPermission.test.ts
+++ b/mobile/src/session/MobileNativeChatPermission.test.ts
@@ -197,22 +197,4 @@ describe('MobileNativeChatPermission', () => {
expect(options.every((option) => option.props.disabled === true)).toBe(true)
})
})
-
- 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 a5e90a274d2f..ab7dda7374e0 100644
--- a/mobile/src/session/MobileNativeChatPermission.tsx
+++ b/mobile/src/session/MobileNativeChatPermission.tsx
@@ -15,17 +15,13 @@ import type { MobileChatPermission } from './mobile-native-chat-permission'
function MobileNativeChatPermissionImpl({
permission,
onRespond,
- onCancel,
- disabled = false
+ onCancel
}: {
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 [inFlight, setSubmitting] = useState(false)
- const submitting = inFlight || disabled
+ const [submitting, setSubmitting] = useState(false)
const submittingRef = useRef(false)
// A newer Orca's subject: its detail is shown, and only the card's cancel answers.
const newerSubject = isNewerApprovalSubject(permission.subject)
@@ -130,7 +126,7 @@ function MobileNativeChatPermissionImpl({
styles.option,
isPrimary ? styles.optionPrimary : styles.optionSecondary,
pressed && !submitting && styles.optionPressed,
- (disabled || newerSubject) && styles.disabled
+ newerSubject && styles.disabled
]}
hitSlop={6}
onPress={() => respond(option.send)}
diff --git a/mobile/src/session/MobileNativeChatPromptCard.tsx b/mobile/src/session/MobileNativeChatPromptCard.tsx
index 03b75702fbdf..31a007801dc5 100644
--- a/mobile/src/session/MobileNativeChatPromptCard.tsx
+++ b/mobile/src/session/MobileNativeChatPromptCard.tsx
@@ -19,8 +19,7 @@ export function MobileNativeChatPromptCard({
permission,
onRespondPermission,
question,
- onAnswerQuestion,
- disabled = false
+ onAnswerQuestion
}: {
ask?: AskPrompt | null
askKey?: string | null
@@ -32,8 +31,6 @@ 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 (
@@ -64,7 +61,6 @@ export function MobileNativeChatPromptCard({
permission={permission}
onRespond={async (send) => (await onRespondPermission?.(send)) ?? false}
onCancel={onCancelPrompt}
- disabled={disabled}
/>
)
}
@@ -75,7 +71,6 @@ 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 4958d7797410..f5f9e69cb8ec 100644
--- a/mobile/src/session/MobileNativeChatQuestion.tsx
+++ b/mobile/src/session/MobileNativeChatQuestion.tsx
@@ -14,8 +14,6 @@ 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
@@ -25,13 +23,11 @@ type Props = {
export function MobileNativeChatQuestion({
question,
onAnswer,
- onCancel,
- disabled = false
+ onCancel
}: Props): React.JSX.Element {
const [selectedOptionIndexes, setSelectedOptionIndexes] = useState([])
const [freeText, setFreeText] = useState('')
- const [inFlight, setSending] = useState(false)
- const sending = inFlight || disabled
+ const [sending, setSending] = useState(false)
const sendingRef = useRef(false)
const allowOther = question.allowOther !== false
@@ -137,11 +133,9 @@ export function MobileNativeChatQuestion({
style={({ pressed }) => [
styles.option,
isSelected && styles.optionSelected,
- pressed && styles.pressed,
- disabled && styles.disabled
+ pressed && styles.pressed
]}
onPress={() => (question.multiSelect ? toggle(optIndex) : answerSingle(optIndex))}
- disabled={disabled}
>
{question.multiSelect ? (
@@ -189,7 +183,6 @@ export function MobileNativeChatQuestion({
placeholderTextColor={colors.textMuted}
selectionColor={colors.accentBlue}
onSubmitEditing={submitFreeText}
- editable={!disabled}
returnKeyType="send"
multiline
/>
@@ -324,8 +317,5 @@ 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 0f6e519bb346..b2929f1a1a57 100644
--- a/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx
+++ b/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx
@@ -383,19 +383,4 @@ 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 4eaf486ece05..57f2fa410ffe 100644
--- a/mobile/src/session/MobileNativeChatQueuedMessages.tsx
+++ b/mobile/src/session/MobileNativeChatQueuedMessages.tsx
@@ -34,8 +34,6 @@ 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
@@ -46,8 +44,7 @@ export function MobileNativeChatQueuedMessages({
onDelete,
onEdit,
pause,
- onResume,
- disabled = false
+ onResume
}: 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.
@@ -70,7 +67,7 @@ export function MobileNativeChatQueuedMessages({
setBusyIds(new Set(inFlightRef.current))
}
}
- const resuming = disabled || busyIds.has(RESUME_KEY)
+ const resuming = 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)
@@ -106,7 +103,7 @@ export function MobileNativeChatQueuedMessages({
) : null}
{cards.map((card, index) => {
- const busy = disabled || busyIds.has(card.messageId)
+ const busy = 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 b1db761b6c7c..7a0ec85912fd 100644
--- a/mobile/src/session/MobileNativeChatSessionOptionPickers.tsx
+++ b/mobile/src/session/MobileNativeChatSessionOptionPickers.tsx
@@ -33,24 +33,19 @@ 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,
- locked = false
+ sendInFlight = 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)
- if (!locked) {
- setOpenDescriptorId(controller.optionPickerRequest.id)
- }
+ setOpenDescriptorId(controller.optionPickerRequest.id)
}
const { snapshot, pendingId } = controller
const model = snapshot.find((descriptor) => descriptor.category === 'model')
@@ -58,7 +53,7 @@ export function MobileNativeChatSessionOptionPickers({
if (!model) {
return null
}
- const disabled = locked || isWorking || pendingId !== null || sendInFlight
+ const disabled = 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 8f4eae4a5803..37c6d226d292 100644
--- a/mobile/src/session/MobileNativeChatView.test.ts
+++ b/mobile/src/session/MobileNativeChatView.test.ts
@@ -80,7 +80,6 @@ type Overrides = {
sendErrorMessage?: string | null
onClearSendError?: () => void
inputLockReason?: 'disconnected' | 'waiting' | null
- readOnlyNotice?: string | null
onSend?: (text: string) => Promise
pending?: Parameters[0]['pending']
structuredActivityUi?: boolean
@@ -94,6 +93,9 @@ type Overrides = {
keyboardInset?: number
hasMore?: boolean
onLoadEarlier?: () => void
+ status?: Parameters[0]['status']
+ error?: string
+ readFailedFinally?: boolean
}
function assistantTurn(id: string, text: string): NativeChatMessage {
@@ -215,6 +217,16 @@ 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 () => {
+ const composers = () => renderer!.root.findAll((node) => node.type === 'Composer')
+ await render({ status: 'error', error: 'Unable to load this chat.', readFailedFinally: true })
+ expect(composers()).toHaveLength(0)
+
+ await update({ status: 'error', error: "Orca couldn't open this chat's history right now." })
+ expect(composers()).toHaveLength(1)
+ })
+
it('renders the route-reported failure verbatim', async () => {
await render({ sendErrorMessage: 'Permission reply failed' })
@@ -678,17 +690,4 @@ describe('MobileNativeChatView', () => {
vi.useRealTimers()
}
})
-
- 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).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 2dd8b7b68f09..cabb30889601 100644
--- a/mobile/src/session/MobileNativeChatView.tsx
+++ b/mobile/src/session/MobileNativeChatView.tsx
@@ -42,10 +42,9 @@ 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, 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'
+/** 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'
type Props = MobileQueuedSlotProps & {
/** Raw transcript, only for telling "still loading" from "loaded and empty". */
@@ -54,6 +53,8 @@ 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. */
+ readFailedFinally?: boolean
/** Resolved agent for this chat; names the empty-state copy (desktop parity). */
agent?: string | null
agentWorking?: boolean
@@ -110,8 +111,6 @@ type Props = MobileQueuedSlotProps & {
sendErrorMessage?: string | null
/** Clears `sendErrorMessage` once a later send is accepted. */
onClearSendError?: () => void
- /** Why the host keeps this chat read-only: the placeholder of a `read-only` lock. */
- readOnlyNotice?: string | null
filePaths?: string[]
onNeedFiles?: (query: string) => void
/** Model/session-option pickers for the composer action row (desktop parity). */
@@ -148,6 +147,7 @@ export function MobileNativeChatView({
folded,
status,
error,
+ readFailedFinally = false,
agent,
agentWorking,
canStop = agentWorking,
@@ -181,7 +181,6 @@ export function MobileNativeChatView({
inputLockReason,
sendErrorMessage,
onClearSendError,
- readOnlyNotice = null,
filePaths,
onNeedFiles,
sessionOptions,
@@ -310,9 +309,7 @@ export function MobileNativeChatView({
const emptyState = mobileNativeChatEmptyState(status, agent ?? null, error)
const showLoading = status === 'loading' && messages.length === 0
- // A read-only lock lasts, so it needs no settling; a transport lock outranks it.
- const lockReason =
- useSettledMobileNativeChatInputLock(inputLockReason) ?? (readOnlyNotice ? 'read-only' : null)
+ const lockReason = useSettledMobileNativeChatInputLock(inputLockReason)
return (
@@ -394,7 +391,6 @@ export function MobileNativeChatView({
onRespondPermission={onRespondPermission}
question={question}
onAnswerQuestion={onAnswerQuestion}
- disabled={readOnlyNotice !== null}
/>
@@ -434,38 +430,41 @@ export function MobileNativeChatView({
{sendErrorMessage}
) : null}
-
+ {readFailedFinally ? null : (
+
+ )}
)
}
diff --git a/mobile/src/session/mobile-native-chat-read-only.test.tsx b/mobile/src/session/mobile-native-chat-read-only.test.tsx
deleted file mode 100644
index a9bd69985cc8..000000000000
--- a/mobile/src/session/mobile-native-chat-read-only.test.tsx
+++ /dev/null
@@ -1,200 +0,0 @@
-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',
- // 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: [
- {
- 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,
- 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 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(
- 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,
- onOpenFile: vi.fn(),
- images: {
- attachments: [],
- isAttaching: false,
- attachImage: vi.fn(async () => {}),
- removeAttachment: vi.fn(),
- sendNativeChat: vi.fn(async () => true)
- },
- 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 [view] = renderer!.root.findAll((node) => String(node.type) === 'ChatView')
- const props: Record = view!.props
- act(() => renderer?.unmount())
- renderer = null
- return props
-}
-
-/** 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: { id: 'question-1' },
- permission: { id: 'permission-1' },
- composerText: 'a draft'
- })
- expect(queuedCardsProps(readOnly)).toMatchObject({ disabled: true })
- expect(chatViewProps(overlayElement(NOTICE, 'disconnected'))).toMatchObject({
- inputLockReason: 'disconnected'
- })
- const writable = chatViewProps(overlayElement(null))
- expect(writable).toMatchObject({ inputLockReason: null, readOnlyNotice: null })
- expect(queuedCardsProps(writable)).toMatchObject({ disabled: false })
-})
diff --git a/mobile/src/session/mobile-structured-agent-session-rpc.ts b/mobile/src/session/mobile-structured-agent-session-rpc.ts
index 7ccbc10e2198..c2506ba9ed8d 100644
--- a/mobile/src/session/mobile-structured-agent-session-rpc.ts
+++ b/mobile/src/session/mobile-structured-agent-session-rpc.ts
@@ -58,10 +58,15 @@ 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. */
-export function agentSessionReadFailureText(failure: unknown): string {
- const refusal = readAgentSessionErrorRefusal(
+/** The refusal a failed read met, from a thrown error or a stream's error frame. */
+export function agentSessionReadFailureRefusal(failure: unknown) {
+ return readAgentSessionErrorRefusal(
typeof failure === 'object' && failure !== null && 'error' in failure ? failure.error : failure
)
+}
+
+export function agentSessionReadFailureText(failure: unknown): string {
+ const refusal = agentSessionReadFailureRefusal(failure)
if (refusal) {
return agentSessionWriteNoticeEnglish(
agentSessionWriteNoticeParts(agentSessionRefusalFailure(refusal), 'read-history')
diff --git a/mobile/src/session/mobile-structured-agent-session-thrown-refusal.test.ts b/mobile/src/session/mobile-structured-agent-session-thrown-refusal.test.ts
index 065845640b3c..0a682f1c937a 100644
--- a/mobile/src/session/mobile-structured-agent-session-thrown-refusal.test.ts
+++ b/mobile/src/session/mobile-structured-agent-session-thrown-refusal.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import {
+ agentSessionReadFailureRefusal,
agentSessionReadFailureText,
callAgentSession,
requestStructuredAgentSessionMutation
@@ -43,6 +44,18 @@ describe('a refusal the host threw', () => {
expect(agentSessionReadFailureText({ type: 'error', message: 'Connection interrupted' })).toBe(
'Connection interrupted'
)
+ // The refusal itself rides beside the words, so the view can tell a failure no retry gets past.
+ expect(
+ agentSessionReadFailureRefusal({
+ type: 'error',
+ message: THROWN_JOURNAL_REFUSAL.message,
+ error: THROWN_JOURNAL_REFUSAL
+ })
+ ).toEqual(THROWN_JOURNAL_REFUSAL.data.refusal)
+ expect(agentSessionReadFailureRefusal(thrown)).toEqual(THROWN_JOURNAL_REFUSAL.data.refusal)
+ expect(
+ agentSessionReadFailureRefusal({ type: 'error', message: 'Connection interrupted' })
+ ).toBeUndefined()
})
it("fails a write with the refusal's words, not as unconfirmed", async () => {
diff --git a/mobile/src/session/mobile-structured-read-only-notice.ts b/mobile/src/session/mobile-structured-read-only-notice.ts
deleted file mode 100644
index 09a50b85cc8c..000000000000
--- a/mobile/src/session/mobile-structured-read-only-notice.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-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-queued-slot.ts b/mobile/src/session/use-mobile-native-chat-queued-slot.ts
index 27a3f0896728..aac805a5c20e 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, disabled } = queuedMessages
+ const { cards, onSend, onDelete, pause, onResume, sessionKey } = queuedMessages
return useMemo(
() => ({
cards: createElement(MobileNativeChatQueuedMessages, {
@@ -41,21 +41,10 @@ export function useMobileNativeChatQueuedSlot(
onDelete,
onEdit: editQueuedMessage,
pause,
- onResume,
- disabled
+ onResume
}),
composerInputRef
}),
- [
- cards,
- composerInputRef,
- disabled,
- editQueuedMessage,
- onDelete,
- onResume,
- onSend,
- pause,
- sessionKey
- ]
+ [cards, composerInputRef, editQueuedMessage, onDelete, onResume, onSend, pause, sessionKey]
)
}
diff --git a/mobile/src/session/use-mobile-native-chat-session.ts b/mobile/src/session/use-mobile-native-chat-session.ts
index 66564cf00252..c3add0c67b4a 100644
--- a/mobile/src/session/use-mobile-native-chat-session.ts
+++ b/mobile/src/session/use-mobile-native-chat-session.ts
@@ -35,14 +35,15 @@ export type MobileNativeChatSession = {
* wait for this to clear. */
transcriptLoading: boolean
error?: string
+ /** The read failed for good (damage, or a newer Orca's chat): the error says why, once, and
+ * there is nothing to send into. */
+ readFailedFinally?: boolean
/** True when an older page may exist (the last read filled the window). */
hasMore: boolean
/** Whether an older-history page is currently loading. */
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-read-failure.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-read-failure.test.tsx
new file mode 100644
index 000000000000..0c1c1a7f7115
--- /dev/null
+++ b/mobile/src/session/use-mobile-structured-agent-session-read-failure.test.tsx
@@ -0,0 +1,104 @@
+// A refused read of a chat's history: the phone keeps the refusal beside its words, so a failure no
+// retry gets past (damage, a newer Orca's chat) shows no composer under it.
+
+import { createElement } from 'react'
+import { act, create, type ReactTestRenderer } from 'react-test-renderer'
+import { afterEach, beforeEach, expect, it, vi } from 'vitest'
+import type { RpcClient } from '../transport/rpc-client'
+import { resetMobileStructuredSendOperationJournalForTests } from './mobile-structured-send-operation-journal'
+import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session'
+import { CAPABLE, SESSION_ID, ok } from './use-mobile-structured-agent-session-queued.test-fixture'
+
+const asyncStorage = vi.hoisted(() => ({
+ getItem: vi.fn(async () => null),
+ setItem: vi.fn(async () => undefined),
+ removeItem: vi.fn(async () => undefined)
+}))
+
+vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage }))
+
+let renderer: ReactTestRenderer | null = null
+let hook: ReturnType | null = null
+let listener: ((value: unknown) => void) | null = null
+const sendRequest = vi.fn()
+const client: RpcClient = {
+ sendRequest,
+ subscribe: vi.fn((_method, _params, onData) => {
+ listener = onData
+ return vi.fn()
+ }),
+ updateTerminalSubscriptionViewport: () => {},
+ getState: () => 'connected',
+ getReconnectAttempt: () => 0,
+ getLastConnectedAt: () => null,
+ onStateChange: () => () => {},
+ notifyForeground: () => {},
+ close: () => {}
+}
+
+function Harness(): null {
+ hook = useMobileStructuredAgentSession({
+ client,
+ sessionId: SESSION_ID,
+ sourceIdentity: 'host-a\0workspace-a',
+ enabled: true,
+ connected: true,
+ agent: 'claude',
+ hostSupport: CAPABLE,
+ appendComposerText: () => true,
+ onSendError: vi.fn()
+ })
+ return null
+}
+
+/** The stream's error frame for a refused read, as the RPC client hands it over. */
+function refusedRead(reason: string) {
+ const error = {
+ code: 'runtime_error',
+ message: 'agent_session_journal_unreadable',
+ data: { refusal: { code: 'agent_session_journal_unreadable', details: { reason } } }
+ }
+ return { type: 'error', message: error.message, error }
+}
+
+async function readFailedWith(reason: string) {
+ act(() => {
+ renderer = create(createElement(Harness))
+ })
+ await vi.waitFor(() => expect(listener).toEqual(expect.any(Function)))
+ act(() => listener?.(refusedRead(reason)))
+ return hook!.session
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ resetMobileStructuredSendOperationJournalForTests()
+ sendRequest.mockImplementation(async (method) =>
+ method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({})
+ )
+})
+
+afterEach(() => {
+ act(() => renderer?.unmount())
+ renderer = null
+ hook = null
+ listener = null
+})
+
+it.each([
+ ['journalCorrupt', 'Unable to load this chat.'],
+ ['journalWrittenByNewerOrca', 'This chat was saved by a newer Orca. Update Orca to open it.']
+])('reads %s as a failure no retry gets past, in its words', async (reason, words) => {
+ expect(await readFailedWith(reason)).toMatchObject({
+ status: 'error',
+ error: words,
+ readFailedFinally: true
+ })
+})
+
+it('reads a failure that can clear as one the chat keeps trying past', async () => {
+ expect(await readFailedWith('journalUnavailable')).toMatchObject({
+ status: 'error',
+ readFailedFinally: false
+ })
+})
diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts
index fd7632018df7..5dedeae0712e 100644
--- a/mobile/src/session/use-mobile-structured-agent-session.ts
+++ b/mobile/src/session/use-mobile-structured-agent-session.ts
@@ -3,6 +3,7 @@ import { encodeNativeChatTranscriptIdentity } from '../../../src/shared/native-c
import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send'
import { projectStructuredAgentSessionMessages } from '../../../src/shared/structured-agent-session-message-projection'
import { isStructuredAgentSessionMainAgentWorking } from '../../../src/shared/structured-agent-session-main-agent-working'
+import { isFinalAgentSessionReadRefusal } from '../../../src/shared/structured-agent-session-read-refusal'
import {
activeStructuredAgentSessionTurnId,
isStructuredAgentSessionThinking
@@ -30,7 +31,6 @@ 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
@@ -162,10 +162,7 @@ export function useMobileStructuredAgentSession(args: {
() => projectStructuredAgentSessionMessages(state.items, [], state.submissions),
[state.items, state.submissions]
)
- // 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 turnId = activeStructuredAgentSessionTurnId(state.items)
const turnTiming = useMobileStructuredAgentTurnTiming(state, turnId)
const activityText =
selectStructuredAgentTurnActivity(state.items, turnId, state.activity)?.text ?? null
@@ -225,14 +222,12 @@ export function useMobileStructuredAgentSession(args: {
status,
transcriptLoading: status === 'loading',
error: state.error,
+ readFailedFinally: status === 'error' && isFinalAgentSessionReadRefusal(state.readRefusal),
hasMore: state.hasOlder,
loadingEarlier: loadingOlder,
- loadEarlier,
- readOnlyNotice
+ loadEarlier
},
- isWorking:
- !readOnlyNotice &&
- isStructuredAgentSessionMainAgentWorking(turnId, state.submissions, state.fence),
+ isWorking: isStructuredAgentSessionMainAgentWorking(turnId, state.submissions, state.fence),
turnId,
turnIndicator,
...turnTiming,
diff --git a/mobile/src/session/use-mobile-structured-agent-state.ts b/mobile/src/session/use-mobile-structured-agent-state.ts
index 9eca412c5411..b903e42de001 100644
--- a/mobile/src/session/use-mobile-structured-agent-state.ts
+++ b/mobile/src/session/use-mobile-structured-agent-state.ts
@@ -17,6 +17,7 @@ import {
} from '../../../src/shared/structured-agent-session-reducer'
import type { RpcClient } from '../transport/rpc-client'
import {
+ agentSessionReadFailureRefusal,
agentSessionReadFailureText,
callAgentSession
} from './mobile-structured-agent-session-rpc'
@@ -138,6 +139,18 @@ export function useMobileStructuredAgentState(args: {
},
[sessionKey]
)
+ // The refusal rides beside its words, so the view can tell a failure no retry gets past.
+ const applyReadFailure = useCallback(
+ (failure: unknown) => {
+ const refusal = agentSessionReadFailureRefusal(failure)
+ apply({
+ type: 'error',
+ message: agentSessionReadFailureText(failure),
+ ...(refusal ? { refusal } : {})
+ })
+ },
+ [apply]
+ )
const applyQueued = useCallback(
(event: AgentSessionSubscribeEvent) => {
@@ -187,7 +200,7 @@ export function useMobileStructuredAgentState(args: {
})
const endStream = openTranscriptAfterHold(client, sessionId, held, (raw) => {
if (typeof raw === 'object' && raw !== null && 'type' in raw && raw.type === 'error') {
- apply({ type: 'error', message: agentSessionReadFailureText(raw) })
+ applyReadFailure(raw)
return
}
if (isSubscribeEvent(raw)) {
@@ -212,7 +225,7 @@ export function useMobileStructuredAgentState(args: {
)
.catch(() => undefined)
}
- }, [apply, applyQueued, client, connected, enabled, sessionId, sessionKey])
+ }, [apply, applyQueued, applyReadFailure, client, connected, enabled, sessionId, sessionKey])
const loadEarlier = useCallback(() => {
const current = stateRef.current
@@ -280,7 +293,7 @@ export function useMobileStructuredAgentState(args: {
})()
.catch((error: unknown) => {
if (isCurrentRead()) {
- apply({ type: 'error', message: agentSessionReadFailureText(error) })
+ applyReadFailure(error)
}
})
.finally(() => {
@@ -288,7 +301,7 @@ export function useMobileStructuredAgentState(args: {
setLoadingOlder(false)
}
})
- }, [apply, client, loadingOlder, sessionId, sessionKey])
+ }, [apply, applyReadFailure, client, loadingOlder, sessionId, sessionKey])
// A window of only a subagent's rows draws nothing, and an empty list cannot be scrolled
// to ask for more, so it reads back once from each such head. A first page can be one: an
diff --git a/src/main/claude/claude-background-wake-retention.test.ts b/src/main/claude/claude-background-wake-retention.test.ts
index a865e1c1b76e..d3a32a2bb81a 100644
--- a/src/main/claude/claude-background-wake-retention.test.ts
+++ b/src/main/claude/claude-background-wake-retention.test.ts
@@ -31,7 +31,6 @@ async function wiredSession() {
let sequence = 0
const journal = {
cursor: () => ({ epoch: 'epoch-1', sequence: ++sequence }),
- isReadOnly: false,
lastActivityAt: () => 1,
snapshot: () => ({
items: [...run.journalItems.values()]
diff --git a/src/main/native-chat/agent-session-journal/journal-closed-sets.test.ts b/src/main/native-chat/agent-session-journal/journal-closed-sets.test.ts
index a042abdecab1..acaaf99e0ae3 100644
--- a/src/main/native-chat/agent-session-journal/journal-closed-sets.test.ts
+++ b/src/main/native-chat/agent-session-journal/journal-closed-sets.test.ts
@@ -1,8 +1,9 @@
// Every closed set a journal row is read against: a row kind, a lifecycle mutation kind, and each
-// enum, literal or discriminant in a body. A new row or mutation kind makes older builds go
-// read-only; a new value in a body's closed set is damage to them, and the chat fails to load
-// (a turn's context usage is dropped instead). This snapshot sits beside the row version so that
-// a change to either is deliberate.
+// enum, literal or discriminant in a body. A new row or mutation kind, or a bumped row version,
+// makes older builds refuse the chat as a newer Orca's ("Update Orca to open it"); a new value in a
+// body's closed set at the same version is damage to them ("Unable to load this chat"), where a
+// turn's context usage is dropped instead. This snapshot sits beside the row version so that a
+// change to either is deliberate.
import { expect, it } from 'vitest'
import { z } from 'zod'
@@ -102,8 +103,7 @@ const SNAPSHOT = {
it('changes no closed set of a journal row without the row version', () => {
expect(
journalClosedSets(),
- 'A new closed-set value without a row-version bump makes older builds fail to load the ' +
- 'chat (a new row or batch-change kind makes them read-only instead). Bump v, then update ' +
- 'this snapshot.'
+ 'A new closed-set value without a row-version bump makes older builds call the chat ' +
+ 'damaged, where with the bump they say to update Orca. Bump v, then update this snapshot.'
).toEqual(SNAPSHOT)
})
diff --git a/src/main/native-chat/agent-session-journal/journal-cursor.ts b/src/main/native-chat/agent-session-journal/journal-cursor.ts
index bd43623ae497..3e79cf326a52 100644
--- a/src/main/native-chat/agent-session-journal/journal-cursor.ts
+++ b/src/main/native-chat/agent-session-journal/journal-cursor.ts
@@ -72,20 +72,15 @@ export function findSequenceGap(
return null
}
-/** Rows appended after `cursor`, or the reset a client must take instead. A
- * read-only journal always resets: this build cannot vouch for what it holds. */
+/** Rows appended after `cursor`, or the reset a client must take instead. */
export function readJournalSince(
source: {
state: { epoch: string; lastSequence: number; oldestSequence: number }
rowsAfter: (afterSequence: number) => JournalRow[]
- readOnly: boolean
},
cursor: AgentJournalCursor,
currentCursor: () => AgentJournalCursor
): JournalReadSince {
- if (source.readOnly) {
- return { ok: false, reset: 'schema_unreadable' }
- }
const resume = resolveJournalResume(source.state, cursor)
if (!resume.ok) {
return { ok: false, reset: resume.reset }
diff --git a/src/main/native-chat/agent-session-journal/journal-damage.test.ts b/src/main/native-chat/agent-session-journal/journal-damage.test.ts
index 10e0cd66fb33..2ced3e4b2a99 100644
--- a/src/main/native-chat/agent-session-journal/journal-damage.test.ts
+++ b/src/main/native-chat/agent-session-journal/journal-damage.test.ts
@@ -22,7 +22,8 @@ import {
loadTestJournal,
liveTestJournalRows,
updateTestJournalRowJson,
- deleteTestJournalRow
+ deleteTestJournalRow,
+ SAVED_BY_NEWER_ORCA
} from './journal-host-database-test-support'
const IDENTITY: AgentSessionJournalIdentity = {
@@ -141,12 +142,12 @@ describe('a damaged chat', () => {
withJournalDatabase((db) => deleteTestJournalRow(db, IDENTITY.sessionId, 4))
expect(loadTestJournal(root, IDENTITY.sessionId)).toMatchObject({
- readOnly: false,
+ newer: null,
damage: { sequence: 4, cause: 'sequence-gap' }
})
})
- it("reads past damage to a newer build's row, which keeps the chat read-only instead", async () => {
+ it("reads past damage to a newer build's row, which fails the load as a newer Orca's instead", async () => {
await writeChat()
withJournalDatabase((db) => {
updateTestJournalRowJson(db, IDENTITY.sessionId, 3, '}{')
@@ -155,9 +156,7 @@ describe('a damaged chat', () => {
})
const before = storedRows()
- const journal = await open()
-
- expect(journal.isReadOnly).toBe(true)
+ await expect(open()).rejects.toMatchObject(SAVED_BY_NEWER_ORCA)
expect(storedRows()).toEqual(before)
})
})
@@ -189,7 +188,6 @@ describe('a row the reader would reject', () => {
await journal.close()
const reopened = await open()
- expect(reopened.isReadOnly).toBe(false)
expect(reopened.snapshot().items.map((entry) => entry.body)).toEqual([
body('before'),
body('after')
@@ -229,7 +227,7 @@ describe('an epoch named with no rows at all', () => {
}
})
expect(loadTestJournal(root, IDENTITY.sessionId)).toMatchObject({
- readOnly: false,
+ newer: null,
damage: null,
state: { lastSequence: 0 }
})
diff --git a/src/main/native-chat/agent-session-journal/journal-epoch-controller.ts b/src/main/native-chat/agent-session-journal/journal-epoch-controller.ts
index 485e4d784174..3a79762ee2dd 100644
--- a/src/main/native-chat/agent-session-journal/journal-epoch-controller.ts
+++ b/src/main/native-chat/agent-session-journal/journal-epoch-controller.ts
@@ -20,7 +20,6 @@ export class JournalEpochController {
serialize: (run: JournalWriteBody) => Promise
database: () => JournalHostDatabase
readOnly: () => boolean
- setReadOnly: (readOnly: boolean) => void
highestFence: () => number
/** What of the live epoch's Stop and Resume a replacement restates. */
queuePauseRestatement: () => JournalQueuePauseRestatement
@@ -42,8 +41,8 @@ export class JournalEpochController {
}
/**
- * Every reason takes the same writable guard. A latched store refuses a roll
- * like any other write, and `schema_unreadable` has no production caller.
+ * Every reason takes the same writable guard: a newer Orca's database refuses a roll like any
+ * other write, and `schema_unreadable` has no production caller.
*
* Serialized like every other write, so the discard cannot land between an
* admitted append's sequence assignment and its commit.
@@ -52,7 +51,6 @@ export class JournalEpochController {
return this.deps.serialize(() => {
assertJournalWritable(this.deps.readOnly(), this.deps.identity.sessionId)
this.start(reason, fence)
- this.deps.setReadOnly(false)
return this.deps.cursor()
})
}
diff --git a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts
index cc67fc9c62d6..1921bc82a459 100644
--- a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts
+++ b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts
@@ -105,5 +105,5 @@ export function replaceJournalEpoch(input: {
// live one. The caller adopts that immediately, or a later failure leaves the
// live store writing into an epoch whose rows were just deleted.
state.oldestSequence = 1
- input.onPublished({ state, readOnly: false, damage: null })
+ input.onPublished({ state, newer: null, damage: null })
}
diff --git a/src/main/native-chat/agent-session-journal/journal-epoch-rollover.ts b/src/main/native-chat/agent-session-journal/journal-epoch-rollover.ts
index a753053d19fe..7f9abfc408f5 100644
--- a/src/main/native-chat/agent-session-journal/journal-epoch-rollover.ts
+++ b/src/main/native-chat/agent-session-journal/journal-epoch-rollover.ts
@@ -55,5 +55,5 @@ export function publishNewEpoch(input: {
const state = createJournalReducerState(sessionId, input.epoch)
applyJournalRow(state, row)
state.oldestSequence = 1
- input.onPublished({ state, readOnly: false, damage: null })
+ input.onPublished({ state, newer: null, damage: null })
}
diff --git a/src/main/native-chat/agent-session-journal/journal-file-format-remnant.test.ts b/src/main/native-chat/agent-session-journal/journal-file-format-remnant.test.ts
index dafc111fff77..84fb299fb056 100644
--- a/src/main/native-chat/agent-session-journal/journal-file-format-remnant.test.ts
+++ b/src/main/native-chat/agent-session-journal/journal-file-format-remnant.test.ts
@@ -22,7 +22,8 @@ import {
loadTestJournal,
deleteTestJournalRow,
insertTestJournalRowJson,
- liveTestJournalRows
+ liveTestJournalRows,
+ SAVED_BY_NEWER_ORCA
} from './journal-host-database-test-support'
const IDENTITY: AgentSessionJournalIdentity = {
@@ -174,9 +175,8 @@ describe('a chat whose history is still in the pre-SQLite format', () => {
}
})
- // A journal latched by a newer row reaches the same branch — and an append into one throws,
- // which would make the session unopenable rather than read-only.
- it('writes nothing into a journal latched by a newer row', async () => {
+ // A newer row fails the load before the empty-epoch branch would write a notice.
+ it("writes nothing into a newer Orca's journal, whose open is refused", async () => {
const founded = await open()
const epoch = founded.epoch
await founded.close()
@@ -198,10 +198,12 @@ describe('a chat whose history is still in the pre-SQLite format', () => {
)
await writeRemnant()
- const latched = await open()
+ const before = liveTestJournalRows(openTestJournalHostDatabase(root).db, IDENTITY.sessionId)
- expect(latched.isReadOnly).toBe(true)
- expect(disclosure(latched)).toBeNull()
+ await expect(open()).rejects.toMatchObject(SAVED_BY_NEWER_ORCA)
+ expect(liveTestJournalRows(openTestJournalHostDatabase(root).db, IDENTITY.sessionId)).toEqual(
+ before
+ )
})
// A row nothing projects is a row nobody reads.
diff --git a/src/main/native-chat/agent-session-journal/journal-host-database-test-support.ts b/src/main/native-chat/agent-session-journal/journal-host-database-test-support.ts
index dbe15b4709ed..ad151d10ec72 100644
--- a/src/main/native-chat/agent-session-journal/journal-host-database-test-support.ts
+++ b/src/main/native-chat/agent-session-journal/journal-host-database-test-support.ts
@@ -81,6 +81,14 @@ export function createTrackedJournalOpener(): TrackedJournalOpener {
}
/** What a fresh open of the chat would replay, read from the test state directory's database. */
+/** What an open of a chat a newer Orca saved is refused with. */
+export const SAVED_BY_NEWER_ORCA = {
+ refusal: {
+ code: 'agent_session_journal_unreadable',
+ details: { reason: 'journalWrittenByNewerOrca' }
+ }
+}
+
export function loadTestJournal(stateDirectory: string, sessionId: string): JournalLoad | null {
return replayJournal(openTestJournalHostDatabase(stateDirectory).db, sessionId)
}
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 f4391d153e95..98626d3051a9 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
@@ -31,10 +31,18 @@ export class JournalDamageError extends Error {
override readonly name = 'JournalDamageError'
}
-/** The one way damage fails a chat's load, wherever its rows are read: refused as history that
- * cannot be loaded, and every row is left where it is. A newer build's row wins, so a read-only
- * load never fails here. */
-export function failLoadOnJournalDamage(sessionId: string, load: JournalLoad): void {
+/** The one way a chat's load fails on its rows, wherever they are read, and every row is left where
+ * it is. A newer Orca's row is refused as one only an update opens, and wins over damage beside
+ * it; damage is refused as history that cannot be loaded. */
+export function failLoadOnUnloadableJournal(sessionId: string, load: JournalLoad): void {
+ if (load.newer) {
+ throw journalOpenRefusalError(
+ new AgentSessionJournalError(
+ 'journal_read_only',
+ `journal of ${sessionId} holds a newer Orca's row at sequence ${load.newer.sequence}`
+ )
+ )
+ }
if (!load.damage) {
return
}
diff --git a/src/main/native-chat/agent-session-journal/journal-open.ts b/src/main/native-chat/agent-session-journal/journal-open.ts
index be3eb8fa20b9..2c066601ea7a 100644
--- a/src/main/native-chat/agent-session-journal/journal-open.ts
+++ b/src/main/native-chat/agent-session-journal/journal-open.ts
@@ -4,7 +4,8 @@
// There is no snapshot to anchor to and no superseded-epoch rows to drop — a
// roll deletes them in the same transaction that publishes the new epoch. A row
// this build cannot parse, a gap, or a first row that is not the epoch's is
-// damage: the load names it and the open fails on it. Nothing is deleted.
+// damage, and a row a newer Orca wrote is one this build cannot place: the load
+// names either and the open fails on it. Nothing is deleted.
import type Database from '../../sqlite/sync-database'
import {
@@ -31,8 +32,9 @@ export type JournalDamage = {
export type JournalLoad = {
state: JournalReducerState
- /** A row from a future schema, or of a kind this build does not know, was met: no writes. */
- readOnly: boolean
+ /** The first row a newer Orca wrote: a future row version, or a row kind this build does not
+ * know. Only an update opens the chat. */
+ newer: { sequence: number } | null
/** Set when the history is damaged; a newer build's row wins over damage beside it. */
damage: JournalDamage | null
}
@@ -62,17 +64,17 @@ export function startJournalRowFold(input: { sessionId: string; epoch: string })
let expectedSequence = FIRST_JOURNAL_SEQUENCE
let damage: JournalDamage | null = null
let anchored = false
- let latched = false
+ let newer: { sequence: number } | null = null
let empty = true
const add = (entry: { seq: number; rowJson: string }): boolean => {
empty = false
const parsed = parseJournalRow(entry.rowJson)
if (!parsed.ok && parsed.unreadable) {
- latched = true
+ newer = { sequence: entry.seq }
return false
}
- // Damage is read past, so a newer build's row further on still latches read-only.
+ // Damage is read past, so a newer build's row further on still says to update.
if (!parsed.ok) {
damage ??= { sequence: entry.seq, cause: 'unparseable-row' }
return true
@@ -108,7 +110,7 @@ export function startJournalRowFold(input: { sessionId: string; epoch: string })
(anchored || empty
? null
: { sequence: FIRST_JOURNAL_SEQUENCE, cause: 'no-epoch-row' as const })
- return { state, readOnly: latched, damage: latched ? null : found }
+ return { state, newer, damage: newer ? null : found }
}
return { add, finish }
}
diff --git a/src/main/native-chat/agent-session-journal/journal-per-session-import.test.ts b/src/main/native-chat/agent-session-journal/journal-per-session-import.test.ts
index c42847bd43d7..6c291c3a3783 100644
--- a/src/main/native-chat/agent-session-journal/journal-per-session-import.test.ts
+++ b/src/main/native-chat/agent-session-journal/journal-per-session-import.test.ts
@@ -2,7 +2,10 @@
// that chat's open: verbatim, and deleted only once the copy reads back as the file. A file that
// reappears after a downgrade is set aside on disk, and the chat keeps this build's history.
-import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types'
+import {
+ AGENT_JOURNAL_THREAD_SCOPE,
+ AGENT_SESSION_JOURNAL_SCHEMA_VERSION
+} from '../../../shared/agent-session-journal-types'
import type * as NodeFs from 'node:fs'
import { existsSync, rmSync } from 'node:fs'
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises'
@@ -23,7 +26,8 @@ import {
liveTestJournalRows,
loadTestJournal,
openTestJournalHostDatabase,
- readTestJournalRows
+ readTestJournalRows,
+ SAVED_BY_NEWER_ORCA
} from './journal-host-database-test-support'
import { journalDirectoryFor, legacyJournalDatabaseFile } from './journal-paths'
import { importPerSessionJournal, previewPerSessionJournal } from './journal-per-session-import'
@@ -423,7 +427,6 @@ describe('importing a per-chat journal', () => {
).resolves.toBeNull()
const journal = await openChat()
- expect(journal.isReadOnly).toBe(false)
expect(journal.snapshot().items).toEqual([])
expect(journal.cursor().sequence).toBe(1)
})
@@ -456,6 +459,30 @@ describe('importing a per-chat journal', () => {
).toMatchObject({ total: 0 })
})
+ it("keeps a newer Orca's file whole, copies none of it, and refuses the chat as a newer Orca's", async () => {
+ const { epoch, rows } = await historyRows()
+ const last = rows.at(-1)!
+ const newer = {
+ ...last,
+ rowJson: JSON.stringify({
+ ...JSON.parse(last.rowJson),
+ v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION + 1
+ })
+ }
+ await writeLegacyJournal(epoch, [...rows.slice(0, -1), newer])
+ const database = openTestJournalHostDatabase(root)
+ const before = await readFile(legacyJournalDatabaseFile(legacyDir()))
+ const input = { database, identity: IDENTITY, legacyDirectory: legacyDir() }
+
+ await expect(previewPerSessionJournal(input)).rejects.toMatchObject(SAVED_BY_NEWER_ORCA)
+ await expect(importPerSessionJournal(input)).rejects.toMatchObject(SAVED_BY_NEWER_ORCA)
+ await expect(openChat()).rejects.toMatchObject(SAVED_BY_NEWER_ORCA)
+
+ expect((await readFile(legacyJournalDatabaseFile(legacyDir()))).equals(before)).toBe(true)
+ expect(readJournalSessionEpoch(database.db, IDENTITY.sessionId)).toBeNull()
+ expect(rowCount(database.db)).toBe(0)
+ })
+
// A copy that keeps every count but not every byte is no copy: the file is all there is.
it('keeps the file and refuses the chat when a copied row differs but every count matches', async () => {
const { epoch, rows } = await historyRows()
diff --git a/src/main/native-chat/agent-session-journal/journal-per-session-import.ts b/src/main/native-chat/agent-session-journal/journal-per-session-import.ts
index 4b6f892a9218..66f1f139ea32 100644
--- a/src/main/native-chat/agent-session-journal/journal-per-session-import.ts
+++ b/src/main/native-chat/agent-session-journal/journal-per-session-import.ts
@@ -26,7 +26,7 @@ import type Database from '../../sqlite/sync-database'
import type { JournalHostDatabase } from './journal-host-database'
import { startJournalRowFold, type JournalLoad } from './journal-open'
import {
- failLoadOnJournalDamage,
+ failLoadOnUnloadableJournal,
JournalImportMismatchError,
journalOpenRefusalError
} from './journal-open-failure'
@@ -173,9 +173,9 @@ export async function previewPerSessionJournal(
return null
}
const loaded = await foldLegacyJournal(source, sessionId, legacy)
- failLoadOnJournalDamage(sessionId, loaded)
+ failLoadOnUnloadableJournal(sessionId, loaded)
// An empty one is copied now, so its open founds the chat's epoch in the host's database.
- return loaded.readOnly || loaded.state.lastSequence === 0 ? null : loaded
+ return loaded.state.lastSequence === 0 ? null : loaded
} finally {
source.close()
}
@@ -217,11 +217,11 @@ async function copyLegacyJournal(
first = false
}
const copied = fold.finish()
- if (copied.damage) {
+ if (copied.damage || copied.newer) {
// Never published, so the copy goes; the file it came from stays.
input.database.transaction((db) => deleteUnpublishedJournalRows(db, sessionId))
}
- failLoadOnJournalDamage(sessionId, copied)
+ failLoadOnUnloadableJournal(sessionId, copied)
await verifyCopiedJournal(input, legacyRowBatches(source, sessionId, epoch, batchRows), epoch)
input.database.transaction((db) => {
publishJournalSessionEpoch(db, input.identity, epoch)
diff --git a/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.test.ts b/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.test.ts
index 3404c2310959..7b8e9fe5935a 100644
--- a/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.test.ts
+++ b/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.test.ts
@@ -114,7 +114,7 @@ describe("a Stop's event and a Resume", () => {
// such an epoch as it stands.
it('fold as history in an epoch an older build founded after deleting rows', () => {
expect(fold(epochWith('unreconcilable_prefix', ['stop', 'resume']))).toMatchObject({
- readOnly: false,
+ newer: null,
damage: null,
state: { lastSequence: 3 }
})
diff --git a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts
index d251983beb24..815e6480430c 100644
--- a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts
+++ b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts
@@ -40,7 +40,6 @@ export type JournalStoreHost = {
database: () => JournalHostDatabase
state: () => JournalReducerState
readOnly: () => boolean
- setReadOnly: (readOnly: boolean) => void
cursor: () => AgentJournalCursor
adopt: (loaded: JournalLoad) => void
commit: (row: JournalRow) => void
@@ -68,7 +67,6 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal
serialize: host.serialize,
database: host.database,
readOnly: host.readOnly,
- setReadOnly: host.setReadOnly,
highestFence: () => host.state().highestFence,
queuePauseRestatement: () =>
journalQueuePauseRestatement(
diff --git a/src/main/native-chat/agent-session-journal/journal-store-open.ts b/src/main/native-chat/agent-session-journal/journal-store-open.ts
index 29a3381e9b9a..cac1517c4091 100644
--- a/src/main/native-chat/agent-session-journal/journal-store-open.ts
+++ b/src/main/native-chat/agent-session-journal/journal-store-open.ts
@@ -8,18 +8,11 @@ import {
journalFileFormatRemnantDisclosure
} from './journal-file-format-remnant'
import type { JournalLoad } from './journal-open'
-import { failLoadOnJournalDamage } from './journal-open-failure'
+import { failLoadOnUnloadableJournal } from './journal-open-failure'
import { staleSubagentRosterRevisions } from './journal-subagent-liveness'
type JournalDisclosure = ReturnType
-export function journalStoreLoadedFields(loaded: JournalLoad) {
- return {
- state: loaded.state,
- readOnly: loaded.readOnly
- }
-}
-
export async function openJournalStoreState(input: {
sessionId: string
legacyDirectory: string
@@ -33,17 +26,16 @@ export async function openJournalStoreState(input: {
) => Promise
agent: AgentType
highestFence: () => number
- readOnly: () => boolean
}): Promise {
const loaded = input.replay()
// An epoch named but holding no row (a crash inside an older build's repair) has nothing to
// keep, so it is founded afresh like a chat with no journal; no row is deleted.
- if (!loaded || (!loaded.readOnly && !loaded.damage && loaded.state.lastSequence === 0)) {
+ if (!loaded || (!loaded.newer && !loaded.damage && loaded.state.lastSequence === 0)) {
input.start()
await discloseFileFormatRemnant(input)
return
}
- failLoadOnJournalDamage(input.sessionId, loaded)
+ failLoadOnUnloadableJournal(input.sessionId, loaded)
input.adopt(loaded)
await settleStaleSubagentRosters(input, loaded)
// Founding the epoch and appending the row are two transactions, and a
@@ -68,11 +60,7 @@ async function discloseFileFormatRemnant(input: {
fence: number
) => Promise
highestFence: () => number
- readOnly: () => boolean
}): Promise {
- if (input.readOnly()) {
- return
- }
const transcriptPath = findJournalFileFormatRemnant(input.legacyDirectory)
if (!transcriptPath) {
return
@@ -90,13 +78,9 @@ async function settleStaleSubagentRosters(
fence: number
) => Promise
highestFence: () => number
- readOnly: () => boolean
},
loaded: JournalLoad
): Promise {
- if (input.readOnly()) {
- return
- }
for (const revision of staleSubagentRosterRevisions(loaded.state.items.values())) {
await input.appendItem(revision.identity, revision.body, input.highestFence())
}
diff --git a/src/main/native-chat/agent-session-journal/journal-store-restore.ts b/src/main/native-chat/agent-session-journal/journal-store-restore.ts
index cac30b2c55e8..d8c44f964bdb 100644
--- a/src/main/native-chat/agent-session-journal/journal-store-restore.ts
+++ b/src/main/native-chat/agent-session-journal/journal-store-restore.ts
@@ -8,9 +8,8 @@
import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types'
import type { JournalEpochController } from './journal-epoch-controller'
-import { replayJournal, type JournalLoad } from './journal-open'
-import { failLoadOnJournalDamage } from './journal-open-failure'
-import { createJournalReducerState } from './journal-reducer'
+import { replayJournal } from './journal-open'
+import { failLoadOnUnloadableJournal, journalOpenRefusalError } from './journal-open-failure'
import type { JournalStoreHost } from './journal-store-collaborators'
import { openJournalStoreState } from './journal-store-open'
import { importPerSessionJournal, previewPerSessionJournal } from './journal-per-session-import'
@@ -26,7 +25,13 @@ export async function restoreJournalStore(
legacyDirectory: host.legacyDirectory
}
if (source.database.readOnly) {
- return restoreFromNewerDatabase(host, source)
+ // A newer Orca's database: nothing in it is read as this build's, and nothing is written.
+ throw journalOpenRefusalError(
+ new AgentSessionJournalError(
+ 'journal_read_only',
+ `agent-session journal for ${host.identity.sessionId} is in a newer Orca's database`
+ )
+ )
}
// A restore reads a chat still in its per-chat file from there, and copies it before its first use.
const preview = host.deferPerSessionImport ? await previewPerSessionJournal(source) : null
@@ -37,7 +42,7 @@ export async function restoreJournalStore(
if (!imported) {
throw new Error(`per-chat journal of ${host.identity.sessionId} was gone before its copy`)
}
- failLoadOnJournalDamage(host.identity.sessionId, imported)
+ failLoadOnUnloadableJournal(host.identity.sessionId, imported)
host.adopt(imported)
})
} else {
@@ -54,36 +59,6 @@ export async function restoreJournalStore(
appendItem: (identity, body, fence) =>
host.journal().appendItem(identity, body, { fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE }),
agent: host.identity.agent,
- highestFence: () => host.state().highestFence,
- readOnly: host.readOnly
- })
-}
-
-/**
- * A newer Orca's database: each chat shows what this build can read of it, from the database or a
- * per-chat file never copied in, and nothing is written, copied or founded. Every write the store
- * is asked for is refused as read-only, and a newer build's database wins over damage in it.
- */
-async function restoreFromNewerDatabase(
- host: JournalStoreHost,
- source: Parameters[0]
-): Promise {
- const { sessionId } = host.identity
- let loaded: JournalLoad | null
- try {
- loaded =
- (await previewPerSessionJournal(source)) ?? replayJournal(source.database.db, sessionId)
- } catch (error) {
- // Tables the newer schema changed: still a chat only an update opens, not a damaged one.
- throw new AgentSessionJournalError(
- 'journal_read_only',
- `agent-session journal for ${sessionId} could not be read under a newer schema`,
- { cause: error }
- )
- }
- host.adopt({
- state: loaded?.state ?? createJournalReducerState(sessionId, ''),
- readOnly: true,
- damage: null
+ highestFence: () => host.state().highestFence
})
}
diff --git a/src/main/native-chat/agent-session-journal/journal-store-schema.test.ts b/src/main/native-chat/agent-session-journal/journal-store-schema.test.ts
index edd80e1e8962..18cb7efa47a8 100644
--- a/src/main/native-chat/agent-session-journal/journal-store-schema.test.ts
+++ b/src/main/native-chat/agent-session-journal/journal-store-schema.test.ts
@@ -24,7 +24,8 @@ import {
createTrackedJournalOpener,
openTestJournalHostDatabase,
liveTestJournalRows,
- insertTestJournalRowJson
+ insertTestJournalRowJson,
+ SAVED_BY_NEWER_ORCA
} from './journal-host-database-test-support'
const IDENTITY: AgentSessionJournalIdentity = {
@@ -110,8 +111,8 @@ afterEach(async () => {
})
describe('axis 1: the database shape', () => {
- // A newer build's database opens read-only: its chats read, and nothing here writes to it.
- it('reads a database a newer build stamped, refuses every write, and writes nothing', async () => {
+ // A newer build's database: none of its chats open here, and nothing here writes to it.
+ it('refuses every chat in a database a newer build stamped, and writes nothing', async () => {
const journal = await open()
await journal.appendItem(item(0), body('a'), {
fence: 1,
@@ -121,32 +122,17 @@ describe('axis 1: the database shape', () => {
await withDatabase((db) => db.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION + 1}`))
const before = await digest(journalDatabasePath(root))
- const reopened = await open()
- expect(reopened.isReadOnly).toBe(true)
- expect(reopened.snapshot().items.map((entry) => entry.body)).toEqual([body('a')])
+ await expect(open()).rejects.toMatchObject(SAVED_BY_NEWER_ORCA)
+ // A chat the database never held is refused the same way rather than being founded.
await expect(
- reopened.appendItem(item(1), body('b'), { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE })
- ).rejects.toMatchObject({
- code: 'journal_read_only'
- })
- // A chat the database never held opens empty rather than being founded, and refuses the same way.
- const unwritten = await journals.open({
- identity: { ...IDENTITY, sessionId: 'session-2' },
- stateDirectory: root
- })
- expect(unwritten.isReadOnly).toBe(true)
- expect(unwritten.snapshot().items).toEqual([])
- await expect(
- unwritten.appendItem(item(1), body('b'), { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE })
- ).rejects.toMatchObject({
- code: 'journal_read_only'
- })
+ journals.open({ identity: { ...IDENTITY, sessionId: 'session-2' }, stateDirectory: root })
+ ).rejects.toMatchObject(SAVED_BY_NEWER_ORCA)
await journals.closeAll()
expect(await digest(journalDatabasePath(root))).toBe(before)
})
// Tables a newer schema changed read as a chat only an update opens, not as damage.
- it('refuses as read-only a chat whose tables a newer build changed', async () => {
+ it("refuses as a newer Orca's a chat whose tables a newer build changed", async () => {
const journal = await open()
await journal.appendItem(item(0), body('a'), {
fence: 1,
@@ -158,23 +144,7 @@ describe('axis 1: the database shape', () => {
db.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION + 1}`)
})
- await expect(open()).rejects.toMatchObject({ code: 'journal_read_only' })
- })
-
- it('refuses the schema escape hatch on a store latched by a newer row', async () => {
- const journal = await open()
- const epoch = journal.epoch
- const nextSeq = journal.cursor().sequence + 1
- await journal.close()
- await appendRawRow(epoch, nextSeq, futureRow(epoch, nextSeq))
-
- const reopened = await open()
- // With byte-copy quarantine gone there is nothing for `schema_unreadable` to
- // do differently, so it takes the same writable guard as every other reason.
- await expect(reopened.rollEpoch('schema_unreadable', 2)).rejects.toMatchObject({
- code: 'journal_read_only'
- })
- expect(reopened.isReadOnly).toBe(true)
+ await expect(open()).rejects.toMatchObject(SAVED_BY_NEWER_ORCA)
})
// The only older version brought forward; 1 and 2 are refused (journal-database.test.ts).
@@ -188,7 +158,6 @@ describe('axis 1: the database shape', () => {
await withDatabase((db) => db.pragma('user_version = 0'))
const reopened = await open()
- expect(reopened.isReadOnly).toBe(false)
expect(reopened.snapshot().items).toHaveLength(1)
await reopened.close()
await withDatabase((db) => {
@@ -198,7 +167,7 @@ describe('axis 1: the database shape', () => {
})
describe('axis 2: the row body shape', () => {
- it('degrades to read-only on a row from a newer build, without skipping it', async () => {
+ it("refuses a journal holding a row from a newer build as a newer Orca's, and keeps that row", async () => {
const journal = await open()
await journal.appendItem(item(0), body('a'), {
fence: 1,
@@ -209,14 +178,7 @@ describe('axis 2: the row body shape', () => {
await journal.close()
await appendRawRow(epoch, nextSeq, futureRow(epoch, nextSeq))
- const reopened = await open()
- expect(reopened.isReadOnly).toBe(true)
- await expect(
- reopened.appendItem(item(1), body('b'), { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE })
- ).rejects.toMatchObject({
- code: 'journal_read_only'
- })
- await reopened.close()
+ await expect(open()).rejects.toMatchObject(SAVED_BY_NEWER_ORCA)
// Never skipped, never deleted: the row this build cannot read is still there.
await withDatabase((db) => {
const stored = liveTestJournalRows(db, IDENTITY.sessionId).find((row) => row.seq === nextSeq)
@@ -275,7 +237,6 @@ describe('axis 2: the row body shape', () => {
)
const reopened = await open()
- expect(reopened.isReadOnly).toBe(false)
expect(reopened.snapshot().items.some((entry) => entry.itemId === '%')).toBe(true)
})
})
diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts
index 2c3b0a54e842..ca98bbe07b6d 100644
--- a/src/main/native-chat/agent-session-journal/journal-store.ts
+++ b/src/main/native-chat/agent-session-journal/journal-store.ts
@@ -67,7 +67,6 @@ import type { JournalRowWriter } from './journal-row-writer'
import type { JournalEpochController } from './journal-epoch-controller'
import { JournalWriteQueue } from './journal-write-queue'
import { createJournalStoreCollaborators } from './journal-store-collaborators'
-import { journalStoreLoadedFields } from './journal-store-open'
import type { JournalItemAppender, JournalResolvedItem } from './journal-item-appender'
import type { JournalLifecycleBatchAppender } from './journal-lifecycle-batch-appender'
import type { JournalStopMarks } from './journal-stop-marks'
@@ -81,7 +80,6 @@ export class AgentSessionJournal {
private readonly mintEpoch: () => string
private state: JournalReducerState
- private readOnly = false
private openedThrough: AgentJournalCursor = { epoch: '', sequence: 0 }
private onCommitted: (() => void) | null = null
private readonly queue: JournalWriteQueue
@@ -112,10 +110,8 @@ export class AgentSessionJournal {
owe: (work) => this.queue.owe(work),
database: () => this.database,
state: () => this.state,
- readOnly: () => this.readOnly,
- setReadOnly: (readOnly) => {
- this.readOnly = readOnly
- },
+ // A newer Orca's database: nothing it holds opens, and every write is refused.
+ readOnly: () => this.database.readOnly,
cursor: this.cursor,
adopt: (loaded) => {
this.adoptLoadedJournal(loaded)
@@ -138,10 +134,6 @@ export class AgentSessionJournal {
this.restore = collaborators.restore
}
- get isReadOnly(): boolean {
- return this.readOnly
- }
-
get epoch(): string {
return this.state.epoch
}
@@ -266,8 +258,7 @@ export class AgentSessionJournal {
readSince(cursor: AgentJournalCursor, limit?: number): JournalReadSince {
const { sessionId } = this.identity
const rowsAfter = journalRowsAfterReader(this.database.db, sessionId, this.state.epoch, limit)
- const source = { state: this.state, rowsAfter, readOnly: this.readOnly }
- return readJournalSince(source, cursor, () => this.cursor())
+ return readJournalSince({ state: this.state, rowsAfter }, cursor, () => this.cursor())
}
/** Upsert by stable identity. The revision is assigned here so a caller
@@ -377,6 +368,6 @@ export class AgentSessionJournal {
}
private adoptLoadedJournal(loaded: JournalLoad): void {
- Object.assign(this, journalStoreLoadedFields(loaded))
+ this.state = loaded.state
}
}
diff --git a/src/main/native-chat/agent-session-journal/journal-streaming-replay.test.ts b/src/main/native-chat/agent-session-journal/journal-streaming-replay.test.ts
index 73e66401c594..86ff11c54a3b 100644
--- a/src/main/native-chat/agent-session-journal/journal-streaming-replay.test.ts
+++ b/src/main/native-chat/agent-session-journal/journal-streaming-replay.test.ts
@@ -117,13 +117,13 @@ describe('streaming journal replay', () => {
expect(checkpoints.map((entry) => entry.busy)).toEqual([0, 0])
})
- it('reads past a gap to a future row, which latches read-only with no damage named', () => {
+ it('reads past a gap to a future row, which names the newer row and no damage', () => {
put(anchor())
put(revision(2))
put(revision(4))
put({ ...revision(5), v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION + 1 })
const loaded = replayJournal(opened.db, sessionId)!
- expect(loaded).toMatchObject({ readOnly: true, damage: null })
+ expect(loaded).toMatchObject({ newer: { sequence: 5 }, damage: null })
expect(loaded.state.items.get('message-1')?.revision).toBe(2)
expect(loaded.state.lastSequence).toBe(2)
const checkpoint = opened.db.pragma('wal_checkpoint(TRUNCATE)') as { busy: number }[]
@@ -137,7 +137,7 @@ describe('streaming journal replay', () => {
insertTestJournalRowJson(opened.db, sessionId, 5, '{')
const loaded = replayJournal(opened.db, sessionId)!
expect(loaded).toMatchObject({
- readOnly: false,
+ newer: null,
damage: { sequence: 3, cause: 'sequence-gap' }
})
expect(loaded.state.lastSequence).toBe(2)
@@ -148,7 +148,7 @@ describe('streaming journal replay', () => {
put(revision(3))
const loaded = replayJournal(opened.db, sessionId)!
expect(loaded).toMatchObject({
- readOnly: false,
+ newer: null,
damage: { sequence: 1, cause: 'no-epoch-row' }
})
expect(loaded.state.items.size).toBe(0)
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 9cdcc55fec2e..e56bcc92c962 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 a newer build wrote is never damage. A body of a kind this
// build does not know (or a plan subject of one) is kept and the chat stays writable; a mutation or
-// sent message of a newer kind latches the chat read-only with every row kept as it was, even
-// beside damage. Anything else that fails is damage: the chat fails to load, every row kept.
+// sent message of a newer kind fails the load as a newer Orca's chat with every row kept as it was,
+// even beside damage. Anything else that fails is damage: the chat fails to load, every row kept.
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
@@ -18,7 +18,8 @@ import {
createTrackedJournalOpener,
insertTestJournalRowJson,
liveTestJournalRows,
- openTestJournalHostDatabase
+ openTestJournalHostDatabase,
+ SAVED_BY_NEWER_ORCA
} from './journal-host-database-test-support'
const IDENTITY: AgentSessionJournalIdentity = {
@@ -110,17 +111,10 @@ const PLAN_APPROVAL = {
subject: { kind: 'plan', text: 'Step one' }
}
-async function expectReadOnlyAndKept(written: { seq: number; rowJson: string }[]) {
- const journal = await open()
- expect(journal.isReadOnly).toBe(true)
- 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()
+/** Refused as a newer Orca's chat on every open, with every row left as it was. */
+async function expectNewerAndKept(written: { seq: number; rowJson: string }[]) {
+ await expect(open()).rejects.toMatchObject(SAVED_BY_NEWER_ORCA)
+ await expect(open()).rejects.toMatchObject(SAVED_BY_NEWER_ORCA)
expect(stored()).toEqual(written)
}
@@ -128,7 +122,6 @@ async function expectReadOnlyAndKept(written: { seq: number; rowJson: string }[]
* row byte-identical. */
async function expectWritableAndKept(written: { seq: number; rowJson: string }[], itemId: string) {
const journal = await open()
- expect(journal.isReadOnly).toBe(false)
expect(journal.snapshot().items.some((entry) => entry.itemId === itemId)).toBe(true)
await journal.appendItem(item(5), { kind: 'status', text: 'taken' }, SCOPE)
await journals.closeAll()
@@ -158,7 +151,7 @@ describe("a newer build's body kind, kept and writable", () => {
describe("a newer build's row content that stays a closed set", () => {
it.each([['of a newer kind', { kind: 'pin', itemId: 'codex:thread-1:turn-1:3', revision: 2 }]])(
- 'latches read-only on a lifecycle mutation %s',
+ 'fails the load on a lifecycle mutation %s',
async (_name, mutation) => {
const { written } = await journalWith((epoch) => [
batchRow(epoch, [
@@ -171,11 +164,11 @@ describe("a newer build's row content that stays a closed set", () => {
mutation
])
])
- await expectReadOnlyAndKept(written)
+ await expectNewerAndKept(written)
}
)
- it('latches read-only on a submission whose body is a newer kind', async () => {
+ it('fails the load on a submission whose body is a newer kind', async () => {
const { written } = await journalWith((epoch) => [
{
v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION,
@@ -189,14 +182,14 @@ describe("a newer build's row content that stays a closed set", () => {
body: { kind: 'voice-note', clip: 'by a newer build' }
}
])
- await expectReadOnlyAndKept(written)
+ await expectNewerAndKept(written)
})
- it('latches read-only on a newer mutation kind that carries no item id', async () => {
+ it('fails the load on a newer mutation kind that carries no item id', async () => {
const { written } = await journalWith((epoch) => [
batchRow(epoch, [{ kind: 'turn-settle', turnId: 'turn-1', outcome: 'done' }])
])
- await expectReadOnlyAndKept(written)
+ await expectNewerAndKept(written)
})
it.each([
@@ -222,9 +215,9 @@ describe("a newer build's row content that stays a closed set", () => {
{ kind: 'pin' }
])
]
- ])('latches read-only when damage sits beside newer content in %s', async (_where, row) => {
+ ])('fails the load as newer when damage sits beside newer content in %s', async (_where, row) => {
const { written } = await journalWith((epoch) => [row(epoch)])
- await expectReadOnlyAndKept(written)
+ await expectNewerAndKept(written)
})
})
@@ -239,7 +232,6 @@ describe("a turn's context usage of a newer shape", () => {
})
])
const journal = await open()
- expect(journal.isReadOnly).toBe(false)
expect(journal.snapshot().items[2]?.body).toEqual({
kind: 'turn',
turnId: 'turn-1',
diff --git a/src/main/native-chat/agent-session-journal/journal-unknown-row-kind.test.ts b/src/main/native-chat/agent-session-journal/journal-unknown-row-kind.test.ts
index 9e78ffce0d7e..bb965817b595 100644
--- a/src/main/native-chat/agent-session-journal/journal-unknown-row-kind.test.ts
+++ b/src/main/native-chat/agent-session-journal/journal-unknown-row-kind.test.ts
@@ -1,5 +1,6 @@
-// A newer build's row kind is never damage: like a newer row version, it latches this build
-// read-only with every row kept, so the newer build still reads the whole chat after an upgrade.
+// A newer build's row kind is never damage: like a newer row version, it fails this build's load as
+// a newer Orca's chat with every row kept, so the newer build still reads the whole chat after an
+// upgrade.
// A row whose stored sequence and body disagree is damage, which fails the load with every row kept.
import { mkdtemp, rm } from 'node:fs/promises'
@@ -17,7 +18,8 @@ import {
createTrackedJournalOpener,
insertTestJournalRowJson,
liveTestJournalRows,
- openTestJournalHostDatabase
+ openTestJournalHostDatabase,
+ SAVED_BY_NEWER_ORCA
} from './journal-host-database-test-support'
import { parseJournalRow, type JournalRow } from './journal-row-schema'
@@ -74,7 +76,8 @@ function open() {
/** Closes the chat, applies `edit` to its stored rows as a newer build or a bad write would, and
* reopens it as a restarted host would. */
-async function restartAfter(edit: (put: (seq: number, json: string) => void) => void = () => {}) {
+/** Rewrites rows with every journal closed, so the caller's next open replays them. */
+async function rewriteClosed(edit: (put: (seq: number, json: string) => void) => void = () => {}) {
await journals.closeAll()
const { db } = openTestJournalHostDatabase(root)
edit((seq, json) => {
@@ -85,6 +88,10 @@ async function restartAfter(edit: (put: (seq: number, json: string) => void) =>
insertTestJournalRowJson(db, IDENTITY.sessionId, seq, json, 5_000 + seq)
})
closeTestJournalHostDatabase(root)
+}
+
+async function restartAfter(edit: (put: (seq: number, json: string) => void) => void = () => {}) {
+ await rewriteClosed(edit)
return open()
}
@@ -194,7 +201,7 @@ describe('parsing a row of a kind this build does not know', () => {
})
describe("a journal holding a newer build's row kind", () => {
- it('opens read-only with the rows before it, refuses writes, keeps every row, and reads whole once the kind is known', async () => {
+ it("fails the load as a newer Orca's chat, keeps every row, and reads whole once the kind is known", async () => {
const first = await open()
for (const ordinal of [0, 1, 2, 3]) {
await first.appendItem(item(ordinal), { kind: 'status', text: `item ${ordinal}` }, SCOPE)
@@ -204,31 +211,24 @@ describe("a journal holding a newer build's row kind", () => {
const known = stored().find((row) => row.seq === 3)?.rowJson ?? ''
const newer = JSON.stringify(newerRow(epoch, 3))
- const journal = await restartAfter((put) => put(3, newer))
- const latched = stored()
- expect(journal.isReadOnly).toBe(true)
- expect(itemIds(journal)).toEqual(['codex:thread-1:turn-1:0'])
- await expect(
- journal.appendItem(item(4), { kind: 'status', text: 'after' }, SCOPE)
- ).rejects.toMatchObject({ code: 'journal_read_only' })
-
- const reopened = await restartAfter()
- expect(reopened.isReadOnly).toBe(true)
- expect(stored()).toEqual(latched)
+ await rewriteClosed((put) => put(3, newer))
+ const refused = stored()
+ await expect(open()).rejects.toMatchObject(SAVED_BY_NEWER_ORCA)
+ await expect(open()).rejects.toMatchObject(SAVED_BY_NEWER_ORCA)
+ expect(stored()).toEqual(refused)
expect(stored().find((row) => row.seq === 3)?.rowJson).toBe(newer)
// Stand-in for the newer build after an upgrade: the same place holds a kind it reads.
const upgraded = await restartAfter((put) => put(3, known))
- expect(upgraded.isReadOnly).toBe(false)
expect(itemIds(upgraded)).toHaveLength(4)
await upgraded.appendItem(item(4), { kind: 'status', text: 'after' }, SCOPE)
expect(upgraded.cursor().sequence).toBe(6)
})
- it('stays read-only with nothing deleted when the row names another sequence than its key', async () => {
+ it("fails the load as a newer Orca's chat with nothing deleted when the row names another sequence than its key", async () => {
const epoch = await journalWithOneItem()
- const journal = await restartAfter((put) => put(3, JSON.stringify(newerRow(epoch, 9))))
- expect(journal.isReadOnly).toBe(true)
+ await rewriteClosed((put) => put(3, JSON.stringify(newerRow(epoch, 9))))
+ await expect(open()).rejects.toMatchObject(SAVED_BY_NEWER_ORCA)
expect(storedKinds()).toEqual([
[1, 'epoch'],
[2, 'item'],
diff --git a/src/main/native-chat/agent-session-journal/journal-write-guards.ts b/src/main/native-chat/agent-session-journal/journal-write-guards.ts
index 5ec2d0fbe907..8da990a4eac7 100644
--- a/src/main/native-chat/agent-session-journal/journal-write-guards.ts
+++ b/src/main/native-chat/agent-session-journal/journal-write-guards.ts
@@ -19,8 +19,8 @@ export class AgentSessionJournalError extends Error {
}
}
-/** A journal written by a newer schema is readable but never writable: this
- * host cannot represent rows it does not understand. */
+/** A newer Orca's database is never written: this host cannot represent rows it does not
+ * understand. */
export function assertJournalWritable(readOnly: boolean, sessionId: string): void {
if (readOnly) {
throw new AgentSessionJournalError(
diff --git a/src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.test.ts b/src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.test.ts
index 638a62bdf02a..15911b5e080c 100644
--- a/src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.test.ts
+++ b/src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.test.ts
@@ -136,7 +136,6 @@ describe('draft bookkeeping inside a journal append', () => {
PRIMARY KEY (session_id, message_id))`)
db.close()
const journal = await open()
- expect(journal.isReadOnly).toBe(false)
await queueAndConsume(journal, 'draft-1')
await journal.resolveDispatch({
clientMessageId: 'sub-draft-1',
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 c3910e466854..848aea9817e4 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
@@ -150,7 +150,6 @@ describe('draft rows', () => {
db.exec('DROP TABLE queued_messages')
db.close()
const journal = await open()
- expect(journal.isReadOnly).toBe(false)
const row = await queueDraft(journal, 'draft-1')
expect(row.position).toBe(1)
})
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 abdaf2b3ce5f..9dac44d12dce 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
@@ -80,23 +80,6 @@ export function readAgentSessionHistory(
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 {
- // 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)
@@ -159,7 +142,7 @@ export function readAgentSessionHydrationPage(
journal: AgentSessionJournal,
fence?: number
): AgentSessionHistoryPage {
- return { ...buildHydrationPage(journal.snapshot(), fence), ...readOnlyField(journal) }
+ return buildHydrationPage(journal.snapshot(), fence)
}
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
deleted file mode 100644
index 49fb7a35646f..000000000000
--- a/src/main/native-chat/agent-session-wire/agent-session-read-only-page.test.ts
+++ /dev/null
@@ -1,188 +0,0 @@
-// 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'
-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 `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()
- 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()
- 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 = '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 }
-
-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 batch-change kind',
- () => ({
- v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION,
- kind: 'lifecycle-batch',
- settlementId: 'settle-1',
- mutations: [{ kind: 'pin', itemId: 'x', revision: 1 }]
- })
- ]
- ])('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('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)
- for (const page of servedPages(journal)) {
- expect(page).not.toHaveProperty('readOnly')
- }
- expect(attachedClientNotice(journal)).toBeNull()
-})
diff --git a/src/main/native-chat/agent-session-wire/agent-session-subscriber-catch-up.ts b/src/main/native-chat/agent-session-wire/agent-session-subscriber-catch-up.ts
index 5ff159ce2e62..6350ba671980 100644
--- a/src/main/native-chat/agent-session-wire/agent-session-subscriber-catch-up.ts
+++ b/src/main/native-chat/agent-session-wire/agent-session-subscriber-catch-up.ts
@@ -53,7 +53,7 @@ export function deliverToSubscriber(
...(publishedActivity !== undefined ? { activity: publishedActivity } : {})
}
// Caught up, so there are no rows to read: every publish behind a commit's own delivery.
- if (!journal.isReadOnly && sameJournalCursor(subscriber.cursor, journal.cursor())) {
+ if (sameJournalCursor(subscriber.cursor, journal.cursor())) {
emitCaughtUp(port, subscriber, emitCheckpoint, shared)
return
}
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts
index 6552f8a26016..ae75f4765916 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts
@@ -2,7 +2,10 @@ import type { SubmissionRejectionFact } from '../../../shared/agent-session-fail
import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication'
import type { AgentJournalCursor } from '../../../shared/agent-session-journal-types'
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
-import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire'
+import type {
+ AgentSessionStatusSummary,
+ AgentSessionWireRefusal
+} from '../../../shared/agent-session-wire'
import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
import type { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule'
import type { AgentSessionSpawnTokenScan } from '../../runtime/agent-session-spawn-token-process-scan'
@@ -30,6 +33,8 @@ export type StructuredAgentSessionReveal = {
workspaceId: string
agent: 'claude' | 'codex'
readable: boolean
+ /** Why the journal did not open, as a read would be refused. Host-side only: never published. */
+ openRefusal?: AgentSessionWireRefusal
}
/** Which provider child: the adapter acquisition and the lease fence it writes at. */
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts
index ac1fdbb0617c..a48f0d8684b3 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts
@@ -214,9 +214,7 @@ export async function maybeQueueStructuredAgentSessionSend(
if (ctx.journal.submissions().some((entry) => entry.clientMessageId === clientMessageId)) {
return null
}
- // A newer Orca's journal takes no new draft: the immediate path refuses the send.
if (
- ctx.journal.isReadOnly ||
!shouldQueueStructuredAgentSessionSend({
journal: ctx.journal,
record: context.deps.store.getRecord(ctx.sessionId),
@@ -269,7 +267,7 @@ export class StructuredAgentSessionQueuedMessageDrain {
schedule(sessionId: string): void {
const journal = this.deps.sessions.get(sessionId)?.journal
- if (!journal || journal.isReadOnly) {
+ if (!journal) {
return
}
// Cheap pre-check so token streams do not pay a serialized step per delta.
@@ -312,7 +310,7 @@ export class StructuredAgentSessionQueuedMessageDrain {
private async step(sessionId: string): Promise {
const session = this.deps.sessions.get(sessionId)
- if (!session || session.journal.isReadOnly) {
+ if (!session) {
return
}
const journal = session.journal
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 a57719c7e446..eeaafbbaff8d 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
@@ -1,8 +1,11 @@
-// A chat journal a newer Orca wrote opens read-only here: its cards still show, and every queue
-// write (a queued send, Send-now, Delete, Resume) is refused with the words a send gets there.
+// A chat journal a newer Orca wrote does not open here: its history read and every queue write (a
+// queued send, Send-now, Delete, Resume) are refused as a newer Orca's chat, and nothing is written.
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
-import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support'
+import {
+ openTestJournalHostDatabase,
+ SAVED_BY_NEWER_ORCA
+} from '../agent-session-journal/journal-host-database-test-support'
import { JOURNAL_NEWER_SCHEMA_MESSAGE } from '../agent-session-journal/journal-open-failure'
import { HOST_TEST_SESSION } from './structured-agent-session-host-test-data'
import {
@@ -42,15 +45,11 @@ async function reopenOnNewerOrcaDatabase(close: () => Promise): Promise
Object.defineProperty(openTestJournalHostDatabase(rig.root), 'readOnly', { value: true })
}
-/** 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: true, page: { readOnly: 'written-by-newer-orca' } })
- const page = 'page' in answer ? answer.page : undefined
- return {
- cards: (page?.queuedMessages ?? []).map(({ messageId, state }) => ({ messageId, state })),
- pause: page?.queuePause ?? null
- }
+/** The history read a newer Orca's chat gets: refused, as every reader's is. */
+async function expectHistoryRefused(): Promise {
+ await expect(
+ rig.host.history({ sessionId: HOST_TEST_SESSION, direction: 'tail' })
+ ).rejects.toMatchObject(SAVED_BY_NEWER_ORCA)
}
async function twoCardsBehindWork() {
@@ -68,26 +67,22 @@ async function twoCardsBehindWork() {
}
describe("a newer Orca's journal", () => {
- it('shows the cards, and refuses a queued send, Send-now and Delete with the update words', async () => {
- const { first, second, cards } = await twoCardsBehindWork()
+ it('refuses the history read, a queued send, Send-now and Delete with the update words', async () => {
+ const { first, second } = await twoCardsBehindWork()
await reopenOnNewerOrcaDatabase(() => rig.host.close(HOST_TEST_SESSION, 'evict'))
- expect(await readOnlyQueue()).toEqual({ cards, pause: null })
+ await expectHistoryRefused()
- // The waiting cards would queue this send behind them; the journal takes no new draft.
expect(await rig.send('third', 'queue-if-active').result).toMatchObject(REFUSED_BY_NEWER_ORCA)
expect(await rig.sendNow(first)).toMatchObject(REFUSED_BY_NEWER_ORCA)
expect(await rig.deleteQueued(second)).toMatchObject(REFUSED_BY_NEWER_ORCA)
-
- expect(await readOnlyQueue()).toEqual({ cards, pause: null })
+ await expectHistoryRefused()
})
it('refuses Resume of the queue a restart paused with the update words', async () => {
- const { cards } = await twoCardsBehindWork()
+ await twoCardsBehindWork()
await reopenOnNewerOrcaDatabase(() => rig.restartHostProcess())
- expect(await readOnlyQueue()).toEqual({ cards, pause: { reason: 'restarted' } })
+ await expectHistoryRefused()
expect(await rig.resume()).toMatchObject(REFUSED_BY_NEWER_ORCA)
-
- expect(await readOnlyQueue()).toEqual({ cards, pause: { reason: 'restarted' } })
})
})
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts
index a0d3dbe7b529..6fd6bb136125 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts
@@ -44,7 +44,7 @@ export function wireStructuredAgentSessionQueuedMessages(
onJournalActivity: (sessionId: string) => {
sessions.touch(sessionId)
const journal = sessions.get(sessionId)?.journal
- if (journal && !journal.isReadOnly) {
+ if (journal) {
void adoptEndedRestartPause(sessionId, journal, context().deps.logger)
}
drain.schedule(sessionId)
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 d05a81c3c55e..a8b199932d03 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
@@ -2,7 +2,7 @@
//
// A different question from storage: the durable record decides which markers are still present;
// this decides which of those a resume may act on. The offer, the click and the pre-send check all
-// ask it; only the pre-send check is built not to skip a newer Orca's chat (see the host).
+// ask it.
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import type { AgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker'
@@ -30,8 +30,8 @@ export function createStructuredAgentSessionRestartCandidateReader(deps: {
adapter: StructuredAgentSessionAdapter
/** Whether the chat moved on since the offer was taken; see the offer withdrawal. */
movedOn: (marker: AgentSessionResumeMarker) => boolean
- /** Whether the chat is a newer Orca's here: its whole database, or its open journal. */
- readOnly: (sessionId: string) => boolean
+ /** Whether the chat was saved by a newer Orca: its whole database, or its journal's open. */
+ savedByNewerOrca: (sessionId: string) => boolean
}): StructuredAgentSessionRestartCandidateReader {
return (markers, leaseState) =>
structuredAgentSessionResumableSet({
@@ -39,7 +39,7 @@ export function createStructuredAgentSessionRestartCandidateReader(deps: {
getRecord: deps.getRecord,
supportsRecord: (record) => adapterSupportsRecord(deps.adapter, record),
movedOn: deps.movedOn,
- readOnly: deps.readOnly,
+ savedByNewerOrca: deps.savedByNewerOrca,
latestPrompt: (sessionId) =>
latestStructuredAgentSessionPrompt(
deps.sessions.get(sessionId)?.journal.snapshot().items ?? []
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 7347b22d37d3..c922810d08a4 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,9 +78,6 @@ export function createStructuredAgentSessionRestartFailureLedger(deps: {
adapter: StructuredAgentSessionAdapter
/** The predicate a retry applies to the failure's marker. */
retryable: (marker: AgentSessionResumeMarker) => boolean
- /** Whether the chat is a newer Orca's here (its whole database, or its open journal): its
- * failure is kept but not shown, since no retry can land until Orca is updated. */
- readOnly: (sessionId: string) => boolean
/** Makes the failed chats readable here, so `retryable` reads each one's journal. */
reveal: (markers: readonly AgentSessionResumeMarker[]) => Promise
logger: StructuredAgentSessionLogger
@@ -104,11 +101,7 @@ export function createStructuredAgentSessionRestartFailureLedger(deps: {
failure: AgentSessionResumeFailureRecord
): StructuredAgentSessionResumeFailure[] => {
const record = deps.getRecord(failure.marker.sessionId)
- if (
- !record ||
- !adapterSupportsRecord(deps.adapter, record) ||
- deps.readOnly(failure.marker.sessionId)
- ) {
+ if (!record || !adapterSupportsRecord(deps.adapter, record)) {
return []
}
const model = normalizeOptionalField(record.options?.model, AGENT_MODEL_MAX_LENGTH)
@@ -160,21 +153,15 @@ export function createStructuredAgentSessionRestartFailureLedger(deps: {
])
for (const outcome of outcomes) {
const resumed = outcome.outcome === 'resumed'
- // A newer Orca's refusal spends nothing: the rollback below reopens the offer for an updated
- // Orca, 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.
+ // 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.
const failure = resumed
? action.failureAfterResume(outcome.sessionId)
: outcome.reason === STRUCTURED_AGENT_SESSION_RESUME_NOT_ELIGIBLE ||
- outcome.reason === RESTART_CONTINUATION_SUPERSEDED
+ outcome.reason === RESTART_CONTINUATION_SUPERSEDED ||
+ (outcome.reason === 'agent_session_journal_unreadable' &&
+ outcome.details?.reason === 'journalWrittenByNewerOrca')
? 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
new file mode 100644
index 000000000000..3fc34803b6ab
--- /dev/null
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-newer-orca.test.ts
@@ -0,0 +1,122 @@
+import { afterEach, expect, it, vi } from 'vitest'
+import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule'
+import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types'
+import {
+ insertTestJournalRowJson,
+ liveTestJournalRows,
+ openTestJournalHostDatabase,
+ updateTestJournalRowJson
+} from '../agent-session-journal/journal-host-database-test-support'
+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 {
+ HOST_TEST_NOW as NOW,
+ 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.
+
+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 {
+ const { db } = openTestJournalHostDatabase(root)
+ const last = liveTestJournalRows(db, SESSION).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')
+ }
+ const { epoch } = parsed
+ const seq = last.seq + 1
+ insertTestJournalRowJson(
+ db,
+ SESSION,
+ seq,
+ JSON.stringify({
+ v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION,
+ kind: 'future-mark',
+ epoch,
+ seq,
+ fence: 1,
+ ts: NOW
+ }),
+ NOW
+ )
+}
+
+async function spentWithoutAWord(
+ host: Awaited>['host'],
+ root: string,
+ acquire: ReturnType
+) {
+ expect(await host.restartResume.list()).toEqual([])
+ expect((await host.restartResume.continueAfterRestart([SESSION], 'modal')).continued).toEqual([])
+ expect(acquire).not.toHaveBeenCalled()
+ expect(await host.restartResume.listFailures()).toEqual([])
+ const capsule = new AgentSessionRecoveryCapsule(root)
+ expect(await capsule.list(NOW)).toEqual([])
+ 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 () => {
+ const { host, root, acquire } = await interruptedRestart()
+ newerOrcaRow(root)
+ await spentWithoutAWord(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 () => {
+ 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)
+})
+
+// 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 () => {
+ const { host, root } = await interruptedRestart()
+ await host.restartResume.list()
+ vi.spyOn(AgentSessionJournal.prototype, 'appendSubmission').mockRejectedValue(
+ new AgentSessionJournalError('journal_read_only', 'a newer Orca wrote this journal')
+ )
+
+ const action = await host.restartResume.continueAfterRestart([SESSION], 'modal')
+
+ expect(action.continued).toMatchObject([
+ {
+ outcome: 'refused',
+ reason: 'agent_session_journal_unreadable',
+ refusal: { details: { reason: 'journalWrittenByNewerOrca' } }
+ }
+ ])
+ const capsule = new AgentSessionRecoveryCapsule(root)
+ expect(await capsule.listFailed(NOW)).toEqual([])
+ expect(await capsule.list(NOW)).toEqual([])
+ expect(await host.restartResume.listFailures()).toEqual([])
+})
+
+// What listing does with a journal that throws for another reason: a damaged chat stays offered,
+// and acting on it files the failure, which names the damage.
+it('still offers a damaged chat, and files its failure when acting on it', async () => {
+ const { host, root, acquire } = await interruptedRestart()
+ updateTestJournalRowJson(openTestJournalHostDatabase(root).db, SESSION, 1, '}{')
+
+ expect((await host.restartResume.list()).map((candidate) => candidate.sessionId)).toEqual([
+ SESSION
+ ])
+ await host.restartResume.continueAfterRestart([SESSION], 'modal')
+
+ expect(acquire).not.toHaveBeenCalled()
+ expect(await host.restartResume.listFailures()).toMatchObject([
+ {
+ sessionId: SESSION,
+ reason: 'agent_session_journal_unreadable',
+ details: { reason: 'journalCorrupt' }
+ }
+ ])
+})
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-read-only.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-read-only.test.ts
deleted file mode 100644
index 74f5757660fc..000000000000
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-read-only.test.ts
+++ /dev/null
@@ -1,165 +0,0 @@
-import { afterEach, expect, it, vi } from 'vitest'
-import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule'
-import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types'
-import {
- insertTestJournalRowJson,
- liveTestJournalRows,
- openTestJournalHostDatabase
-} from '../agent-session-journal/journal-host-database-test-support'
-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 { StructuredAgentSessionResumeAdmission } from './structured-agent-session-restart-resume-runner'
-import {
- HOST_TEST_NOW as NOW,
- HOST_TEST_SESSION as SESSION
-} from './structured-agent-session-host-test-data'
-
-// A chat whose journal this build keeps read-only (a newer Orca's) is not offered for resume and
-// not counted as failed, and its offer is kept for an updated Orca to act on.
-
-afterEach(() => vi.restoreAllMocks())
-
-function journalsReadOnly() {
- return vi.spyOn(AgentSessionJournal.prototype, 'isReadOnly', 'get').mockReturnValue(true)
-}
-
-it('offers and runs nothing for a read-only chat, keeps its offer, and offers it again once writable', async () => {
- const { host, root, acquire } = await interruptedRestart()
- const readOnly = journalsReadOnly()
-
- expect(await host.restartResume.list()).toEqual([])
- const action = await host.restartResume.continueAfterRestart([SESSION], 'modal')
- expect(action.continued).toEqual([])
- expect(acquire).not.toHaveBeenCalled()
- expect(await host.restartResume.listFailures()).toEqual([])
- expect((await new AgentSessionRecoveryCapsule(root).list(NOW)).map((m) => m.sessionId)).toEqual([
- SESSION
- ])
-
- readOnly.mockRestore()
- expect((await host.restartResume.list()).map((candidate) => candidate.sessionId)).toEqual([
- SESSION
- ])
-})
-
-it('keeps a failure already filed for a chat that is now read-only, but does not show it', async () => {
- const { host, 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 }])
-
- const readOnly = journalsReadOnly()
- expect(await host.restartResume.listFailures()).toEqual([])
- readOnly.mockRestore()
- expect(await host.restartResume.listFailures()).toMatchObject([
- { sessionId: SESSION, retryable: true }
- ])
-})
-
-/** 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 {
- const { db } = openTestJournalHostDatabase(root)
- const last = liveTestJournalRows(db, SESSION).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')
- }
- const { epoch } = parsed
- const seq = last.seq + 1
- insertTestJournalRowJson(
- db,
- SESSION,
- seq,
- JSON.stringify({
- v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION,
- kind: 'future-mark',
- epoch,
- seq,
- fence: 1,
- ts: NOW
- }),
- NOW
- )
-}
-
-async function nothingOfferedRunOrCounted(
- host: Awaited>['host'],
- root: string,
- acquire: ReturnType
-) {
- expect(await host.restartResume.list()).toEqual([])
- expect((await host.restartResume.continueAfterRestart([SESSION], 'modal')).continued).toEqual([])
- expect(acquire).not.toHaveBeenCalled()
- expect(await host.restartResume.listFailures()).toEqual([])
- const capsule = new AgentSessionRecoveryCapsule(root)
- expect((await capsule.list(NOW)).map((marker) => marker.sessionId)).toEqual([SESSION])
- expect(await capsule.listFailed(NOW)).toEqual([])
-}
-
-it('keeps the offer of a chat whose journal really is read-only, and offers, runs and counts nothing', async () => {
- const { host, root, acquire } = await interruptedRestart()
- newerOrcaRow(root)
- await nothingOfferedRunOrCounted(host, root, acquire)
-})
-
-// A newer Orca's whole database: even a chat whose journal fails to open is a newer Orca's.
-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 })
- // A table the newer schema changed: this chat's journal cannot be opened at all.
- database.db.exec('ALTER TABLE journal_sessions RENAME TO journal_sessions_newer')
- await nothingOfferedRunOrCounted(host, root, acquire)
-})
-
-// However a newer Orca's refusal reaches a send (a journal first opened when sending), it files
-// nothing and keeps the offer.
-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(
- new AgentSessionJournalError('journal_read_only', 'a newer Orca wrote this journal')
- )
-
- const action = await host.restartResume.continueAfterRestart([SESSION], 'modal')
-
- expect(action.continued).toMatchObject([
- {
- outcome: 'refused',
- reason: 'agent_session_journal_unreadable',
- refusal: { details: { reason: 'journalWrittenByNewerOrca' } }
- }
- ])
- const capsule = new AgentSessionRecoveryCapsule(root)
- expect(await capsule.listFailed(NOW)).toEqual([])
- expect((await capsule.list(NOW)).map((marker) => marker.sessionId)).toEqual([SESSION])
- expect(await host.restartResume.listFailures()).toEqual([])
-})
-
-// Read-only first seen after the action chose the chat: the check right before sending must not
-// turn it away as if the user had moved on, which would spend the offer.
-it('keeps the offer of a chat first seen read-only when sending', async () => {
- const { host, root } = await interruptedRestart()
- await host.restartResume.list()
- const database = openTestJournalHostDatabase(root)
- // After the action chose the chat, before its send-time checks.
- const admit = StructuredAgentSessionResumeAdmission.prototype.run
- vi.spyOn(StructuredAgentSessionResumeAdmission.prototype, 'run').mockImplementation(
- async function (this, ...args) {
- Object.defineProperty(database, 'readOnly', { value: true, configurable: true })
- return admit.apply(this, args)
- }
- )
- vi.spyOn(AgentSessionJournal.prototype, 'appendSubmission').mockRejectedValue(
- new AgentSessionJournalError('journal_read_only', 'a newer Orca wrote this journal')
- )
-
- await host.restartResume.continueAfterRestart([SESSION], 'modal')
-
- const capsule = new AgentSessionRecoveryCapsule(root)
- expect((await capsule.list(NOW)).map((marker) => marker.sessionId)).toEqual([SESSION])
- expect(await capsule.listFailed(NOW)).toEqual([])
- expect(await host.restartResume.listFailures()).toEqual([])
-})
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 84df6f333ecd..706a15752074 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,28 +104,23 @@ export function createStructuredAgentSessionRestartResume(
now: surfaces.now,
enqueue: enqueueRecoveryOperation
})
- // A newer Orca's chat: the whole database is a newer Orca's (so even a journal that fails to open
- // is one), or the chat's journal, which listing and acting open first, is read-only here.
- const readOnly = (sessionId: string) =>
- deps.journalDatabase.readOnly || sessions.get(sessionId)?.journal.isReadOnly === true
- const reader = (skipReadOnly: (sessionId: string) => boolean) =>
- createStructuredAgentSessionRestartCandidateReader({
- sessions,
- getRecord: deps.store.getRecord,
- adapter: deps.adapter,
- movedOn: withdrawal.movedOn,
- readOnly: skipReadOnly
- })
- const derive = reader(readOnly)
- // The check made right before sending skips nothing for read-only: turning a chat away there
- // would spend its offer. Its send is refused instead, and settling keeps the offer.
- const deriveAtSend = reader(() => false)
+ // 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.
+ 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 failures = createStructuredAgentSessionRestartFailureLedger({
...(deps.recoveryCapsule ? { capsule: deps.recoveryCapsule } : {}),
getRecord: deps.store.getRecord,
adapter: deps.adapter,
retryable: (marker) => derive([marker], 'may-be-held').candidates.length === 1,
- readOnly,
reveal: (markers) => revealMarkers(markers),
logger: deps.logger,
now: surfaces.now,
@@ -138,7 +133,12 @@ export function createStructuredAgentSessionRestartResume(
readFailedMarkers: async () => (await failures.read()).map((failure) => failure.marker),
hasSession: (sessionId) => sessions.has(sessionId),
reveal: async (sessionId) => {
- await surfaces.revealSession(sessionId).catch(() => null)
+ const revealed = await surfaces.revealSession(sessionId).catch(() => null)
+ if (revealed?.openRefusal?.details?.reason === 'journalWrittenByNewerOrca') {
+ newerOrcaChats.add(sessionId)
+ } else {
+ newerOrcaChats.delete(sessionId)
+ }
},
logger: deps.logger,
now: surfaces.now,
@@ -163,7 +163,7 @@ export function createStructuredAgentSessionRestartResume(
deps.store.getRecord(sessionId)
? structuredAgentSessionConversationFence(deps.store, sessionId)
: null,
- stillResumable: (marker) => deriveAtSend([marker], 'may-be-held').candidates.length === 1
+ stillResumable: (marker) => derive([marker], 'may-be-held').candidates.length === 1
}
/** One explicit action: reserve the offers, then continue each through `continueOne`, a few at
@@ -207,9 +207,7 @@ export function createStructuredAgentSessionRestartResume(
admission,
consumeMarker: async (sessionId) => {
const marker = markersBySession.get(sessionId)
- return (
- marker !== undefined && deriveAtSend([marker], 'may-be-held').candidates.length === 1
- )
+ return marker !== undefined && derive([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 a9a66ea43df5..5732da1e1b43 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
@@ -79,8 +79,8 @@ export type StructuredAgentSessionResumeSetInput = {
latestPrompt: (sessionId: string) => string
/** Whether the chat moved on since the offer was taken; false when its journal is not open here. */
movedOn: (marker: AgentSessionResumeMarker) => boolean
- /** Whether the chat is a newer Orca's here: its whole database, or its open journal. */
- readOnly: (sessionId: string) => boolean
+ /** Whether the chat was saved by a newer Orca: its whole database, or its journal's open. */
+ savedByNewerOrca: (sessionId: string) => boolean
/**
* Whether the lease must be free.
*
@@ -117,15 +117,12 @@ export function structuredAgentSessionResumableSet(
superseded.push(marker)
continue
}
- if (input.movedOn(marker)) {
+ // 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)) {
superseded.push(marker)
continue
}
- // Nothing can continue a chat this build cannot write. The offer is kept, not spent: an updated
- // Orca can still act on it.
- if (input.readOnly(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-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-test-harness.ts
index a2631563073a..9f00c69aca49 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-test-harness.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-test-harness.ts
@@ -136,7 +136,6 @@ export function claudeRecord(
/** The fake journal's own shape, so callers can wire `appendItem` without reaching into `unknown`.
* The code under test sees the real `AgentSessionJournal` type; tests see this. */
export type HarnessJournal = {
- isReadOnly: boolean
snapshot: () => {
items: AgentJournalRenderItem[]
submissions: AgentJournalSubmission[]
@@ -150,12 +149,10 @@ export type HarnessSession = { journal: HarnessJournal; child: { fence: number }
export function journal(
items: AgentJournalRenderItem[],
- isReadOnly = false,
submissions: AgentJournalSubmission[] = []
) {
- // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the code under test calls only isReadOnly, snapshot(), submissions() and appendItem(); a real AgentSessionJournal needs an on-disk SQLite store.
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the code under test calls only snapshot(), submissions() and appendItem(); a real AgentSessionJournal needs an on-disk SQLite store.
return {
- isReadOnly,
snapshot: () => ({ items, submissions, cursor: { epoch: EPOCH, sequence: items.length } }),
submissions: () => submissions,
appendItem: async () => undefined
@@ -219,8 +216,8 @@ export function resumableSet(input: {
markers: AgentSessionResumeMarker[]
items?: AgentJournalRenderItem[]
chain?: AgentSessionRecord['providerHandleChain']
- /** Sessions whose journal is read-only here. */
- readOnly?: string[]
+ /** Sessions a newer Orca saved. */
+ savedByNewerOrca?: string[]
}) {
return structuredAgentSessionResumableSet({
markers: input.markers,
@@ -228,6 +225,6 @@ export function resumableSet(input: {
supportsRecord: () => true,
latestPrompt: () => 'fix the auth bug',
movedOn: () => false,
- readOnly: (sessionId) => input.readOnly?.includes(sessionId) === true
+ savedByNewerOrca: (sessionId) => input.savedByNewerOrca?.includes(sessionId) === true
})
}
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts
index 2284367c14b8..1c17dc11e301 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts
@@ -8,14 +8,17 @@ import type { AgentJournalMessageItem } from '../../../shared/agent-session-jour
import type {
AgentSessionMutationEnvelope,
AgentSessionMutationResult,
- AgentSessionSendResult
+ AgentSessionSendResult,
+ AgentSessionWireRefusal
} from '../../../shared/agent-session-wire'
import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view'
import { MAX_TIMER_DELAY_MS } from '../../../shared/timer-delay'
import type { SendSettlementWaitOptions } from './structured-agent-session-send-settlement'
export type StructuredAgentSessionRestartResumeSurfaces = {
- revealSession: (sessionId: string) => Promise<{ readable: boolean }>
+ revealSession: (
+ sessionId: string
+ ) => Promise<{ readable: boolean; openRefusal?: AgentSessionWireRefusal }>
send: (input: {
envelope: AgentSessionMutationEnvelope
body: AgentJournalMessageItem
@@ -41,7 +44,7 @@ export const STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER =
/** Exactly the host members this binds. Structural, so the host satisfies it without declaring a
* dependency, and nothing outside this list is reachable from here. */
type RestartResumeHostBindings = {
- revealSession: (sessionId: string) => Promise<{ readable: boolean }>
+ revealSession: StructuredAgentSessionRestartResumeSurfaces['revealSession']
send: (
caller: { callerKey: string },
params: {
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 ff203587bad0..dd14ef1fea7c 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
@@ -288,7 +288,7 @@ describe('deriving what was working at teardown', () => {
[
SESSION,
{
- journal: journal([], false, [submission('msg-1', 'pending')]),
+ journal: journal([], [submission('msg-1', 'pending')]),
child: { fence: 1 }
}
]
@@ -311,9 +311,7 @@ describe('deriving what was working at teardown', () => {
[
SESSION,
{
- journal: journal([turnItem('turn-0', 'completed')], false, [
- submission('msg-1', 'pending')
- ]),
+ journal: journal([turnItem('turn-0', 'completed')], [submission('msg-1', 'pending')]),
child: { fence: 1 }
}
]
@@ -338,7 +336,7 @@ describe('deriving what was working at teardown', () => {
[
SESSION,
{
- journal: journal([turnItem('turn-0', 'completed')], false, [queued]),
+ journal: journal([turnItem('turn-0', 'completed')], [queued]),
child: { fence: 1 }
}
]
@@ -364,14 +362,14 @@ describe('deriving what was working at teardown', () => {
[
SESSION,
{
- journal: journal([turnItem('turn-0', 'completed')], false, [handedOver, queued]),
+ journal: journal([turnItem('turn-0', 'completed')], [handedOver, queued]),
child: { fence: 1 }
}
],
[
'session-running',
{
- journal: journal([turnItem('turn-1', 'running')], false, [queued]),
+ journal: journal([turnItem('turn-1', 'running')], [queued]),
child: { fence: 1 }
}
]
@@ -397,9 +395,7 @@ describe('deriving what was working at teardown', () => {
[
SESSION,
{
- journal: journal([turnItem('turn-1', 'running')], false, [
- submission('msg-1', 'accepted')
- ]),
+ journal: journal([turnItem('turn-1', 'running')], [submission('msg-1', 'accepted')]),
child: { fence: 1 }
}
]
@@ -456,7 +452,7 @@ describe('the resumable set', () => {
supportsRecord: () => true,
latestPrompt: () => '',
movedOn: () => false,
- readOnly: () => false
+ savedByNewerOrca: () => false
})
expect(candidates).toHaveLength(1)
@@ -472,29 +468,23 @@ describe('the resumable set', () => {
supportsRecord: () => true,
latestPrompt: () => '',
movedOn: () => false,
- readOnly: () => false
+ savedByNewerOrca: () => false
})
expect(set.candidates).toEqual([])
expect(set.superseded).toEqual([forked])
})
- // A read-only chat cannot be continued here; its offer is kept for an updated Orca, not spent.
- it('neither offers nor spends a read-only chat, and offers it once its journal is writable', () => {
+ // 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", () => {
const offer = marker()
- expect(resumableSet({ markers: [offer], readOnly: [SESSION] })).toEqual({
+ expect(resumableSet({ markers: [offer], savedByNewerOrca: [SESSION] })).toEqual({
candidates: [],
- superseded: []
+ superseded: [offer]
})
expect(resumableSet({ markers: [offer] }).candidates).toHaveLength(1)
})
- // Read-only does not keep a forked chat's offer alive: the fork still ends it.
- it('still withdraws a forked read-only chat', () => {
- const forked = marker({ providerHandleRoot: 'codex:"other-thread"' })
- expect(resumableSet({ markers: [forked], readOnly: [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-reveal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts
index 3046aa971f67..c95d8f6d7bae 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts
@@ -12,6 +12,7 @@
import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire'
import { agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals'
+import { journalOpenRefusal } from '../agent-session-journal/journal-open-failure'
import { adapterSupportsRecord } from './structured-agent-session-provider-support'
import { StructuredAgentSessionReadableRestorer } from './structured-agent-session-readable-restorer'
import { StructuredAgentSessionRestartRestoreGate } from './structured-agent-session-restart-restore-gate'
@@ -42,9 +43,9 @@ export async function revealStructuredAgentSession(
// Lease state is not consulted on purpose: this neither claims the lease nor spawns a child, so a
// contested or reconciling chat still reveals and the send that follows adjudicates it. Refusing
// here would hide the one view of a session a user needs when its ownership is in doubt.
- const readable = await openConversation(sessionId).then(
- () => true,
- () => false
+ const openRefusal = await openConversation(sessionId).then(
+ () => null,
+ (error: unknown) => journalOpenRefusal(error)
)
return {
sessionId,
@@ -52,7 +53,8 @@ export async function revealStructuredAgentSession(
// to aim the tab publication at another workspace.
workspaceId: record.location.workspaceId,
agent: record.provider,
- readable
+ readable: openRefusal === null,
+ ...(openRefusal ? { openRefusal } : {})
}
}
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts
index f95579d7750d..ee9c69bf38cb 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts
@@ -77,9 +77,6 @@ export async function rewindStructuredAgentSession(
if (conversationCommandBlocked(ctx, record, context.readChildWork(sessionId))) {
return rewindRefusal('busy')
}
- if (ctx.journal.isReadOnly) {
- return rewindRefusal('unsupported')
- }
const snapshot = ctx.journal.snapshot()
const providerKeys = new Map(
snapshot.submissions.flatMap((submission) =>
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts
index 579be82e0c96..8857bd3b34ca 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts
@@ -666,7 +666,7 @@ describe('StructuredAgentSessionStatusFeed', () => {
expect(events.at(-1)).toMatchObject({ type: 'status', session: { status: 'idle' } })
})
- it('invalidates cached status on unreadability and keeps record metadata live', async () => {
+ it('keeps record metadata live', async () => {
const journal = await openJournal()
await journal.appendItem(
USER_IDENTITY,
@@ -681,12 +681,6 @@ describe('StructuredAgentSessionStatusFeed', () => {
type: 'status',
session: { status: 'idle', model: 'second-model' }
})
- const readOnly = vi.spyOn(journal, 'isReadOnly', 'get').mockReturnValue(true)
- feed.publish(SESSION)
- expect(events.at(-1)).toMatchObject({ type: 'status', session: { status: null } })
- readOnly.mockRestore()
- feed.publish(SESSION)
- expect(events.at(-1)).toMatchObject({ type: 'status', session: { status: 'idle' } })
})
})
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts
index ffb6767a321b..9a1dc01ee3f5 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts
@@ -242,12 +242,8 @@ export class StructuredAgentSessionStatusFeed {
private retireSettledChildrenOnNewTurn(
sessionId: string,
session: StatusFeedSession,
- acceptedSendKey: string | null
+ acceptedSendKey: string
): void {
- // An unreadable journal says nothing about the user's turns: the last send read stands.
- if (acceptedSendKey === null) {
- return
- }
const seen = this.acceptedSends.has(sessionId)
const previous = this.acceptedSends.get(sessionId)
this.acceptedSends.set(sessionId, acceptedSendKey)
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-journal-projection.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-journal-projection.ts
index 04ce86d36bca..87b440ef37a3 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-journal-projection.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-journal-projection.ts
@@ -13,11 +13,9 @@ export type StructuredAgentSessionStatusState = ReturnType<
export type StructuredAgentSessionJournalProjection = {
epoch: string
sequence: number
- readOnly: boolean
fence: number | undefined
state: StructuredAgentSessionStatusState
- /** Null for an unreadable journal, which says nothing about the user's turns. */
- acceptedSendKey: string | null
+ acceptedSendKey: string
}
export class StructuredAgentSessionJournalProjections {
@@ -31,9 +29,7 @@ export class StructuredAgentSessionJournalProjections {
journal: AgentSessionJournal,
record: AgentSessionRecord | null
): StructuredAgentSessionJournalProjection {
- // An unreadable journal projects as "no turn": the chat itself shows the reset.
const cursor = journal.cursor()
- const readOnly = journal.isReadOnly
// The conversation's fence, which a child's end moves: its unanswered sends stop counting.
const fence = record?.lease.runtimeFence
let projection = this.byJournal.get(journal)
@@ -41,24 +37,20 @@ export class StructuredAgentSessionJournalProjections {
!projection ||
projection.epoch !== cursor.epoch ||
projection.sequence !== cursor.sequence ||
- projection.readOnly !== readOnly ||
projection.fence !== fence
) {
// A journalled submission bumps `lastSequence`, so the send-time working
// signal reaches the cache; the lease fence does not, hence the extra key.
- const snapshot = readOnly ? null : journal.snapshot()
+ const snapshot = journal.snapshot()
projection = {
...cursor,
- readOnly,
fence,
state: projectStructuredAgentSessionStatusState(
- snapshot?.items ?? [],
- snapshot?.submissions ?? [],
+ snapshot.items,
+ snapshot.submissions,
fence
),
- acceptedSendKey: snapshot
- ? newestAcceptedSendKey(cursor.epoch, snapshot.submissions ?? [])
- : null
+ acceptedSendKey: newestAcceptedSendKey(cursor.epoch, snapshot.submissions)
}
this.byJournal.set(journal, projection)
}
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 a2b2cf15ddc3..21a06671eac3 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
@@ -1,10 +1,13 @@
// A restored chat whose open fails keeps its tab, so a phone on a headless host still lists it and
-// its read says why, instead of the chat disappearing.
+// its read says why, instead of the chat disappearing: damaged, or saved by a newer Orca.
import { cp, rm } from 'node:fs/promises'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness'
+import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types'
+import type Database from '../../sqlite/sync-database'
import {
+ liveTestJournalRows,
openTestJournalHostDatabase,
updateTestJournalRowJson
} from '../agent-session-journal/journal-host-database-test-support'
@@ -37,8 +40,10 @@ afterEach(async () => {
)
})
-/** A restarted process over the chat's files, whose epoch row no longer parses. */
-async function relaunchWithDamage(): Promise {
+/** A restarted process over the chat's files, its epoch row rewritten by `edit`. */
+async function relaunchWith(
+ edit: (db: Database.Database) => void
+): Promise {
const before = hostTestState()
await attach()
await before.host.flushStreamedEvents(SESSION)
@@ -50,7 +55,7 @@ async function relaunchWithDamage(): Promise {
recursive: true,
filter: (source) => !source.includes('.lock')
})
- updateTestJournalRowJson(openTestJournalHostDatabase(relaunched).db, SESSION, 1, '}{')
+ edit(openTestJournalHostDatabase(relaunched).db)
const store = await openTestAgentSessionRecordStore(relaunched)
const host = new StructuredAgentSessionHost({
logger: createStructuredAgentSessionLogger(),
@@ -65,25 +70,39 @@ async function relaunchWithDamage(): Promise {
return host
}
-it('lists a restored chat that could not be opened, and its send is refused as unloadable', async () => {
- const host = await relaunchWithDamage()
- const workspaceId = hostTestState().store.getRecord(SESSION)?.location.workspaceId
+const damaged = (db: Database.Database) => updateTestJournalRowJson(db, SESSION, 1, '}{')
+const savedByNewerOrca = (db: Database.Database) => {
+ const epochRow = liveTestJournalRows(db, SESSION).find((row) => row.seq === 1)!
+ const newer = { ...JSON.parse(epochRow.rowJson), v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION + 1 }
+ updateTestJournalRowJson(db, SESSION, 1, JSON.stringify(newer))
+}
- await host.restoreReadableSessions([SESSION])
+it.each([
+ ['damaged', damaged, 'journalCorrupt'],
+ ['saved by a newer Orca', savedByNewerOrca, 'journalWrittenByNewerOrca']
+] as const)(
+ 'lists a restored chat that could not be opened (%s), and its read and send are refused with why',
+ async (_why, edit, reason) => {
+ const host = await relaunchWith(edit)
+ const workspaceId = hostTestState().store.getRecord(SESSION)?.location.workspaceId
+ const refusal = { code: 'agent_session_journal_unreadable', details: { reason } }
- expect(host.hasSession(SESSION)).toBe(false)
- expect(host.listSessionTabs()).toEqual([
- { sessionId: SESSION, workspaceId, agent: expect.any(String) }
- ])
- const body = hostTestMessage('sent to a damaged chat')
- await expect(
- host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body })
- ).resolves.toMatchObject({
- ok: false,
- refusal: { code: 'agent_session_journal_unreadable', details: { reason: 'journalCorrupt' } }
- })
+ await host.restoreReadableSessions([SESSION])
- await host.setSessionTabVisibility(SESSION, false)
- expect(host.listSessionTabs()).toEqual([])
- await host.flushAllStreamedEvents()
-})
+ expect(host.hasSession(SESSION)).toBe(false)
+ expect(host.listSessionTabs()).toEqual([
+ { sessionId: SESSION, workspaceId, agent: expect.any(String) }
+ ])
+ await expect(host.history({ sessionId: SESSION, direction: 'tail' })).rejects.toMatchObject({
+ refusal
+ })
+ const body = hostTestMessage('sent to a chat that cannot load')
+ await expect(
+ host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body })
+ ).resolves.toMatchObject({ ok: false, refusal })
+
+ await host.setSessionTabVisibility(SESSION, false)
+ expect(host.listSessionTabs()).toEqual([])
+ await host.flushAllStreamedEvents()
+ }
+)
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts
index 11647f2eefde..217360673fce 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts
@@ -178,8 +178,7 @@ export function structuredAgentSessionWorkingAtStop(input: {
now: number
}): AgentSessionResumeMarker | null {
const { sessionId, session } = input
- // A journal this host cannot read tells us nothing about what the turn was doing.
- if (!session?.child || session.journal.isReadOnly) {
+ if (!session?.child) {
return null
}
const snapshot = session.journal.snapshot()
diff --git a/src/main/runtime/rpc/methods/structured-agent-session-reveal.ts b/src/main/runtime/rpc/methods/structured-agent-session-reveal.ts
index 903f8d440afb..10b5610e3531 100644
--- a/src/main/runtime/rpc/methods/structured-agent-session-reveal.ts
+++ b/src/main/runtime/rpc/methods/structured-agent-session-reveal.ts
@@ -56,7 +56,9 @@ export const STRUCTURED_AGENT_SESSION_REVEAL_METHODS = [
agent: revealed.agent,
activate: true
})
- return { ok: true as const, ...revealed }
+ // Named fields only: the host's reasons a journal did not open stay on the host.
+ const { sessionId, workspaceId, agent, readable } = revealed
+ return { ok: true as const, sessionId, workspaceId, agent, readable }
}
})
]
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 a09e82464ba3..f896297e15e2 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
@@ -102,7 +102,6 @@ function statusFeed(): StructuredAgentSessionStatusFeed {
STATUS_SESSION,
{
journal: {
- isReadOnly: false,
cursor: () => ({ epoch: 'epoch-status', sequence: 2 }),
lastActivityAt: () => 2,
snapshot: () => ({ items: STATUS_ITEMS })
diff --git a/src/main/runtime/structured-agent-session-failure-logging.test.ts b/src/main/runtime/structured-agent-session-failure-logging.test.ts
index 9a8d9edb96f1..14efe114f903 100644
--- a/src/main/runtime/structured-agent-session-failure-logging.test.ts
+++ b/src/main/runtime/structured-agent-session-failure-logging.test.ts
@@ -209,7 +209,7 @@ describe('host collaborators log with their own scope', () => {
it('the queued-message drain logs a step that fails', async () => {
const log = recordingStructuredAgentSessionLogger()
- const journal = { isReadOnly: false, queuedMessages: { settlementOwed: () => true } }
+ const journal = { queuedMessages: { settlementOwed: () => true } }
const drain = new StructuredAgentSessionQueuedMessageDrain(
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a schedule past the owed-settlement check reads only these members.
{
diff --git a/src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx b/src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx
index 82d04d625371..8e50bce5e9f2 100644
--- a/src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx
@@ -78,32 +78,6 @@ 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 9e213f0161a6..8ab0d6d822cd 100644
--- a/src/renderer/src/components/native-chat/NativeChatApprovalCard.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatApprovalCard.tsx
@@ -19,8 +19,6 @@ 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
@@ -36,7 +34,6 @@ export function NativeChatApprovalCard({
onChoose,
onCancel,
shouldFocus = false,
- disabled = false,
onLinkClick,
allowFileUriLinks = false
}: NativeChatApprovalCardProps): React.JSX.Element {
@@ -67,7 +64,7 @@ export function NativeChatApprovalCard({
aria-label={approval.title}
tabIndex={-1}
onKeyDown={(event) => {
- if (event.key === 'Escape' && !event.nativeEvent.isComposing && onCancel && !disabled) {
+ if (event.key === 'Escape' && !event.nativeEvent.isComposing && onCancel) {
event.preventDefault()
event.stopPropagation()
onCancel()
@@ -85,10 +82,9 @@ export function NativeChatApprovalCard({
{onCancel ? (
@@ -174,7 +170,7 @@ export function NativeChatApprovalCard({
onChoose(opt.send)}
className={cn(
'rounded-md px-4 py-1.5 text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx
index f60ec36f87de..ad5ad98a62a2 100644
--- a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx
@@ -14,7 +14,6 @@ const mocks = vi.hoisted(() => ({
composerIsComposing: null as (() => boolean) | null,
attachmentIsComposing: null as (() => boolean) | null,
flushPendingAttachments: vi.fn(),
- voice: ((): { enabled: boolean; sttModel?: string } => ({ enabled: false }))(),
fieldProps: null as {
onSend?: () => void
onStop?: () => void
@@ -58,7 +57,7 @@ const mocks = vi.hoisted(() => ({
vi.mock('../../store', () => {
const state = {
dictationState: 'idle',
- settings: { voice: mocks.voice, nativeChatSessionOptions: {} },
+ settings: { voice: { enabled: false }, nativeChatSessionOptions: {} },
agentStatusByPaneKey: {},
updateSettings: vi.fn(),
clearNativeChatLaunchDraft: mocks.clearNativeChatLaunchDraft,
@@ -157,29 +156,6 @@ vi.mock('./use-native-chat-send-lifecycle', () => ({
}))
import { NativeChatComposer } from './NativeChatComposer'
-import type { NativeChatStructuredComposerTransport } from './native-chat-composer-types'
-
-/** A structured transport whose parts a test does not inspect. */
-function structuredTransport(
- overrides: Partial = {}
-): NativeChatStructuredComposerTransport {
- return {
- send: vi.fn(() => true),
- dispatchCommand: vi.fn(async () => ({ handled: false, accepted: false, error: null })),
- optionsSurface: {
- getSnapshot: () => [],
- setOption: vi.fn(),
- invokeAction: vi.fn(),
- subscribe: () => () => {}
- },
- optionSnapshot: [],
- onError: vi.fn(),
- runtime: 'local',
- sessionId: 'session-test',
- runtimeEnvironmentId: null,
- ...overrides
- }
-}
describe('NativeChatComposer', () => {
beforeEach(() => {
@@ -288,20 +264,39 @@ describe('NativeChatComposer', () => {
})
it('routes structured sends and hydrated options through the existing composer', async () => {
+ const send = vi.fn(() => true)
+ const dispatchCommand = vi.fn(async () => ({
+ handled: false,
+ accepted: false,
+ error: null
+ }))
+ const optionsSurface = {
+ getSnapshot: () => [],
+ setOption: vi.fn(),
+ invokeAction: vi.fn(),
+ subscribe: () => () => {}
+ } satisfies SessionOptionsSurface
const optionSnapshot = [{ id: 'model' }] as SessionOptionDescriptor[]
- const transport = structuredTransport({ optionSnapshot })
- const { send, dispatchCommand } = transport
render(
)
- expect(mocks.fieldProps?.sessionOptionsSurface).toBe(transport.optionsSurface)
+ expect(mocks.fieldProps?.sessionOptionsSurface).toBe(optionsSurface)
expect(mocks.fieldProps?.sessionOptionsSnapshot).toBe(optionSnapshot)
expect(mocks.fieldProps?.attachDisabled).toBe(false)
await act(async () => mocks.fieldProps?.onSend?.())
@@ -312,45 +307,6 @@ describe('NativeChatComposer', () => {
expect(mocks.setDraft).toHaveBeenCalledWith('')
})
- it("locks a structured composer's options and dictation with it, never a terminal composer's options", () => {
- mocks.voice.enabled = true
- mocks.voice.sttModel = 'model'
- const { unmount } = render(
-
- )
- expect(mocks.fieldProps).toMatchObject({
- disabled: true,
- canSend: false,
- lockReason: 'Saved by a newer Orca.',
- dictationDisabled: true,
- sessionOptionsDisabled: true
- })
- unmount()
- render(
-
- )
- expect(mocks.fieldProps).toMatchObject({
- disabled: true,
- dictationDisabled: false,
- sessionOptionsDisabled: false
- })
- mocks.voice.enabled = false
- delete mocks.voice.sttModel
- })
-
// The structured menu offers only what a pick can carry out: the host's own
// commands, plus the ones the agent itself runs from message text (Codex `/goal`).
// Listing the agent's whole TUI catalog here answered every pick with
@@ -366,7 +322,21 @@ describe('NativeChatComposer', () => {
paneKey={`tab-1:structured-${agent}`}
targetPtyId={null}
agent={agent}
- structuredTransport={structuredTransport()}
+ structuredTransport={{
+ send: vi.fn(() => true),
+ dispatchCommand: vi.fn(async () => ({ handled: false, accepted: false, error: null })),
+ optionsSurface: {
+ getSnapshot: () => [],
+ setOption: vi.fn(),
+ invokeAction: vi.fn(),
+ subscribe: () => () => {}
+ },
+ optionSnapshot: [],
+ onError: vi.fn(),
+ runtime: 'local',
+ sessionId: 'session-test',
+ runtimeEnvironmentId: null
+ }}
/>
)
@@ -387,7 +357,26 @@ describe('NativeChatComposer', () => {
paneKey="tab-1:structured"
targetPtyId={null}
agent="codex"
- structuredTransport={structuredTransport({ send, worktreeId: 'wt-1' })}
+ structuredTransport={{
+ send,
+ dispatchCommand: vi.fn(async () => ({
+ handled: false,
+ accepted: false,
+ error: null
+ })),
+ optionsSurface: {
+ getSnapshot: () => [],
+ setOption: vi.fn(),
+ invokeAction: vi.fn(),
+ subscribe: () => () => {}
+ },
+ optionSnapshot: [],
+ worktreeId: 'wt-1',
+ onError: vi.fn(),
+ runtime: 'local',
+ sessionId: 'session-test',
+ runtimeEnvironmentId: null
+ }}
/>
)
diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx
index 413cd4858b39..d9fb9f1ec2d7 100644
--- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx
@@ -56,8 +56,7 @@ const NativeChatComposerPane = forwardRef {
setCaret(el.selectionStart ?? el.value.length)
@@ -370,7 +366,6 @@ const NativeChatComposerPane = forwardRef
diff --git a/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx b/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx
index 085e21c10f2c..3012aa28cc44 100644
--- a/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx
@@ -27,7 +27,6 @@ export type NativeChatComposerActionsProps = {
onStop?: () => void
sessionOptionsSurface: SessionOptionsSurface | null
sessionOptionsSnapshot: SessionOptionDescriptor[]
- sessionOptionsDisabled?: boolean
sessionOptionsPickerRequest?: NativeChatOptionPickerRequest | null
/** Present while the composer is in goal mode; the chip calls it to leave. */
onExitGoalMode?: () => void
@@ -50,7 +49,6 @@ export function NativeChatComposerActions({
onStop,
sessionOptionsSurface,
sessionOptionsSnapshot,
- sessionOptionsDisabled,
sessionOptionsPickerRequest,
onExitGoalMode,
contextUsage
@@ -100,7 +98,6 @@ export function NativeChatComposerActions({
surface={sessionOptionsSurface}
snapshot={sessionOptionsSnapshot}
isWorking={isWorking}
- disabled={sessionOptionsDisabled}
pickerRequest={sessionOptionsPickerRequest}
/>
{contextUsage ? : null}
diff --git a/src/renderer/src/components/native-chat/NativeChatComposerField.placeholder.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposerField.placeholder.test.tsx
deleted file mode 100644
index 85eac2abb3c6..000000000000
--- a/src/renderer/src/components/native-chat/NativeChatComposerField.placeholder.test.tsx
+++ /dev/null
@@ -1,94 +0,0 @@
-// @vitest-environment happy-dom
-// The composer's placeholder as the real editor draws it: a lock with a reason shows the reason,
-// and every other state draws what it drew before.
-
-import { useRef } from 'react'
-import { cleanup, render } from '@testing-library/react'
-import { afterEach, expect, it, vi } from 'vitest'
-import { useImeEnterGestureOwnership } from '@/lib/ime-composition-keyboard-event'
-import type { NativeChatComposerInput } from './native-chat-composer-input'
-import {
- NativeChatComposerField,
- type NativeChatComposerFieldProps
-} from './NativeChatComposerField'
-
-vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
-vi.mock('./NativeChatComposerActions', () => ({ NativeChatComposerActions: () => null }))
-vi.mock('./NativeChatAutocompleteMenus', () => ({
- NativeChatMentionHint: () => null,
- NativeChatPickerMenu: () => null
-}))
-
-afterEach(cleanup)
-
-const REASON = 'Saved by a newer Orca. Update Orca to continue this chat.'
-
-type State = Pick
-
-function Field(state: State): React.JSX.Element {
- const imeEnterGesture = useImeEnterGestureOwnership()
- const textareaRef = useRef(null)
- return (
-
- )
-}
-
-/** What the empty line draws: the editor's `::before` reads this attribute. */
-function drawnPlaceholder(): string {
- return document.querySelector('p.is-editor-empty')?.getAttribute('data-placeholder') ?? ''
-}
-
-const WRITABLE: State = { disabled: false, hasPty: true, canSend: true }
-const LOCKED: State = { disabled: true, hasPty: true, canSend: false, lockReason: REASON }
-
-it('draws the lock reason while the composer is locked, and the usual words once it unlocks', () => {
- const { rerender } = render()
- expect(drawnPlaceholder()).toBe('Send a message…')
- rerender()
- expect(drawnPlaceholder()).toBe(REASON)
- rerender()
- expect(drawnPlaceholder()).toBe('Send a message…')
- rerender()
- expect(drawnPlaceholder()).toBe(REASON)
-})
-
-it('draws nothing in a composer disabled for any other reason, as before', () => {
- render()
- expect(drawnPlaceholder()).toBe('')
- cleanup()
- render()
- expect(drawnPlaceholder()).toBe('')
-})
diff --git a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx
index dd78ec4b5dd3..ccbd38afe84f 100644
--- a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx
@@ -29,7 +29,6 @@ export type NativeChatComposerFieldProps = {
disabled: boolean
hasPty: boolean
canSend: boolean
- lockReason?: string
autocomplete: ComposerAutocomplete
activeSuggestion: number
notice: string | null
@@ -59,7 +58,6 @@ export type NativeChatComposerFieldProps = {
onStop?: () => void
sessionOptionsSurface: SessionOptionsSurface | null
sessionOptionsSnapshot: SessionOptionDescriptor[]
- sessionOptionsDisabled?: boolean
contextUsage?: NativeChatContextUsageSummary | null
sessionOptionsPickerRequest?: NativeChatOptionPickerRequest | null
goalMode?: NativeChatComposerGoalMode
@@ -105,7 +103,6 @@ export function NativeChatComposerField({
disabled,
hasPty,
canSend,
- lockReason,
autocomplete,
activeSuggestion,
notice,
@@ -135,7 +132,6 @@ export function NativeChatComposerField({
onStop,
sessionOptionsSurface,
sessionOptionsSnapshot,
- sessionOptionsDisabled,
contextUsage,
sessionOptionsPickerRequest,
goalMode
@@ -265,14 +261,13 @@ export function NativeChatComposerField({
? `${pickerListboxId}-option-${Math.min(activeSuggestion, autocomplete.items.length - 1)}`
: undefined
}
- placeholderWhenDisabled={lockReason !== undefined}
placeholder={
goalMode?.active
? translate(
'components.native-chat.goal.placeholder',
'Describe your goal, define measurable outcomes for best results'
)
- : nativeChatComposerPlaceholder(hasPty, canSend, lockReason)
+ : nativeChatComposerPlaceholder(hasPty, canSend)
}
// Why: coarse-pointer min-height follows the app's touch target convention.
// Editable content grows naturally; the 8lh cap (plus
@@ -301,7 +296,6 @@ export function NativeChatComposerField({
onStop={onStop}
sessionOptionsSurface={sessionOptionsSurface}
sessionOptionsSnapshot={sessionOptionsSnapshot}
- sessionOptionsDisabled={sessionOptionsDisabled}
contextUsage={contextUsage}
sessionOptionsPickerRequest={sessionOptionsPickerRequest}
onExitGoalMode={goalMode?.active ? goalMode.exit : undefined}
diff --git a/src/renderer/src/components/native-chat/NativeChatPromptEditor.tsx b/src/renderer/src/components/native-chat/NativeChatPromptEditor.tsx
index bc736a7bd92b..ceb812e6291c 100644
--- a/src/renderer/src/components/native-chat/NativeChatPromptEditor.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatPromptEditor.tsx
@@ -29,8 +29,6 @@ type Props = Omit, 'onChange' | 'onSelect'> & {
initialValue: string
disabled: boolean
placeholder: string
- /** Draws the placeholder while disabled too: it then says why the editor is locked. */
- placeholderWhenDisabled?: boolean
onChange: (input: NativeChatComposerInput) => void
onSelect: (input: NativeChatComposerInput) => void
}
@@ -41,7 +39,6 @@ export function NativeChatPromptEditor({
initialValue,
disabled,
placeholder,
- placeholderWhenDisabled = false,
onChange,
onSelect,
className,
@@ -49,7 +46,6 @@ export function NativeChatPromptEditor({
}: Props): React.JSX.Element {
const placeholderRef = useRef(placeholder)
placeholderRef.current = placeholder
- const placeholderWhenDisabledRef = useRef(placeholderWhenDisabled)
const editor = useEditor(
{
extensions: [
@@ -72,12 +68,7 @@ export function NativeChatPromptEditor({
trailingNode: false
}),
NativeChatSkill,
- // Every other disabled editor keeps drawing no placeholder, as the default option would.
- Placeholder.configure({
- showOnlyWhenEditable: false,
- placeholder: ({ editor: current }) =>
- current.isEditable || placeholderWhenDisabledRef.current ? placeholderRef.current : ''
- })
+ Placeholder.configure({ placeholder: () => placeholderRef.current })
],
content:
(scopeKey && readNativeChatDraftDocument(scopeKey, initialValue)) ||
@@ -136,10 +127,8 @@ export function NativeChatPromptEditor({
)
useLayoutEffect(() => {
- placeholderWhenDisabledRef.current = placeholderWhenDisabled
- // Also redraws the placeholder, which reads the ref.
editor?.setEditable(!disabled, false)
- }, [disabled, editor, placeholderWhenDisabled])
+ }, [disabled, editor])
const input = useMemo(
() =>
diff --git a/src/renderer/src/components/native-chat/NativeChatQuestionCard.test.tsx b/src/renderer/src/components/native-chat/NativeChatQuestionCard.test.tsx
index 54969c43c83a..189815f24786 100644
--- a/src/renderer/src/components/native-chat/NativeChatQuestionCard.test.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatQuestionCard.test.tsx
@@ -96,66 +96,6 @@ 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(() => {
- root.render(
- {}}
- disabled
- />
- )
- })
- expect(container.textContent).toContain('Do you prefer tabs or spaces?')
- expect(container.textContent).not.toContain('Sending')
- const buttons = [...container.querySelectorAll('button')]
- expect(buttons.length).toBeGreaterThan(0)
- expect(buttons.every((button) => button.disabled)).toBe(true)
- expect(container.querySelector('input')?.disabled).toBe(true)
- clickOption('Spaces')
- expect(optionPressed('Spaces')).toBe('false')
- })
-
it('delivers the SECOND option as index 1, not the default (STA-1860)', () => {
const onAnswer = vi.fn()
render(tabsOrSpaces, onAnswer)
diff --git a/src/renderer/src/components/native-chat/NativeChatQuestionCard.tsx b/src/renderer/src/components/native-chat/NativeChatQuestionCard.tsx
index ae46cabfd0f5..e26131d96f09 100644
--- a/src/renderer/src/components/native-chat/NativeChatQuestionCard.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatQuestionCard.tsx
@@ -8,8 +8,6 @@ export type NativeChatQuestionCardProps = {
prompt: AskPrompt
/** Whether the snapshotted answer is still being delivered to the agent. */
isSubmitting?: boolean
- /** Shown but not answerable: the host refuses every answer. */
- disabled?: boolean
/** Deliver the chosen answer (per-question option indices + free text). */
onAnswer: (selections: AskAnswerSelection[]) => void
allowOther?: boolean | readonly boolean[]
@@ -35,13 +33,11 @@ const TYPED_ANSWER = -1
export function NativeChatQuestionCard({
prompt,
isSubmitting = false,
- disabled = false,
onAnswer,
allowOther = true,
onCancel,
answerInputRef
}: NativeChatQuestionCardProps): React.JSX.Element {
- const locked = isSubmitting || disabled
const [index, setIndex] = useState(0)
// Keep option identity by index: labels are display text and are not guaranteed
// unique, while Claude's selector commits the numbered row (STA-1860).
@@ -169,7 +165,6 @@ export function NativeChatQuestionCard({
setIndex(i)}
className={cn(
@@ -203,10 +198,9 @@ export function NativeChatQuestionCard({
@@ -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({
)}
confirm()}
className={cn(
'shrink-0 whitespace-nowrap rounded-md px-3 py-1 text-xs font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-default disabled:opacity-50',
diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx
index 274485be6bcc..29432e643304 100644
--- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx
@@ -106,7 +106,6 @@ export function queuedMessageCardSendNow(card: QueuedMessageCard): {
export function NativeChatQueuedMessageCard({
card,
showsSteerShortcut,
- disabled = false,
onSteer,
onDelete,
onEdit,
@@ -115,8 +114,6 @@ export function NativeChatQueuedMessageCard({
card: QueuedMessageCard
/** Only the newest card answers Cmd/Ctrl+Enter; only it may show the chord. */
showsSteerShortcut: boolean
- /** The host refuses every write: Send now, Delete and Edit cannot land. */
- disabled?: boolean
onSteer: () => void
onDelete: () => void
onEdit: () => void
@@ -155,7 +152,7 @@ export function NativeChatQueuedMessageCard({
- 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
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)
})
})