diff --git a/src/main/native-chat/agent-session-journal/journal-item-identity.test.ts b/src/main/native-chat/agent-session-journal/journal-item-identity.test.ts index ea0dfb5cba9..0e05fc3fc93 100644 --- a/src/main/native-chat/agent-session-journal/journal-item-identity.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-item-identity.test.ts @@ -147,6 +147,34 @@ describe('key encoding', () => { }) }) +describe('bounded component domain separation', () => { + const oversizedTurnId = 'a'.repeat(MAX_JOURNAL_KEY_COMPONENT_CHARS + 1) + const digestFormMimic = boundJournalKeyComponent(oversizedTurnId) + const keyFor = (turnId: string) => + agentJournalItemKey({ provider: 'codex', threadId: THREAD, turnId, ordinal: 0 }) + + it('separates an oversized component from the raw string matching its digest form', () => { + const oversizedKey = keyFor(oversizedTurnId) + expect(oversizedKey).toBe(`codex:${THREAD}:${digestFormMimic}:0`) + expect(oversizedKey).not.toBe(keyFor(digestFormMimic)) + }) + + it('keeps both persisted key spellings stable through parse and re-key', () => { + for (const turnId of [oversizedTurnId, digestFormMimic]) { + const key = keyFor(turnId) + const parsed = parseAgentJournalItemKey(key) + expect(parsed).not.toBeNull() + expect(agentJournalItemKey(parsed as AgentJournalItemIdentity)).toBe(key) + } + expect(parseAgentJournalItemKey(keyFor(digestFormMimic))).toEqual({ + provider: 'codex', + threadId: THREAD, + turnId: digestFormMimic, + ordinal: 0 + }) + }) +}) + describe('oversized identity bounding on Unicode boundaries', () => { // 39 UTF-16 units of ASCII put the astral character's surrogate pair across // the 40-unit diagnostic-head cut. Pre-fix the head ended in a lone high diff --git a/src/main/native-chat/agent-session-journal/journal-reducer.test.ts b/src/main/native-chat/agent-session-journal/journal-reducer.test.ts index cab7c688e1c..832b25097b5 100644 --- a/src/main/native-chat/agent-session-journal/journal-reducer.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-reducer.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest' -import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { + agentJournalItemKey, + agentJournalSubmissionKey, + boundJournalKeyComponent, + MAX_JOURNAL_KEY_COMPONENT_CHARS +} from '../../../shared/agent-session-journal-item-key' import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation' import { @@ -418,3 +423,24 @@ describe('malformed persisted item keys', () => { expect(renderJournalState(state).items[0]?.itemId).toBe('%') }) }) + +describe('bounded item-key collisions', () => { + it('keeps an oversized turn and its raw digest-form mimic as separate items', () => { + const oversizedTurnId = 'a'.repeat(MAX_JOURNAL_KEY_COMPONENT_CHARS + 1) + const digestFormMimic = boundJournalKeyComponent(oversizedTurnId) + const keyFor = (turnId: string) => + agentJournalItemKey({ provider: 'codex', threadId: 'thread-1', turnId, ordinal: 0 }) + const oversizedKey = keyFor(oversizedTurnId) + const mimicKey = keyFor(digestFormMimic) + + const state = fold([ + { kind: 'item', itemId: oversizedKey, revision: 1, body: text('oversized'), ...base(1) }, + { kind: 'item', itemId: mimicKey, revision: 1, body: text('mimic'), ...base(2) } + ]) + + expect(renderJournalState(state).items.map((item) => item.itemId)).toEqual([ + oversizedKey, + mimicKey + ]) + }) +}) 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 42ace58d1cc..6d850b64193 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 @@ -7,6 +7,10 @@ import type { AgentJournalItemIdentity, AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' +import { + boundJournalKeyComponent, + MAX_JOURNAL_KEY_COMPONENT_CHARS +} from '../../../shared/agent-session-journal-item-key' import { readJournalBlob } from './journal-blob-store' import { JOURNAL_LOG_FILE, JOURNAL_SNAPSHOT_FILE } from './journal-log-file' import { loadJournal } from './journal-open' @@ -91,6 +95,37 @@ describe('sequences', () => { expect(journal.snapshot().items).toHaveLength(1) expect(journal.snapshot().items[0]?.revision).toBe(3) }) + + it('preserves an oversized identity and its raw digest-form mimic across reopen', async () => { + const oversizedTurnId = 'a'.repeat(MAX_JOURNAL_KEY_COMPONENT_CHARS + 1) + const digestFormMimic = boundJournalKeyComponent(oversizedTurnId) + const identityFor = (turnId: string): AgentJournalItemIdentity => ({ + provider: 'codex', + threadId: 'thread-1', + turnId, + ordinal: 0 + }) + const oversizedIdentity = identityFor(oversizedTurnId) + const mimicIdentity = identityFor(digestFormMimic) + const journal = await open() + + const oversized = await journal.appendItem(oversizedIdentity, body('oversized'), { fence: 1 }) + const mimic = await journal.appendItem(mimicIdentity, body('mimic'), { fence: 1 }) + expect(oversized.itemId).not.toBe(mimic.itemId) + expect([oversized.revision, mimic.revision]).toEqual([1, 1]) + + const reopened = await open() + expect(reopened.snapshot().items.map((entry) => entry.body)).toEqual([ + body('oversized'), + body('mimic') + ]) + + await reopened.appendTombstone(oversizedIdentity, { fence: 1 }) + const afterTombstoneReopen = await open() + expect(afterTombstoneReopen.snapshot().items.map((entry) => entry.body)).toEqual([ + body('mimic') + ]) + }) }) describe('fences', () => { diff --git a/src/shared/agent-session-journal-item-key.ts b/src/shared/agent-session-journal-item-key.ts index e53d3c5f15e..4fbb555ebf5 100644 --- a/src/shared/agent-session-journal-item-key.ts +++ b/src/shared/agent-session-journal-item-key.ts @@ -7,6 +7,13 @@ import type { AgentJournalItemIdentity } from './agent-session-journal-types' const KEY_DELIMITER = ':' +const VERBATIM_BOUNDED_COMPONENT_TAG = '%FF' +const BOUNDED_COMPONENT_PATTERN = /^[\s\S]{0,40}~orca-oversized~(?:[1-9]\d*)~[0-9a-f]{16}$/ +const PARSED_JOURNAL_ITEM_KEY = Symbol('parsedJournalItemKey') + +type ParsedJournalItemIdentity = AgentJournalItemIdentity & { + readonly [PARSED_JOURNAL_ITEM_KEY]?: string +} /** Longest raw component a key may embed. Real provider ids are tens of bytes; * anything larger would push the composed key past wire page budgets, so it @@ -92,7 +99,17 @@ function fnv1a32(value: string, seed: number): number { } function encodePart(value: string | number): string { - return encodeURIComponent(boundJournalKeyComponent(String(value))) + const raw = String(value) + const bounded = boundJournalKeyComponent(raw) + const encoded = encodeURIComponent(bounded) + // `%FF` is not valid UTF-8 and cannot be emitted by encodeURIComponent. + return raw === bounded && isBoundedComponentRepresentation(raw) + ? `${VERBATIM_BOUNDED_COMPONENT_TAG}${encoded}` + : encoded +} + +function isBoundedComponentRepresentation(value: string): boolean { + return BOUNDED_COMPONENT_PATTERN.test(value) } /** @@ -106,6 +123,11 @@ function encodePart(value: string | number): string { * plus the uuid. Text never participates. */ export function agentJournalItemKey(identity: AgentJournalItemIdentity): string { + // Parsed pre-tag digest keys must keep addressing their persisted revision chain. + const parsedKey = (identity as ParsedJournalItemIdentity)[PARSED_JOURNAL_ITEM_KEY] + if (parsedKey !== undefined) { + return parsedKey + } if (identity.provider === 'codex') { return [ 'codex', @@ -136,40 +158,77 @@ export function agentJournalSubmissionKey(clientMessageId: string): string { /** * Inverse of {@link agentJournalItemKey}. Clients hold item KEYS, but an upsert * needs the identity behind one — answering an approval re-appends the same - * item at the next revision. Every component is percent-encoded, and - * `encodeURIComponent` escapes the delimiter, so the split is unambiguous. + * item at the next revision. Components are percent-encoded; raw strings that + * imitate a bounded component carry a reserved encoded-domain tag. */ export function parseAgentJournalItemKey(key: string): AgentJournalItemIdentity | null { // Persisted keys can be corrupted: a malformed percent sequence must fail // the parse, never throw through journal replay or open. const parts: string[] = [] + let preserveExactKey = false for (const part of key.split(KEY_DELIMITER)) { - try { - parts.push(decodeURIComponent(part)) - } catch { + const decoded = decodePart(part) + if (!decoded) { return null } + parts.push(decoded.value) + preserveExactKey ||= decoded.tagged || isBoundedComponentRepresentation(decoded.value) } const [provider, ...rest] = parts if (provider === 'codex' && rest.length === 3) { const ordinal = Number(rest[2]) return Number.isSafeInteger(ordinal) && ordinal >= 0 - ? { provider, threadId: rest[0] as string, turnId: rest[1] as string, ordinal } + ? parsedIdentity( + { provider, threadId: rest[0] as string, turnId: rest[1] as string, ordinal }, + key, + preserveExactKey + ) : null } if (provider === 'claude' && rest.length === 2) { - return { provider, sessionId: rest[0] as string, uuid: rest[1] as string } + return parsedIdentity( + { provider, sessionId: rest[0] as string, uuid: rest[1] as string }, + key, + preserveExactKey + ) } if (provider === 'orca' && rest.length === 1) { - return { provider, clientMessageId: rest[0] as string } + return parsedIdentity({ provider, clientMessageId: rest[0] as string }, key, preserveExactKey) } if (provider === 'legacy' && rest.length === 3) { - return { - provider, - agent: rest[0] as string, - sessionId: rest[1] as string, - recordId: rest[2] as string - } + return parsedIdentity( + { + provider, + agent: rest[0] as string, + sessionId: rest[1] as string, + recordId: rest[2] as string + }, + key, + preserveExactKey + ) } return null } + +function decodePart(part: string): { value: string; tagged: boolean } | null { + const tagged = part.startsWith(VERBATIM_BOUNDED_COMPONENT_TAG) + try { + const value = decodeURIComponent( + tagged ? part.slice(VERBATIM_BOUNDED_COMPONENT_TAG.length) : part + ) + return !tagged || isBoundedComponentRepresentation(value) ? { value, tagged } : null + } catch { + return null + } +} + +function parsedIdentity( + identity: T, + key: string, + preserveExactKey: boolean +): T { + if (preserveExactKey) { + Object.defineProperty(identity, PARSED_JOURNAL_ITEM_KEY, { value: key }) + } + return identity +}