fix(native-chat): separate bounded journal key forms

This commit is contained in:
Brennan Benson
2026-08-28 01:36:33 -07:00
parent 3887fac2d3
commit 2d8a4a08bd
4 changed files with 164 additions and 16 deletions
@@ -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
@@ -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
])
})
})
@@ -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', () => {
+74 -15
View File
@@ -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<T extends AgentJournalItemIdentity>(
identity: T,
key: string,
preserveExactKey: boolean
): T {
if (preserveExactKey) {
Object.defineProperty(identity, PARSED_JOURNAL_ITEM_KEY, { value: key })
}
return identity
}