From e230f339615d28f42b4452034e04327777787e8a Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sat, 26 Sep 2026 17:25:34 -0700 Subject: [PATCH 01/25] docs(plan): control history keeps a bounded window (#1274) Co-Authored-By: Claude Opus 5.5 --- .../2026-09-26-control-history-retention.md | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 docs/plans/2026-09-26-control-history-retention.md diff --git a/docs/plans/2026-09-26-control-history-retention.md b/docs/plans/2026-09-26-control-history-retention.md new file mode 100644 index 000000000..972d2550f --- /dev/null +++ b/docs/plans/2026-09-26-control-history-retention.md @@ -0,0 +1,35 @@ +# Control history keeps a bounded window (#1274) + +## Problem +`FileControlHistory` appends every control call (external control, MCP, application tasks) to `control-history/events.jsonl`, with each prompt and result as a `payloads/.json` file. Nothing is ever removed, and `open()` loads the whole journal into an in-memory array that every `history.events()` call copies. + +## Evidence (owner's store, 2026-09-27) +- 129 MB on disk after 22 days: `events.jsonl` has 6,089 rows over 1,894 calls; `payloads/` holds 3,499 files for 3,457 referenced digests (42 orphans from failed appends). +- 105 MB of the payloads are `transcripts.page` results, which are read-only and unkeyed. `mcp.tools/list` adds 7 MB, `mcp.tools/call` 2.8 MB, and `agents.read` 1.5 MB. +- Keyed calls, the executor's dedupe ledger (#1240), are small: 815 rows, 290 `received`. Their keys come from `dispatch.configure`, `commands.run`, `operations.start/finish`, `agents.prompt`, and others. +- Every call in the journal has a `result` row (0 in flight). +- Simulated retention for unkeyed finished calls: 7 days keeps 507 calls (1,910 rows) and 17 MB of payloads; 14 days keeps 881 calls and 48 MB; 30 days keeps everything. + +## The boundary this must not break +The journal is the executor's idempotency ledger (see the #1240 class comment and `docs/plans/2026-09-25-control-history-recovery.md`). A keyed retry must find its `received` row and stored result forever, because request keys have no lifetime in the contract. Retention may therefore only remove calls that can never be looked up for dedupe. + +## Decisions (defaults) +1. **Prune whole calls, never single rows.** A call is removed only when ALL of these hold: + - no row of the call carries a `requestKey`, so it is not dedupe evidence; + - it has a `result` row, so it is not in flight (`history.read` would otherwise show a call lost mid-way); + - no kept row names it as `reusedCallId`; + - its newest row is older than the retention window. + Removing whole calls keeps the per-call key consistency that `analyze()` checks, so a pruned ledger reloads as clean. +2. **Window: 7 days (UNCONFIRMED default).** On the owner's rate this is about 20–35 MB steady state. `history.read` and `history.list` answer for the last week; an agent inspecting what it did is interested in hours, not weeks. The window is a constructor option so a product change is one line. +3. **Pruning runs on load, after recovery,** as one atomic rewrite of `events.jsonl` (temp file, fsync, rename, directory fsync, the same `writeAtomic` recovery uses), with sequences renumbered (process-local cursors, as in recovery). The app restarts often (updates, relaunch), and load is the one point where no append is in flight. A process that runs for weeks keeps growing until its next launch; that residual is stated. +4. **Payload GC after the rewrite.** Delete every `payloads/*.json` not referenced by a kept row, which also removes orphans left by failed appends. This runs only after the journal rewrite is durable, so a crash in between leaves extra files, never a row pointing at a missing payload. A digest shared by a pruned row and a kept row stays. +5. **`history.list` drops "never silently truncated".** It becomes a stated window: "keyed calls are kept; other finished calls are kept for 7 days". +6. **A damaged ledger is not pruned in the same launch.** When recovery ran, the rewritten ledger is still under an unaccepted block; pruning is skipped so the operator reconciles what they saw. The next clean launch prunes. + +## Tests (fail-first, real rows) +A fixture of real journal rows, recorded from the owner's store: ids, timestamps, callers, capability ids and digests only (no prompts, per the #1240 plan). It covers old unkeyed calls, old keyed calls, a reuse pair, and recent calls. Payload files are written by the test under the recorded digests. +- An old unkeyed finished call is pruned, with its payloads, and orphan payloads are removed. Old keyed calls, the calls they reuse, and recent calls are kept, byte-identical apart from renumbered sequences. +- After pruning, a keyed retry of an old key still replays its stored result and never re-dispatches (executor round-trip). +- A call without a `result` row is kept however old it is. +- A payload shared by a pruned and a kept row survives. +- A load that ran recovery does not prune. From 436dc4ac13111c2f7c831b0262470ed8d47a785b Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sat, 26 Sep 2026 17:32:47 -0700 Subject: [PATCH 02/25] fix(control): control history prunes old unkeyed calls and their payloads on load (#1274) The journal and payloads grew forever (129 MB after 22 days) and lived whole in memory. On load, finished calls with no request key, not reused, and older than 7 days are dropped in one atomic rewrite, then unreferenced payloads are collected. Keyed calls (the dedupe ledger) are kept forever. Co-Authored-By: Claude Opus 5.5 --- .../history/FileControlHistory.test.ts | 110 +++++- .../control/history/FileControlHistory.ts | 75 ++++- src/main/control/history/control.ts | 9 +- .../retention-rows-2026-09-27.json | 317 ++++++++++++++++++ 4 files changed, 504 insertions(+), 7 deletions(-) create mode 100644 testing/fixtures/control-history/retention-rows-2026-09-27.json diff --git a/src/main/control/history/FileControlHistory.test.ts b/src/main/control/history/FileControlHistory.test.ts index 79934ab5e..2ffa27026 100644 --- a/src/main/control/history/FileControlHistory.test.ts +++ b/src/main/control/history/FileControlHistory.test.ts @@ -9,12 +9,19 @@ import { defineCapability, type ControlHistory, type ControlResult } from '@cont import { FileControlHistory } from './FileControlHistory' import { historyCapabilities } from './control' +// The recorded rows in real-rows-2026-09.json were written on 2026-09-05. The +// history's retention clock (#1274) is pinned there, so a recorded unkeyed +// call is "recent" as it was when recorded, not pruned for being weeks old by +// the wall clock. Rows these tests append are stamped with the wall clock, +// which is later still, so they are recent too. Retention itself is tested +// against its own clock at the end of the file. +const RECORDED_AT = () => new Date('2026-09-05T09:00:00.000Z') const directories: string[] = [] afterEach(async () => { await Promise.all(directories.splice(0).map(directory => rm(directory, { recursive: true, force: true }))) }) async function setup() { const directory = await mkdtemp(join(tmpdir(), 'ac-control-history-')) directories.push(directory) - return { directory, history: new FileControlHistory(directory) } + return { directory, history: new FileControlHistory(directory, { now: RECORDED_AT }) } } const caller = { kind: 'external' as const, id: 'trial-client' } function executor(history: ControlHistory, handler: () => Promise = async () => 'done') { @@ -150,7 +157,7 @@ async function seeded() { type Recovery = { kind: string; quarantinePath: string; sha256: string; keyedCallsBlocked: boolean } function reopen(directory: string) { const reports: Recovery[] = [] - return { history: new FileControlHistory(directory, { onRecovered: report => reports.push(report as Recovery) }), reports } + return { history: new FileControlHistory(directory, { now: RECORDED_AT, onRecovered: report => reports.push(report as Recovery) }), reports } } // The real keyed row as its own external caller would retry it. const realCaller = { kind: 'external' as const, id: realRows.keyedReceived.caller.replace(/^external:/, '') } @@ -458,3 +465,102 @@ describe('damaged history recovery (#1240) keeps request keys idempotent', () => await access(path) }) }) + +// #1274: the journal and its payloads grew forever (129 MB after 22 days on +// the owner's machine, 105 MB of it read-only transcripts.page results) and +// the whole journal lived in memory. Retention may only drop calls that can +// never be looked up for dedupe (steering q12): unkeyed, finished, not reused. +const retentionRows = JSON.parse(await readFile(join(import.meta.dirname, + '../../../../testing/fixtures/control-history/retention-rows-2026-09-27.json'), 'utf8')) as { + rows: Array<{ sequence: number; callId: string; kind: string; payload?: string; requestKey?: string }> +} +const RETENTION_NOW = new Date('2026-09-27T12:00:00.000Z') +const OLD_UNKEYED = ['95caa49c-eb60-44e2-9b62-938ce2243d11', '5ea10842-a1c8-463c-aee9-5d0e238c56e1', '37763f2f-c479-49b7-8d79-4c990bc0784c'] +const KEPT = ['95c97fce-bca0-4bc7-9901-90b45d988d1c', '13e43d53-5120-41ae-88d7-820dc9088728', 'a346f752-9eec-4b37-b7df-145dfaf1aaf5', + 'e8a21d19-b139-4012-8e09-6139b5643bb5', '2ebe82c0-c877-4bb6-9877-56ded38d4739'] +// The old unkeyed externalControl.status call's `received` digest is also the +// keyed app.windowFocus call's; pruning the first must not take it away. +const SHARED_DIGEST = 'e5624e8c0ef7518948b17f88486be4658cdb3ba0e9c92a02aea6ad365bb92fd1' +const ORPHAN_DIGEST = 'f'.repeat(64) + +describe('control history retention (#1274)', () => { + async function seededJournal(rows = retentionRows.rows) { + const { directory } = await setup() + await writeFile(join(directory, 'events.jsonl'), rows.map((row, index) => `${JSON.stringify({ ...row, sequence: index + 1 })}\n`).join(''), { mode: 0o600 }) + // Payload CONTENTS are not recorded (they hold prompts and results); + // retention decides by the digest in the row, so a placeholder body is + // enough. An orphan, as a failed append leaves, sits beside them. + const { mkdir } = await import('node:fs/promises') + await mkdir(join(directory, 'payloads'), { recursive: true }) + for (const digest of new Set([...rows.map(row => row.payload!).filter(Boolean), ORPHAN_DIGEST])) { + await writeFile(join(directory, 'payloads', `${digest}.json`), '{}') + } + return directory + } + const open = (directory: string, onRecovered?: () => void) => + new FileControlHistory(directory, { now: () => RETENTION_NOW, onRecovered }) + + it('drops old unkeyed finished calls and their payloads, and keeps every keyed, reused and recent call', async () => { + const directory = await seededJournal() + const events = await open(directory).events() + expect([...new Set(events.map(event => event.callId))].sort()).toEqual([...KEPT].sort()) + // Kept rows are the recorded rows, in order, with only sequences renumbered. + const expected = retentionRows.rows.filter(row => KEPT.includes(row.callId)) + expect(events.map(({ sequence: _s, ...rest }) => rest)).toEqual(expected.map(({ sequence: _s, ...rest }) => rest)) + expect(events.map(event => event.sequence)).toEqual(expected.map((_, index) => index + 1)) + const payloads = new Set(await readdir(join(directory, 'payloads'))) + const keptDigests = new Set(expected.map(row => row.payload!).filter(Boolean)) + expect(payloads).toEqual(new Set([...keptDigests].map(digest => `${digest}.json`))) + expect(payloads.has(`${SHARED_DIGEST}.json`)).toBe(true) + expect(payloads.has(`${ORPHAN_DIGEST}.json`)).toBe(false) + // The rewrite is durable and clean: a second launch prunes nothing and + // reports no recovery. + const reports: unknown[] = [] + expect(await open(directory, () => reports.push(1)).events()).toEqual(events) + expect(reports).toEqual([]) + }) + + it('keeps a call with no result row however old it is', async () => { + const rows = retentionRows.rows.filter(row => !(row.callId === OLD_UNKEYED[2] && row.kind === 'result')) + const directory = await seededJournal(rows) + const events = await open(directory).events() + expect(events.some(event => event.callId === OLD_UNKEYED[2])).toBe(true) + expect(events.some(event => event.callId === OLD_UNKEYED[0])).toBe(false) + }) + + it('does not prune in a launch that had to recover the journal', async () => { + const directory = await seededJournal() + await appendFile(join(directory, 'events.jsonl'), '{"sequence":31,"kind":"resu') + const reports: unknown[] = [] + const events = await open(directory, () => reports.push(1)).events() + expect(reports).toHaveLength(1) + expect(events.some(event => event.callId === OLD_UNKEYED[0])).toBe(true) + }) + + it('still replays an old keyed call after its unkeyed neighbours were pruned', async () => { + const { directory } = await setup() + const then = new Date('2026-09-01T00:00:00.000Z') + let effects = 0 + const at = (clock: () => Date, history: ControlHistory) => { + const registry = createControlRegistry() + registry.register({ kind: 'main', generation: 'trial' }, [defineCapability({ + id: 'trial.act', title: 'Harmless trial', description: 'Exercise durable admission', + execution: 'main', effect: 'mutation', input: z.object({ text: z.string() }), output: z.unknown(), + handler: async () => { effects++; return 'sent' }, + })]) + return createControlExecutor({ history, instanceId: randomUUID(), id: randomUUID, now: () => clock().toISOString(), + catalog: () => registry.list(), dispatch: (req, context) => registry.invoke(req, context) }) + } + const old = new FileControlHistory(directory, { now: () => then }) + const first = await at(() => then, old).invoke(request, caller) + await at(() => then, old).invoke({ ...request, requestKey: undefined, input: { text: 'unkeyed' } }, caller) + expect(effects).toBe(2) + + const later = new FileControlHistory(directory, { now: () => RETENTION_NOW }) + const replay = await at(() => RETENTION_NOW, later).invoke(request, caller) + expect(replay).toMatchObject({ ok: true, value: 'sent', operation: { reusedCallId: first.operation?.callId } }) + expect(effects).toBe(2) + const kept = await new FileControlHistory(directory, { now: () => RETENTION_NOW }).events() + expect(kept.every(event => event.requestKey === request.requestKey)).toBe(true) + }) +}) diff --git a/src/main/control/history/FileControlHistory.ts b/src/main/control/history/FileControlHistory.ts index 16066cd3e..621817000 100644 --- a/src/main/control/history/FileControlHistory.ts +++ b/src/main/control/history/FileControlHistory.ts @@ -56,6 +56,12 @@ const EXECUTOR_KINDS = new Set(['received', 'dispatched', 'result', 'dup // A malformed recovery.json preserved aside when a new recovery must rewrite // the marker: its block cannot be read, so it stays a global block of its own. const INVALID_MARKER = /^recovery\.invalid-.+\.json$/ +// How long a call that can never be looked up for dedupe stays readable +// (#1274). UNCONFIRMED product default: history.read/list serve an agent or +// operator inspecting recent work, which is hours, not weeks. On the owner's +// rate (~5 MB of payloads a day, nearly all transcripts.page reads) a week is +// about 20-35 MB instead of an unbounded journal held whole in memory. +export const CONTROL_HISTORY_RETENTION_MS = 7 * 24 * 60 * 60 * 1000 const recoveryFileSchema = z.object({ quarantines: z.array(z.object({ file: z.string().refine(name => QUARANTINE.test(name) || INVALID_MARKER.test(name)), @@ -105,7 +111,12 @@ export class FileControlHistory implements ControlHistory { private guards: Guard[] = [] constructor( private readonly directory: string, - private readonly options: { onRecovered?: (recovery: ControlHistoryRecovery) => void } = {}, + private readonly options: { + onRecovered?: (recovery: ControlHistoryRecovery) => void + /** Clock for retention; injectable so tests can age a real journal. */ + now?: () => Date + retentionMs?: number + } = {}, ) {} private load(): Promise { @@ -120,15 +131,75 @@ export class FileControlHistory implements ControlHistory { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error } let events: HistoryEvent[] = [] + let recovered = false if (bytes) { const analysis = analyze(bytes.toString('utf8')) events = analysis.events - if (analysis.torn || analysis.damaged) await this.recover(bytes, analysis) + recovered = analysis.torn || analysis.damaged + if (recovered) await this.recover(bytes, analysis) } this.guards = await this.readGuards() + // WHY a launch that recovered does not also prune: the operator is about + // to reconcile a damaged ledger against what it showed at recovery time, + // and a second rewrite in the same launch would change it under them. + // The next clean launch prunes. + if (!recovered) events = await this.prune(events) return events } + // Retention (#1274). WHY on load and nowhere else: load is the one moment + // no append is in flight (appends wait on `load()`), so the rewrite cannot + // race a writer, and the app relaunches often (updates, restarts). A + // process running for weeks grows until its next launch; that is the + // accepted residual, bounded by the same rate. + // + // WHY whole calls, and only these (steering q12): the journal is the + // executor's dedupe ledger and request keys have no lifetime in the + // contract, so any call carrying a key is kept forever (815 of 6,089 rows + // on the owner's machine). A call with no `result` may still be in flight + // or is evidence of an interrupted one; a call a kept `duplicate` names + // via reusedCallId is where that duplicate's answer lives. Dropping WHOLE + // calls keeps the per-call key consistency analyze() checks, so the + // pruned ledger reloads as clean instead of being quarantined. + private async prune(events: HistoryEvent[]): Promise { + const cutoff = (this.options.now?.() ?? new Date()).getTime() - (this.options.retentionMs ?? CONTROL_HISTORY_RETENTION_MS) + const calls = new Map() + const reused = new Set() + for (const event of events) { + const call = calls.get(event.callId) ?? { keyed: false, finished: false, newest: 0 } + if (event.requestKey !== undefined) call.keyed = true + if (event.kind === 'result') call.finished = true + // An unparseable time counts as NEW: unknown age is never a reason to + // delete evidence. + const at = Date.parse(event.at) + call.newest = Math.max(call.newest, Number.isFinite(at) ? at : Number.POSITIVE_INFINITY) + calls.set(event.callId, call) + if (event.reusedCallId) reused.add(event.reusedCallId) + } + const expired = new Set([...calls].filter(([id, call]) => + !call.keyed && call.finished && !reused.has(id) && call.newest < cutoff).map(([id]) => id)) + const kept = expired.size > 0 ? events.filter(event => !expired.has(event.callId)) : events + if (expired.size > 0) { + // Same renumbering as recovery: sequences are process-local cursors. + kept.forEach((event, index) => { event.sequence = index + 1 }) + await this.writeAtomic(join(this.directory, JOURNAL), kept.map(event => `${JSON.stringify(event)}\n`).join('')) + } + // Payload GC runs only AFTER the rewrite is durable: a crash in between + // leaves unreferenced files (collected next launch), never a kept row + // pointing at a deleted payload. It also collects orphans from appends + // that stored a payload and then failed. A digest shared with a kept row + // is referenced, so it stays. + const referenced = new Set(kept.flatMap(event => event.payload ? [`${event.payload}.json`] : [])) + for (const name of await readdir(join(this.directory, 'payloads'))) { + // Only finished payload files: a `.tmp` belongs to a write that has not + // renamed yet (none can be in flight here, but that is the writer's + // business, not GC's). + if (!/^[a-f0-9]{64}\.json$/.test(name) || referenced.has(name)) continue + await rm(join(this.directory, 'payloads', name), { force: true }) + } + return kept + } + private async recover(bytes: Buffer, analysis: Analysis): Promise { const sha256 = createHash('sha256').update(bytes).digest('hex') const stamp = new Date().toISOString().replace(/[:.]/g, '-') diff --git a/src/main/control/history/control.ts b/src/main/control/history/control.ts index 75bda3119..f86110eca 100644 --- a/src/main/control/history/control.ts +++ b/src/main/control/history/control.ts @@ -5,7 +5,7 @@ export function historyCapabilities(history: ControlHistory) { return [ defineCapability({ id: 'history.read', title: 'Inspect one control call', execution: 'main', effect: 'read', - description: 'Return every durable event and payload reference for a call, including retries and unresolved outcomes. Retrieve each payload with history.payloadRead.', + description: 'Return every durable event and payload reference for a call, including retries and unresolved outcomes. A call without a request key that finished more than 7 days ago reads as not_found. Retrieve each payload with history.payloadRead.', input: z.object({ callId: z.string().min(1).describe('operation.callId from a tool result, or callId from history.list.') }).strict(), output: z.object({ callId: z.string(), events: z.array(historyEventSchema), relatedCalls: z.array(z.string()), state: z.enum(['recorded', 'outcome_unknown', 'not_found']) }), @@ -18,9 +18,12 @@ export function historyCapabilities(history: ControlHistory) { }), defineCapability({ id: 'history.list', title: 'List control history', execution: 'main', effect: 'read', - description: 'Read durable invocation events. Carry snapshot through paging so reading history does not chase its own new records.', + // The retention window is part of what this tool promises (#1274): a page + // is never clipped, but calls outside the window are gone from the + // journal, and an agent reading an empty result must know why. + description: 'Read durable invocation events. Calls with a request key, and calls without a result, are kept; other calls are kept for 7 days. Carry snapshot through paging so reading history does not chase its own new records.', input: z.object({ after: z.number().int().nonnegative().default(0).describe('Exclusive event sequence boundary; use nextAfter from the previous page.'), snapshot: z.number().int().nonnegative().optional().describe('Keep the first page’s snapshot unchanged to finish a finite history read while new calls are recorded.'), - limit: z.number().int().min(1).max(200).default(50).describe('Maximum events per page; call history is never silently truncated.'), callId: z.string().optional().describe('Optional exact call ID to filter events.') }).strict(), + limit: z.number().int().min(1).max(200).default(50).describe('Maximum events per page; a page is never silently truncated.'), callId: z.string().optional().describe('Optional exact call ID to filter events.') }).strict(), output: z.object({ events: z.array(historyEventSchema), snapshot: z.number().int(), nextAfter: z.number().int().nullable(), complete: z.boolean() }), handler: async ({ after, snapshot, limit, callId }, context) => { const events = await history.events() diff --git a/testing/fixtures/control-history/retention-rows-2026-09-27.json b/testing/fixtures/control-history/retention-rows-2026-09-27.json new file mode 100644 index 000000000..5fd6b63a1 --- /dev/null +++ b/testing/fixtures/control-history/retention-rows-2026-09-27.json @@ -0,0 +1,317 @@ +{ + "source": "Owner's ~/.config/agent-code/control-history/events.jsonl, recorded 2026-09-27: every row of 8 real calls, in file order, sequences renumbered 1..n (process-local cursors). Rows carry only ids, times, callers, capability ids, request keys and payload digests; prompts and results live in payloads/, which are NOT included. Calls: 2 unkeyed finished calls from 2026-09-05 (transcripts.page, agents.read); a keyed dispatch.configure call and the keyed duplicate that reuses it; 2 unkeyed calls from 2026-09-27; an unkeyed externalControl.status call from 2026-09-05 whose received payload digest is shared with the keyed app.windowFocus call after it.", + "rows": [ + { + "sequence": 1, + "at": "2026-09-05T08:37:23.199Z", + "instanceId": "1f082cde-f316-42d2-98e0-c1f4b7202d08", + "callId": "95caa49c-eb60-44e2-9b62-938ce2243d11", + "kind": "received", + "capabilityId": "externalControl.status", + "caller": "application:70afc66f-7160-4a3e-83bd-e12eafa58f46", + "payload": "b85bb9227778026c8624fa7520197de14e91d19d2fa058c873f7a2a222d47e6c" + }, + { + "sequence": 2, + "at": "2026-09-05T08:37:23.224Z", + "instanceId": "1f082cde-f316-42d2-98e0-c1f4b7202d08", + "callId": "95caa49c-eb60-44e2-9b62-938ce2243d11", + "kind": "dispatched", + "capabilityId": "externalControl.status", + "caller": "application:70afc66f-7160-4a3e-83bd-e12eafa58f46", + "payload": "e5624e8c0ef7518948b17f88486be4658cdb3ba0e9c92a02aea6ad365bb92fd1" + }, + { + "sequence": 3, + "at": "2026-09-05T08:37:23.245Z", + "instanceId": "1f082cde-f316-42d2-98e0-c1f4b7202d08", + "callId": "95caa49c-eb60-44e2-9b62-938ce2243d11", + "kind": "result", + "capabilityId": "externalControl.status", + "caller": "application:70afc66f-7160-4a3e-83bd-e12eafa58f46", + "payload": "c90d856f8ace6e59d84a856413fb065a793fc8411220d20559ef1be67789250e" + }, + { + "sequence": 4, + "at": "2026-09-05T08:41:06.887Z", + "instanceId": "1f082cde-f316-42d2-98e0-c1f4b7202d08", + "callId": "5ea10842-a1c8-463c-aee9-5d0e238c56e1", + "kind": "received", + "capabilityId": "agents.read", + "caller": "external:agent-code-control", + "payload": "a8164481c57dddb1b3670545a25d58343a9185c69a16f606c2afa269d3b23985" + }, + { + "sequence": 5, + "at": "2026-09-05T08:41:06.899Z", + "instanceId": "1f082cde-f316-42d2-98e0-c1f4b7202d08", + "callId": "5ea10842-a1c8-463c-aee9-5d0e238c56e1", + "kind": "step", + "capabilityId": "agents.read", + "caller": "external:agent-code-control", + "payload": "7e20e0d386b413a83eb9b6a3a5540ab67087d977559aaf62fed0ff8d8bd55b27" + }, + { + "sequence": 6, + "at": "2026-09-05T08:41:06.916Z", + "instanceId": "1f082cde-f316-42d2-98e0-c1f4b7202d08", + "callId": "5ea10842-a1c8-463c-aee9-5d0e238c56e1", + "kind": "step", + "capabilityId": "agents.read", + "caller": "external:agent-code-control", + "payload": "3f937dbe08f75c640835b9fd3cbd05f12142e513aba9501373f27aa56df2e86f" + }, + { + "sequence": 7, + "at": "2026-09-05T08:41:06.924Z", + "instanceId": "1f082cde-f316-42d2-98e0-c1f4b7202d08", + "callId": "5ea10842-a1c8-463c-aee9-5d0e238c56e1", + "kind": "dispatched", + "capabilityId": "agents.read", + "caller": "external:agent-code-control", + "payload": "e0e0aa86c645a2d2df423d40129a06f5798f08ca4bb2bc102c9ba5d0423c3cfd" + }, + { + "sequence": 8, + "at": "2026-09-05T08:41:06.937Z", + "instanceId": "1f082cde-f316-42d2-98e0-c1f4b7202d08", + "callId": "37763f2f-c479-49b7-8d79-4c990bc0784c", + "kind": "received", + "capabilityId": "transcripts.page", + "caller": "application:8ba30627-5c41-4986-a325-b4ada30aa5f0", + "payload": "f9c28cd48b4c90c71133141e7ea764cf2979bded17bf40ca5d65d305e361d49e" + }, + { + "sequence": 9, + "at": "2026-09-05T08:41:06.953Z", + "instanceId": "1f082cde-f316-42d2-98e0-c1f4b7202d08", + "callId": "37763f2f-c479-49b7-8d79-4c990bc0784c", + "kind": "dispatched", + "capabilityId": "transcripts.page", + "caller": "application:8ba30627-5c41-4986-a325-b4ada30aa5f0", + "payload": "e5624e8c0ef7518948b17f88486be4658cdb3ba0e9c92a02aea6ad365bb92fd1" + }, + { + "sequence": 10, + "at": "2026-09-05T08:41:06.982Z", + "instanceId": "1f082cde-f316-42d2-98e0-c1f4b7202d08", + "callId": "37763f2f-c479-49b7-8d79-4c990bc0784c", + "kind": "result", + "capabilityId": "transcripts.page", + "caller": "application:8ba30627-5c41-4986-a325-b4ada30aa5f0", + "payload": "b0eb2d5e316244a96e238c4a339d8307fbbc60cf699f44cbc8e57fda3de05013" + }, + { + "sequence": 11, + "at": "2026-09-05T08:41:07.010Z", + "instanceId": "1f082cde-f316-42d2-98e0-c1f4b7202d08", + "callId": "5ea10842-a1c8-463c-aee9-5d0e238c56e1", + "kind": "result", + "capabilityId": "agents.read", + "caller": "external:agent-code-control", + "payload": "773a3d7abaa8d0ef41ed43eee632f6ff698aab51d4b72b3fb7cf2abd80604a52" + }, + { + "sequence": 12, + "at": "2026-09-05T08:42:23.936Z", + "instanceId": "1f082cde-f316-42d2-98e0-c1f4b7202d08", + "callId": "95c97fce-bca0-4bc7-9901-90b45d988d1c", + "kind": "received", + "capabilityId": "app.windowFocus", + "caller": "external:agent-code-control", + "requestKey": "focus-second-window-b7-20260905", + "payload": "9c6575ffeb71b0f5d7b24c64aab09e601576f6f6db9e0b7b2d0fe274ac0293ae" + }, + { + "sequence": 13, + "at": "2026-09-05T08:42:23.953Z", + "instanceId": "1f082cde-f316-42d2-98e0-c1f4b7202d08", + "callId": "95c97fce-bca0-4bc7-9901-90b45d988d1c", + "kind": "dispatched", + "capabilityId": "app.windowFocus", + "caller": "external:agent-code-control", + "requestKey": "focus-second-window-b7-20260905", + "payload": "e5624e8c0ef7518948b17f88486be4658cdb3ba0e9c92a02aea6ad365bb92fd1" + }, + { + "sequence": 14, + "at": "2026-09-05T08:42:23.958Z", + "instanceId": "1f082cde-f316-42d2-98e0-c1f4b7202d08", + "callId": "95c97fce-bca0-4bc7-9901-90b45d988d1c", + "kind": "step", + "capabilityId": "app.windowFocus", + "caller": "external:agent-code-control", + "requestKey": "focus-second-window-b7-20260905", + "payload": "f0e8ba44ed2883fbc468b901fb135a2512d8fc67463fe7065d74deab71423cf9" + }, + { + "sequence": 15, + "at": "2026-09-05T08:42:23.972Z", + "instanceId": "1f082cde-f316-42d2-98e0-c1f4b7202d08", + "callId": "95c97fce-bca0-4bc7-9901-90b45d988d1c", + "kind": "step", + "capabilityId": "app.windowFocus", + "caller": "external:agent-code-control", + "requestKey": "focus-second-window-b7-20260905", + "payload": "e218174bd8c67b249384bac6851133ad31a843d71a87f81bdec19a35b2b08e9a" + }, + { + "sequence": 16, + "at": "2026-09-05T08:42:26.500Z", + "instanceId": "1f082cde-f316-42d2-98e0-c1f4b7202d08", + "callId": "95c97fce-bca0-4bc7-9901-90b45d988d1c", + "kind": "result", + "capabilityId": "app.windowFocus", + "caller": "external:agent-code-control", + "requestKey": "focus-second-window-b7-20260905", + "payload": "c9f7a3994312373a76266b5cbd233cd4d3b8260c3ed7922f7150bf99f2b275c2" + }, + { + "sequence": 17, + "at": "2026-09-05T08:44:06.475Z", + "instanceId": "1f082cde-f316-42d2-98e0-c1f4b7202d08", + "callId": "13e43d53-5120-41ae-88d7-820dc9088728", + "kind": "received", + "capabilityId": "dispatch.configure", + "caller": "external:agent-code-control", + "requestKey": "first-window-remove-second-lane-20260905-01", + "payload": "81eed339ed998ffe1a616fc2835511785a99c9970b430f2719afeab42620149e" + }, + { + "sequence": 18, + "at": "2026-09-05T08:44:06.489Z", + "instanceId": "1f082cde-f316-42d2-98e0-c1f4b7202d08", + "callId": "13e43d53-5120-41ae-88d7-820dc9088728", + "kind": "dispatched", + "capabilityId": "dispatch.configure", + "caller": "external:agent-code-control", + "requestKey": "first-window-remove-second-lane-20260905-01", + "payload": "e0e0aa86c645a2d2df423d40129a06f5798f08ca4bb2bc102c9ba5d0423c3cfd" + }, + { + "sequence": 19, + "at": "2026-09-05T08:44:06.496Z", + "instanceId": "1f082cde-f316-42d2-98e0-c1f4b7202d08", + "callId": "13e43d53-5120-41ae-88d7-820dc9088728", + "kind": "step", + "capabilityId": "dispatch.configure", + "caller": "external:agent-code-control", + "requestKey": "first-window-remove-second-lane-20260905-01", + "payload": "a23fff8c48378c858ee4277543a5262c0a9a460645b133b78d507d3f9c6e711d" + }, + { + "sequence": 20, + "at": "2026-09-05T08:44:09.019Z", + "instanceId": "1f082cde-f316-42d2-98e0-c1f4b7202d08", + "callId": "13e43d53-5120-41ae-88d7-820dc9088728", + "kind": "result", + "capabilityId": "dispatch.configure", + "caller": "external:agent-code-control", + "requestKey": "first-window-remove-second-lane-20260905-01", + "payload": "06c80c18a1b15f1079552e212e000e643947135db33e883b707f6eaf74a84bf9" + }, + { + "sequence": 21, + "at": "2026-09-05T08:44:37.088Z", + "instanceId": "1f082cde-f316-42d2-98e0-c1f4b7202d08", + "callId": "a346f752-9eec-4b37-b7df-145dfaf1aaf5", + "kind": "duplicate", + "capabilityId": "dispatch.configure", + "caller": "external:agent-code-control", + "requestKey": "first-window-remove-second-lane-20260905-01", + "payload": "81eed339ed998ffe1a616fc2835511785a99c9970b430f2719afeab42620149e", + "reusedCallId": "13e43d53-5120-41ae-88d7-820dc9088728" + }, + { + "sequence": 22, + "at": "2026-09-05T08:44:37.093Z", + "instanceId": "1f082cde-f316-42d2-98e0-c1f4b7202d08", + "callId": "a346f752-9eec-4b37-b7df-145dfaf1aaf5", + "kind": "result", + "capabilityId": "dispatch.configure", + "caller": "external:agent-code-control", + "requestKey": "first-window-remove-second-lane-20260905-01", + "payload": "ecc533f4442fc2f5fb9a24d96d93feb3a0e72b4b3f30b2153384efe07c41ce0d" + }, + { + "sequence": 23, + "at": "2026-09-27T00:02:07.032Z", + "instanceId": "c42a794b-9bf3-4870-9102-bc7989ef7251", + "callId": "e8a21d19-b139-4012-8e09-6139b5643bb5", + "kind": "received", + "capabilityId": "agents.list", + "caller": "agent:3fbf42d1-7ab3-458b-90f0-772b323f6b1d", + "payload": "af735f317f592f86fb71ffd6cbf8f5d4d780221a009b05c531205b4c78aca59c" + }, + { + "sequence": 24, + "at": "2026-09-27T00:02:07.056Z", + "instanceId": "c42a794b-9bf3-4870-9102-bc7989ef7251", + "callId": "e8a21d19-b139-4012-8e09-6139b5643bb5", + "kind": "dispatched", + "capabilityId": "agents.list", + "caller": "agent:3fbf42d1-7ab3-458b-90f0-772b323f6b1d", + "payload": "d7f3da1d5f8395f12c402917af308af30b5de23a9529e15eb08cee54a4dd8133" + }, + { + "sequence": 25, + "at": "2026-09-27T00:02:07.070Z", + "instanceId": "c42a794b-9bf3-4870-9102-bc7989ef7251", + "callId": "e8a21d19-b139-4012-8e09-6139b5643bb5", + "kind": "result", + "capabilityId": "agents.list", + "caller": "agent:3fbf42d1-7ab3-458b-90f0-772b323f6b1d", + "payload": "a8d9690673a8b89595dd2c1b3f463cf13744bc76bf0024a22ec9f4f4a3b63f64" + }, + { + "sequence": 26, + "at": "2026-09-27T00:02:08.746Z", + "instanceId": "c42a794b-9bf3-4870-9102-bc7989ef7251", + "callId": "2ebe82c0-c877-4bb6-9877-56ded38d4739", + "kind": "received", + "capabilityId": "agents.read", + "caller": "agent:3fbf42d1-7ab3-458b-90f0-772b323f6b1d", + "payload": "b3b9b32ad8b423936419c2700b5746a92970e1ba3bc1a62f69d0eb3070ef91d5" + }, + { + "sequence": 27, + "at": "2026-09-27T00:02:08.762Z", + "instanceId": "c42a794b-9bf3-4870-9102-bc7989ef7251", + "callId": "2ebe82c0-c877-4bb6-9877-56ded38d4739", + "kind": "step", + "capabilityId": "agents.read", + "caller": "agent:3fbf42d1-7ab3-458b-90f0-772b323f6b1d", + "payload": "019f15ae87e4cab8ec60001c72c4c814cc8cd231475c832f2efa3e9d25c1305b" + }, + { + "sequence": 28, + "at": "2026-09-27T00:02:08.774Z", + "instanceId": "c42a794b-9bf3-4870-9102-bc7989ef7251", + "callId": "2ebe82c0-c877-4bb6-9877-56ded38d4739", + "kind": "step", + "capabilityId": "agents.read", + "caller": "agent:3fbf42d1-7ab3-458b-90f0-772b323f6b1d", + "payload": "7535106cc7f66282492f69db9f8b049d1afb2c5c6800573b09aa70856e384275" + }, + { + "sequence": 29, + "at": "2026-09-27T00:02:08.790Z", + "instanceId": "c42a794b-9bf3-4870-9102-bc7989ef7251", + "callId": "2ebe82c0-c877-4bb6-9877-56ded38d4739", + "kind": "dispatched", + "capabilityId": "agents.read", + "caller": "agent:3fbf42d1-7ab3-458b-90f0-772b323f6b1d", + "payload": "d7f3da1d5f8395f12c402917af308af30b5de23a9529e15eb08cee54a4dd8133" + }, + { + "sequence": 30, + "at": "2026-09-27T00:02:08.895Z", + "instanceId": "c42a794b-9bf3-4870-9102-bc7989ef7251", + "callId": "2ebe82c0-c877-4bb6-9877-56ded38d4739", + "kind": "result", + "capabilityId": "agents.read", + "caller": "agent:3fbf42d1-7ab3-458b-90f0-772b323f6b1d", + "payload": "c9be0e86ad3d209892043c29a28f650c0f0dede9795cedee26406ba2d28776f4" + } + ] +} From 6517561550496dd74e5f8484a484462300ee216b Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sat, 26 Sep 2026 18:08:34 -0700 Subject: [PATCH 03/25] fix(control): retention keeps task origins, unsettled outcomes and quarantine evidence (#1330 review) Retention deleted evidence that is still asked for: unkeyed task origins (operations.read went not_found), outcome_unknown and pending results, and payloads an unaccepted recovery quarantine names. It also failed the whole load when the rewrite failed. These are now retained roots, a failed rewrite skips retention and GC, and the survivors are pinned. Refs #1274 Co-Authored-By: Claude Opus 5.5 --- .../2026-09-26-control-history-retention.md | 17 +- .../history/FileControlHistory.test.ts | 179 ++++++++++++++++-- .../control/history/FileControlHistory.ts | 130 +++++++++++-- src/main/control/history/control.ts | 4 +- .../retention-rows-2026-09-27.json | 2 +- 5 files changed, 297 insertions(+), 35 deletions(-) diff --git a/docs/plans/2026-09-26-control-history-retention.md b/docs/plans/2026-09-26-control-history-retention.md index 972d2550f..cd8242592 100644 --- a/docs/plans/2026-09-26-control-history-retention.md +++ b/docs/plans/2026-09-26-control-history-retention.md @@ -6,7 +6,7 @@ ## Evidence (owner's store, 2026-09-27) - 129 MB on disk after 22 days: `events.jsonl` has 6,089 rows over 1,894 calls; `payloads/` holds 3,499 files for 3,457 referenced digests (42 orphans from failed appends). - 105 MB of the payloads are `transcripts.page` results, which are read-only and unkeyed. `mcp.tools/list` adds 7 MB, `mcp.tools/call` 2.8 MB, and `agents.read` 1.5 MB. -- Keyed calls, the executor's dedupe ledger (#1240), are small: 815 rows, 290 `received`. Their keys come from `dispatch.configure`, `commands.run`, `operations.start/finish`, `agents.prompt`, and others. +- Keyed calls, the executor's dedupe ledger (#1240), are small: 815 rows, 220 `received` (221 calls carry a key; corrected in review C3). Their keys come from `dispatch.configure`, `commands.run`, `operations.start/finish`, `agents.prompt`, and others. - Every call in the journal has a `result` row (0 in flight). - Simulated retention for unkeyed finished calls: 7 days keeps 507 calls (1,910 rows) and 17 MB of payloads; 14 days keeps 881 calls and 48 MB; 30 days keeps everything. @@ -33,3 +33,18 @@ A fixture of real journal rows, recorded from the owner's store: ids, timestamps - A call without a `result` row is kept however old it is. - A payload shared by a pruned and a kept row survives. - A load that ran recovery does not prune. + +## Review round 1 (#1330) and steering q47/q49 +All three reviewers returned FIX-BEFORE-MERGE. Whatever window the owner picks, retention must never delete evidence someone can still ask for. A finished, unkeyed, unreused, old call is now also KEPT when: +- **It is a lifecycle task origin** (a `task.*` step) (A1, C1). `operations.read`/`finish` look a task up by its original call id, and the tool contract says task results persist across restarts. The owner has 12 such calls. +- **Its result is not proven settled** (A3). Settled means: an ok result with status `completed`/`ui_opened`, a `not_started` refusal, or an MCP transport echo. `outcome_unknown` (26 on the owner's machine) and `pending` (157, accepted `agents.prompt` operations) are kept, and so is a missing or unreadable payload (q40). +- **An unaccepted recovery quarantine names it** (A2). Its call ids, and every payload digest the quarantine names, stay until the operator accepts that digest in `recovery-accepted.json`. + +Payloads are read only for calls that are otherwise expired, so after the first launch each launch reads about a day's worth. + +Other changes: +- **Rewrite failure (B1).** A failed rewrite no longer fails the load, and payload GC never runs after one. Sequences are renumbered on copies, so the rows served still match the unchanged file. +- **Survivors pinned.** Reuse-only target (A, B2), unknown-age row (A, B3), non-digest temp file (B4), and the window at its boundary, 7 days ± 1 minute (C). +- **Fixture metadata (B5).** The shared digest is on the `dispatched` rows. + +**Owner question (unchanged, still open):** the window length (default 7 days). With the retained roots above, what a window deletes is only settled, unkeyed, non-task calls: exact request/result copies of reads and completed mutations, which `history.read`/`list` then no longer find. On the owner's store today: 7 days keeps ~520 calls and ~20 MB of payloads; 14 days keeps ~884 calls and ~52 MB. diff --git a/src/main/control/history/FileControlHistory.test.ts b/src/main/control/history/FileControlHistory.test.ts index 2ffa27026..a65892b80 100644 --- a/src/main/control/history/FileControlHistory.test.ts +++ b/src/main/control/history/FileControlHistory.test.ts @@ -1,12 +1,13 @@ import { mkdtemp, readFile, readdir, appendFile, writeFile, rm, stat, chmod, access } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { randomUUID } from 'node:crypto' +import { createHash, randomUUID } from 'node:crypto' import { afterEach, describe, expect, it } from 'vitest' import { z } from 'zod' import { createControlExecutor, createControlRegistry } from '../../../control-sdk/host' import { defineCapability, type ControlHistory, type ControlResult } from '@control-sdk' -import { FileControlHistory } from './FileControlHistory' +import { CONTROL_HISTORY_RETENTION_MS, FileControlHistory } from './FileControlHistory' +import { taskHistoryCapabilities } from './tasks' import { historyCapabilities } from './control' // The recorded rows in real-rows-2026-09.json were written on 2026-09-05. The @@ -478,23 +479,50 @@ const RETENTION_NOW = new Date('2026-09-27T12:00:00.000Z') const OLD_UNKEYED = ['95caa49c-eb60-44e2-9b62-938ce2243d11', '5ea10842-a1c8-463c-aee9-5d0e238c56e1', '37763f2f-c479-49b7-8d79-4c990bc0784c'] const KEPT = ['95c97fce-bca0-4bc7-9901-90b45d988d1c', '13e43d53-5120-41ae-88d7-820dc9088728', 'a346f752-9eec-4b37-b7df-145dfaf1aaf5', 'e8a21d19-b139-4012-8e09-6139b5643bb5', '2ebe82c0-c877-4bb6-9877-56ded38d4739'] -// The old unkeyed externalControl.status call's `received` digest is also the -// keyed app.windowFocus call's; pruning the first must not take it away. +// The old unkeyed externalControl.status and transcripts.page calls' +// `dispatched` rows name the same digest as the keyed app.windowFocus call's +// `dispatched` row (#1330 review B5 corrected which rows); pruning the first +// two must not take it away from the third. const SHARED_DIGEST = 'e5624e8c0ef7518948b17f88486be4658cdb3ba0e9c92a02aea6ad365bb92fd1' const ORPHAN_DIGEST = 'f'.repeat(64) describe('control history retention (#1274)', () => { - async function seededJournal(rows = retentionRows.rows) { + // Payload CONTENTS are not recorded (they hold prompts and results), and + // retention now READS result and step payloads (#1330 review: an unknown + // outcome or a task origin must be kept), through the same digest check + // every reader uses. So each recorded digest is replaced, here and not in + // the fixture, by the digest of a body of the right shape for the row + // that first names it: a settled `completed` result, an ordinary owner + // step, or an opaque input. One recorded digest maps to one body, so rows + // that shared a payload still share one. Everything else is the recording. + const bodies = new Map() + for (const row of retentionRows.rows) { + if (!row.payload || bodies.has(row.payload)) continue + bodies.set(row.payload, JSON.stringify(row.kind === 'result' + ? { ok: true, value: { recorded: row.payload }, operation: { callId: row.callId, instanceId: 'recorded', status: 'completed' } } + : row.kind === 'step' ? { step: 'resolve-owner', recorded: row.payload } : { recorded: row.payload })) + } + const digestOf = (body: string) => createHash('sha256').update(body).digest('hex') + const remap = new Map([...bodies].map(([recorded, body]) => [recorded, digestOf(body)])) + const rowsWithBodies = retentionRows.rows.map(row => row.payload ? { ...row, payload: remap.get(row.payload)! } : row) + const SHARED = remap.get(SHARED_DIGEST)! + async function writePayload(directory: string, body: string): Promise { + const { mkdir } = await import('node:fs/promises') + await mkdir(join(directory, 'payloads'), { recursive: true }) + const digest = digestOf(body) + await writeFile(join(directory, 'payloads', `${digest}.json`), body) + return digest + } + async function seededJournal(rows: Array & { callId: string; kind: string; payload?: string }> = rowsWithBodies) { const { directory } = await setup() await writeFile(join(directory, 'events.jsonl'), rows.map((row, index) => `${JSON.stringify({ ...row, sequence: index + 1 })}\n`).join(''), { mode: 0o600 }) - // Payload CONTENTS are not recorded (they hold prompts and results); - // retention decides by the digest in the row, so a placeholder body is - // enough. An orphan, as a failed append leaves, sits beside them. + for (const body of bodies.values()) await writePayload(directory, body) + // An orphan, as a failed append leaves, and a temp file of a write that + // never renamed: GC takes the first and must never touch the second. const { mkdir } = await import('node:fs/promises') await mkdir(join(directory, 'payloads'), { recursive: true }) - for (const digest of new Set([...rows.map(row => row.payload!).filter(Boolean), ORPHAN_DIGEST])) { - await writeFile(join(directory, 'payloads', `${digest}.json`), '{}') - } + await writeFile(join(directory, 'payloads', `${ORPHAN_DIGEST}.json`), '{}') + await writeFile(join(directory, 'payloads', `${ORPHAN_DIGEST}.json.tmp`), '{}') return directory } const open = (directory: string, onRecovered?: () => void) => @@ -505,13 +533,13 @@ describe('control history retention (#1274)', () => { const events = await open(directory).events() expect([...new Set(events.map(event => event.callId))].sort()).toEqual([...KEPT].sort()) // Kept rows are the recorded rows, in order, with only sequences renumbered. - const expected = retentionRows.rows.filter(row => KEPT.includes(row.callId)) + const expected = rowsWithBodies.filter(row => KEPT.includes(row.callId)) expect(events.map(({ sequence: _s, ...rest }) => rest)).toEqual(expected.map(({ sequence: _s, ...rest }) => rest)) expect(events.map(event => event.sequence)).toEqual(expected.map((_, index) => index + 1)) const payloads = new Set(await readdir(join(directory, 'payloads'))) const keptDigests = new Set(expected.map(row => row.payload!).filter(Boolean)) - expect(payloads).toEqual(new Set([...keptDigests].map(digest => `${digest}.json`))) - expect(payloads.has(`${SHARED_DIGEST}.json`)).toBe(true) + expect(payloads).toEqual(new Set([...[...keptDigests].map(digest => `${digest}.json`), `${ORPHAN_DIGEST}.json.tmp`])) + expect(payloads.has(`${SHARED}.json`)).toBe(true) expect(payloads.has(`${ORPHAN_DIGEST}.json`)).toBe(false) // The rewrite is durable and clean: a second launch prunes nothing and // reports no recovery. @@ -521,7 +549,7 @@ describe('control history retention (#1274)', () => { }) it('keeps a call with no result row however old it is', async () => { - const rows = retentionRows.rows.filter(row => !(row.callId === OLD_UNKEYED[2] && row.kind === 'result')) + const rows = rowsWithBodies.filter(row => !(row.callId === OLD_UNKEYED[2] && row.kind === 'result')) const directory = await seededJournal(rows) const events = await open(directory).events() expect(events.some(event => event.callId === OLD_UNKEYED[2])).toBe(true) @@ -563,4 +591,125 @@ describe('control history retention (#1274)', () => { const kept = await new FileControlHistory(directory, { now: () => RETENTION_NOW }).events() expect(kept.every(event => event.requestKey === request.requestKey)).toBe(true) }) + + // Rows shaped exactly like the executor's and the task writer's, for one + // unkeyed call ending at `at`, each with a real hashed payload. + type Built = { callId: string; rows: Array & { callId: string; kind: string; payload?: string }> } + async function call(directory: string, options: { at: string; result?: unknown; steps?: unknown[]; reusedCallId?: string; callId?: string }): Promise { + const callId = options.callId ?? randomUUID() + const base = { at: options.at, instanceId: 'recorded', callId, capabilityId: 'agents.resume', caller: 'external:agent-code-control' } + const rows: Built['rows'] = [ + { ...base, kind: 'received', payload: await writePayload(directory, JSON.stringify({ input: { callId } })) }, + { ...base, kind: 'dispatched', payload: await writePayload(directory, JSON.stringify({ owner: { kind: 'main', generation: 'g' } })) }, + ] + for (const step of options.steps ?? []) rows.push({ ...base, kind: 'step', payload: await writePayload(directory, JSON.stringify(step)) }) + if (options.result !== undefined) rows.push({ ...base, kind: 'result', payload: await writePayload(directory, JSON.stringify(options.result)), ...(options.reusedCallId ? { reusedCallId: options.reusedCallId } : {}) }) + return { callId, rows } + } + async function journal(directory: string, calls: Built[]) { + await writeFile(join(directory, 'events.jsonl'), calls.flatMap(built => built.rows).map((row, index) => `${JSON.stringify({ ...row, sequence: index + 1 })}\n`).join(''), { mode: 0o600 }) + } + const OLD = '2026-09-01T00:00:00.000Z' + const settled = (status = 'completed') => ({ ok: true, value: {}, operation: { callId: 'x', instanceId: 'recorded', status } }) + const kept = async (directory: string, ids: string[]) => { + const present = new Set((await open(directory).events()).map(event => event.callId)) + return ids.map(id => present.has(id)) + } + + // #1330 review A1/C1, q49: an unkeyed task's original call is the task + // store's lookup key. Pruning it turned operations.read into not_found and + // made operations.finish unable to find its origin. + it('keeps an unkeyed task origin, so operations.read still answers after the window', async () => { + const { directory } = await setup() + const owner = { kind: 'main' as const, generation: 'g' } + const task = await call(directory, { at: OLD, result: settled('pending'), steps: [ + { step: 'task.started', owner }, + { step: 'task.finished', result: { ok: true, value: { newSessionId: 'new' } } }, + ] }) + const plain = await call(directory, { at: OLD, result: settled() }) + await journal(directory, [task, plain]) + const history = open(directory) + const read = taskHistoryCapabilities(history, () => false).find(item => item.descriptor.id === 'operations.read')! + const context = { requestId: 'read', owner, caller: { kind: 'external' as const, id: 'operator' } } + expect(await read.execute({ callId: task.callId }, context)).toMatchObject({ ok: true, value: { status: 'completed', result: { ok: true, value: { newSessionId: 'new' } } } }) + expect(await kept(directory, [task.callId, plain.callId])).toEqual([true, false]) + }) + + // #1330 review A3, q49: an unknown outcome is the evidence that the effect + // may have run; `pending` is still open. Only a proven-settled result goes. + it('keeps results that are not proven settled, and prunes settled ones', async () => { + const { directory } = await setup() + const unknown = await call(directory, { at: OLD, result: { ok: false, error: { code: 'unavailable', message: 'lost', outcome: 'unknown' }, operation: { callId: 'x', instanceId: 'recorded', status: 'outcome_unknown' } } }) + const pending = await call(directory, { at: OLD, result: settled('pending') }) + const refused = await call(directory, { at: OLD, result: { ok: false, error: { code: 'unavailable', message: 'no', outcome: 'not_started' }, operation: { callId: 'x', instanceId: 'recorded', status: 'blocked' } } }) + const done = await call(directory, { at: OLD, result: settled() }) + const opened = await call(directory, { at: OLD, result: settled('ui_opened') }) + const transport = await call(directory, { at: OLD, result: { direction: 'outbound', payload: { jsonrpc: '2.0' } } }) + await journal(directory, [unknown, pending, refused, done, opened, transport]) + expect(await kept(directory, [unknown, pending, refused, done, opened, transport].map(built => built.callId))) + .toEqual([true, true, false, false, false, false]) + // The unknown outcome's payload is still readable for reconciliation. + const history = open(directory) + const row = (await history.events()).find(event => event.callId === unknown.callId && event.kind === 'result')! + expect(await history.payload(row.payload!)).toMatchObject({ error: { outcome: 'unknown' } }) + }) + + // #1330 review A2, q49: an unaccepted recovery's quarantine names rows and + // payloads the operator must still be able to read. The first launch + // recovers (no prune); the second must not prune what the quarantine + // names; once the operator accepts the digest, it may go. + it('keeps what an unaccepted recovery quarantine names, until it is accepted', async () => { + const { directory } = await setup() + const old = await call(directory, { at: OLD, result: settled() }) + await journal(directory, [old]) + await appendFile(join(directory, 'events.jsonl'), 'not json\n') + const reports: Array<{ sha256: string }> = [] + await new FileControlHistory(directory, { now: () => RETENTION_NOW, onRecovered: report => reports.push(report) }).events() + expect(reports).toHaveLength(1) + const second = open(directory) + const events = await second.events() + const result = events.find(event => event.callId === old.callId && event.kind === 'result') + expect(result).toBeDefined() + expect(await second.payload(result!.payload!)).toMatchObject({ ok: true }) + await writeFile(join(directory, 'recovery-accepted.json'), JSON.stringify({ accepted: [reports[0]!.sha256] })) + expect(await kept(directory, [old.callId])).toEqual([false]) + }) + + // #1330 review B1: payload GC must only follow a durable rewrite. A + // rewrite that fails (here: the directory refuses the temp file) leaves the + // old journal naming every payload, and the load itself still works. + it('leaves every payload and serves the unpruned rows when the rewrite fails', async () => { + const directory = await seededJournal() + const before = new Set(await readdir(join(directory, 'payloads'))) + await chmod(directory, 0o500) + try { + const events = await open(directory).events() + expect(new Set(events.map(event => event.callId))).toEqual(new Set(retentionRows.rows.map(row => row.callId))) + expect(new Set(await readdir(join(directory, 'payloads')))).toEqual(before) + } finally { await chmod(directory, 0o700) } + // Writable again, the next launch prunes as usual. + expect(await kept(directory, OLD_UNKEYED)).toEqual([false, false, false]) + }) + + // #1330 review A/B survivors: each rule on its own. + it('keeps an old unkeyed call a kept duplicate reuses, and a call of unknown age', async () => { + const { directory } = await setup() + const target = await call(directory, { at: OLD, result: settled() }) + const reuser = await call(directory, { at: RETENTION_NOW.toISOString(), result: settled(), reusedCallId: target.callId }) + const unknownAge = await call(directory, { at: 'not a time', result: settled() }) + await journal(directory, [target, reuser, unknownAge]) + expect(await kept(directory, [target.callId, reuser.callId, unknownAge.callId])).toEqual([true, true, true]) + }) + + // #1330 review C: the window is pinned at its boundary, not only by rows + // three weeks apart. + it('prunes a call exactly past the window and keeps one just inside it', async () => { + const { directory } = await setup() + const minute = 60_000 + const inside = await call(directory, { at: new Date(RETENTION_NOW.getTime() - CONTROL_HISTORY_RETENTION_MS + minute).toISOString(), result: settled() }) + const outside = await call(directory, { at: new Date(RETENTION_NOW.getTime() - CONTROL_HISTORY_RETENTION_MS - minute).toISOString(), result: settled() }) + await journal(directory, [inside, outside]) + expect(await kept(directory, [inside.callId, outside.callId])).toEqual([true, false]) + expect(CONTROL_HISTORY_RETENTION_MS).toBe(7 * 24 * 60 * 60 * 1000) + }) }) diff --git a/src/main/control/history/FileControlHistory.ts b/src/main/control/history/FileControlHistory.ts index 621817000..9cf28dc14 100644 --- a/src/main/control/history/FileControlHistory.ts +++ b/src/main/control/history/FileControlHistory.ts @@ -143,7 +143,17 @@ export class FileControlHistory implements ControlHistory { // to reconcile a damaged ledger against what it showed at recovery time, // and a second rewrite in the same launch would change it under them. // The next clean launch prunes. - if (!recovered) events = await this.prune(events) + if (!recovered) { + // WHY a failed prune does not fail the load (#1330 review B1): retention + // is housekeeping, and the journal is the dedupe ledger every control + // call needs. A read-only or full disk must leave control working on the + // unpruned rows, and a failed rewrite throws before any payload is + // deleted, so the old journal never names a missing payload. + events = await this.prune(events).catch(error => { + console.warn('[control-history] retention skipped this launch:', (error as NodeJS.ErrnoException).code ?? 'error') + return events + }) + } return events } @@ -163,33 +173,46 @@ export class FileControlHistory implements ControlHistory { // pruned ledger reloads as clean instead of being quarantined. private async prune(events: HistoryEvent[]): Promise { const cutoff = (this.options.now?.() ?? new Date()).getTime() - (this.options.retentionMs ?? CONTROL_HISTORY_RETENTION_MS) - const calls = new Map() + const calls = new Map() const reused = new Set() for (const event of events) { - const call = calls.get(event.callId) ?? { keyed: false, finished: false, newest: 0 } + const call = calls.get(event.callId) ?? { keyed: false, finished: false, newest: 0, rows: [] } if (event.requestKey !== undefined) call.keyed = true if (event.kind === 'result') call.finished = true // An unparseable time counts as NEW: unknown age is never a reason to // delete evidence. const at = Date.parse(event.at) call.newest = Math.max(call.newest, Number.isFinite(at) ? at : Number.POSITIVE_INFINITY) + call.rows.push(event) calls.set(event.callId, call) if (event.reusedCallId) reused.add(event.reusedCallId) } - const expired = new Set([...calls].filter(([id, call]) => - !call.keyed && call.finished && !reused.has(id) && call.newest < cutoff).map(([id]) => id)) - const kept = expired.size > 0 ? events.filter(event => !expired.has(event.callId)) : events + // What unaccepted recovery evidence names stays until the operator + // accepts it (#1330 review A2, q49): the quarantine copy holds the rows, + // but only this directory holds their payloads, and reconciling a damaged + // ledger means reading exactly those. + const evidence = await this.quarantineEvidence() + const expired = new Set() + for (const [id, call] of calls) { + if (call.keyed || !call.finished || reused.has(id) || call.newest >= cutoff || evidence.callIds.has(id)) continue + if (await this.mustOutliveRetention(call.rows)) continue + expired.add(id) + } + if (expired.size === 0 && !(await this.hasUnreferencedPayloads(events, evidence.digests))) return events + const kept = events.filter(event => !expired.has(event.callId)) + // Renumbered COPIES: if the rewrite fails, the caller keeps serving the + // original rows with the sequences the unchanged file still has. + const renumbered = kept.map((event, index) => ({ ...event, sequence: index + 1 })) if (expired.size > 0) { - // Same renumbering as recovery: sequences are process-local cursors. - kept.forEach((event, index) => { event.sequence = index + 1 }) - await this.writeAtomic(join(this.directory, JOURNAL), kept.map(event => `${JSON.stringify(event)}\n`).join('')) + await this.writeAtomic(join(this.directory, JOURNAL), renumbered.map(event => `${JSON.stringify(event)}\n`).join('')) } - // Payload GC runs only AFTER the rewrite is durable: a crash in between - // leaves unreferenced files (collected next launch), never a kept row - // pointing at a deleted payload. It also collects orphans from appends - // that stored a payload and then failed. A digest shared with a kept row - // is referenced, so it stays. - const referenced = new Set(kept.flatMap(event => event.payload ? [`${event.payload}.json`] : [])) + // Payload GC runs only AFTER the rewrite is durable (a failed rewrite + // throws above, so GC never runs; see open()): a crash in between leaves + // unreferenced files, collected next launch, never a kept row pointing + // at a deleted payload. It also collects orphans from appends that stored + // a payload and then failed. A digest any kept row or unaccepted + // quarantine names stays. + const referenced = new Set([...kept.flatMap(event => event.payload ? [event.payload] : []), ...evidence.digests].map(digest => `${digest}.json`)) for (const name of await readdir(join(this.directory, 'payloads'))) { // Only finished payload files: a `.tmp` belongs to a write that has not // renamed yet (none can be in flight here, but that is the writer's @@ -197,7 +220,82 @@ export class FileControlHistory implements ControlHistory { if (!/^[a-f0-9]{64}\.json$/.test(name) || referenced.has(name)) continue await rm(join(this.directory, 'payloads', name), { force: true }) } - return kept + return expired.size > 0 ? renumbered : events + } + + // Whether an old, unkeyed, finished call must be kept anyway: its evidence + // is still the only answer to a question someone can ask (#1330 review + // A1/A3/C1, steering q49). + // - A TASK ORIGIN: operations.read/finish look the task up by the original + // call's id and read its `task.*` step payloads. Its tool contract says + // task results persist across restarts, and nothing expires a task, so + // these are kept (12 of 1,894 calls on the owner's machine). + // - A result that is not PROVEN settled: `outcome_unknown` means the + // effect may have run and must not be blindly retried; `pending` is an + // accepted operation whose outcome is still open. Only a completed or + // ui_opened success, a not_started refusal, or an MCP transport echo + // (which reports no operation) is settled. A missing or unreadable + // payload is unknown, and unknown is kept (q40). + // Payloads are read only for calls that are otherwise expired, so steady + // state reads about a day's worth per launch. + private async mustOutliveRetention(rows: HistoryEvent[]): Promise { + for (const row of rows) { + if (row.kind === 'step' && row.payload) { + const head = await this.payloadHead(row.payload, 32) + if (head === null || head.startsWith('{"step":"task.')) return true + } + if (row.kind === 'result') { + if (!row.payload) return true + const result = await this.payload(row.payload).catch(() => undefined) as { + ok?: unknown; error?: { outcome?: unknown }; operation?: { status?: unknown }; direction?: unknown + } | undefined + if (!result || typeof result !== 'object') return true + const settled = result.ok === true + ? result.operation?.status === 'completed' || result.operation?.status === 'ui_opened' + : result.ok === false + ? result.error?.outcome === 'not_started' + : 'direction' in result && !('operation' in result) + if (!settled) return true + } + } + return false + } + + private async payloadHead(id: string, bytes: number): Promise { + try { + const file = await open(this.path(id), 'r') + try { + const buffer = Buffer.alloc(bytes) + const { bytesRead } = await file.read(buffer, 0, bytes, 0) + return buffer.subarray(0, bytesRead).toString('utf8') + } finally { await file.close() } + } catch { return null } + } + + // Call ids and payload digests named by quarantine files whose recovery is + // not yet accepted (this.guards is already filtered by acceptance). Rows in + // a quarantine may be damaged, so each line is parsed on its own and a + // line that does not parse still yields every digest-shaped token in it. + private async quarantineEvidence(): Promise<{ callIds: Set; digests: Set }> { + const callIds = new Set() + const digests = new Set() + for (const guard of this.guards) { + if (!QUARANTINE.test(guard.file)) continue + const text = await readFile(join(this.directory, guard.file), 'utf8').catch(() => '') + for (const line of text.split('\n')) { + for (const digest of line.match(/[a-f0-9]{64}/g) ?? []) digests.add(digest) + try { + const row = JSON.parse(line) as { callId?: unknown } + if (typeof row.callId === 'string') callIds.add(row.callId) + } catch { /* a damaged line: its digests were collected above */ } + } + } + return { callIds, digests } + } + + private async hasUnreferencedPayloads(events: HistoryEvent[], protectedDigests: Set): Promise { + const referenced = new Set([...events.flatMap(event => event.payload ? [event.payload] : []), ...protectedDigests].map(digest => `${digest}.json`)) + return (await readdir(join(this.directory, 'payloads'))).some(name => /^[a-f0-9]{64}\.json$/.test(name) && !referenced.has(name)) } private async recover(bytes: Buffer, analysis: Analysis): Promise { diff --git a/src/main/control/history/control.ts b/src/main/control/history/control.ts index f86110eca..78721c097 100644 --- a/src/main/control/history/control.ts +++ b/src/main/control/history/control.ts @@ -5,7 +5,7 @@ export function historyCapabilities(history: ControlHistory) { return [ defineCapability({ id: 'history.read', title: 'Inspect one control call', execution: 'main', effect: 'read', - description: 'Return every durable event and payload reference for a call, including retries and unresolved outcomes. A call without a request key that finished more than 7 days ago reads as not_found. Retrieve each payload with history.payloadRead.', + description: 'Return every durable event and payload reference for a call, including retries and unresolved outcomes. A settled call without a request key that finished more than 7 days ago (not a task origin, not an unknown or pending outcome) reads as not_found. Retrieve each payload with history.payloadRead.', input: z.object({ callId: z.string().min(1).describe('operation.callId from a tool result, or callId from history.list.') }).strict(), output: z.object({ callId: z.string(), events: z.array(historyEventSchema), relatedCalls: z.array(z.string()), state: z.enum(['recorded', 'outcome_unknown', 'not_found']) }), @@ -21,7 +21,7 @@ export function historyCapabilities(history: ControlHistory) { // The retention window is part of what this tool promises (#1274): a page // is never clipped, but calls outside the window are gone from the // journal, and an agent reading an empty result must know why. - description: 'Read durable invocation events. Calls with a request key, and calls without a result, are kept; other calls are kept for 7 days. Carry snapshot through paging so reading history does not chase its own new records.', + description: 'Read durable invocation events. Kept indefinitely: calls with a request key, calls without a result or whose outcome is unknown or still pending, lifecycle task origins, calls a kept retry reuses, and calls named by unaccepted recovery evidence. Other finished calls are kept for 7 days. Carry snapshot through paging so reading history does not chase its own new records.', input: z.object({ after: z.number().int().nonnegative().default(0).describe('Exclusive event sequence boundary; use nextAfter from the previous page.'), snapshot: z.number().int().nonnegative().optional().describe('Keep the first page’s snapshot unchanged to finish a finite history read while new calls are recorded.'), limit: z.number().int().min(1).max(200).default(50).describe('Maximum events per page; a page is never silently truncated.'), callId: z.string().optional().describe('Optional exact call ID to filter events.') }).strict(), output: z.object({ events: z.array(historyEventSchema), snapshot: z.number().int(), nextAfter: z.number().int().nullable(), complete: z.boolean() }), diff --git a/testing/fixtures/control-history/retention-rows-2026-09-27.json b/testing/fixtures/control-history/retention-rows-2026-09-27.json index 5fd6b63a1..68ae974a6 100644 --- a/testing/fixtures/control-history/retention-rows-2026-09-27.json +++ b/testing/fixtures/control-history/retention-rows-2026-09-27.json @@ -1,5 +1,5 @@ { - "source": "Owner's ~/.config/agent-code/control-history/events.jsonl, recorded 2026-09-27: every row of 8 real calls, in file order, sequences renumbered 1..n (process-local cursors). Rows carry only ids, times, callers, capability ids, request keys and payload digests; prompts and results live in payloads/, which are NOT included. Calls: 2 unkeyed finished calls from 2026-09-05 (transcripts.page, agents.read); a keyed dispatch.configure call and the keyed duplicate that reuses it; 2 unkeyed calls from 2026-09-27; an unkeyed externalControl.status call from 2026-09-05 whose received payload digest is shared with the keyed app.windowFocus call after it.", + "source": "Owner's ~/.config/agent-code/control-history/events.jsonl, recorded 2026-09-27: every row of 8 real calls, in file order, sequences renumbered 1..n (process-local cursors). Rows carry only ids, times, callers, capability ids, request keys and payload digests; prompts and results live in payloads/, which are NOT included. Calls: 2 unkeyed finished calls from 2026-09-05 (transcripts.page, agents.read); a keyed dispatch.configure call and the keyed duplicate that reuses it; 2 unkeyed calls from 2026-09-27; an unkeyed externalControl.status call from 2026-09-05. The `dispatched` rows of externalControl.status, transcripts.page and the keyed app.windowFocus call name the same payload digest (e5624e8c...; corrected in #1330 review B5).", "rows": [ { "sequence": 1, From b3444f0440a174b6f377ca8b3bd5f8aca53cfa7f Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sat, 26 Sep 2026 18:30:05 -0700 Subject: [PATCH 04/25] fix(control): retention survives GC failures, scans torn-tail quarantines, parses task steps (#1330 round 2) A payload deletion failing after the rewrite made open() serve the old rows and the next append corrupt the journal; a torn-tail quarantine was never scanned, so its only-named payload was deleted; task steps were detected by byte prefix. Rewritten rows are always served, every unaccepted quarantine is evidence, and steps are parsed. Refs #1274 Co-Authored-By: Claude Opus 5.5 --- .../2026-09-26-control-history-retention.md | 8 ++ .../history/FileControlHistory.test.ts | 66 +++++++++++++++ .../control/history/FileControlHistory.ts | 80 ++++++++++++------- 3 files changed, 126 insertions(+), 28 deletions(-) diff --git a/docs/plans/2026-09-26-control-history-retention.md b/docs/plans/2026-09-26-control-history-retention.md index cd8242592..9f00ade3a 100644 --- a/docs/plans/2026-09-26-control-history-retention.md +++ b/docs/plans/2026-09-26-control-history-retention.md @@ -48,3 +48,11 @@ Other changes: - **Fixture metadata (B5).** The shared digest is on the `dispatched` rows. **Owner question (unchanged, still open):** the window length (default 7 days). With the retained roots above, what a window deletes is only settled, unkeyed, non-task calls: exact request/result copies of reads and completed mutations, which `history.read`/`list` then no longer find. On the owner's store today: 7 days keeps ~520 calls and ~20 MB of payloads; 14 days keeps ~884 calls and ~52 MB. + +## Review round 2 (#1330) +- **A GC failure after a successful rewrite (a, b, c; Blocker).** The whole prune rejected, and `open()` served the pre-rewrite rows. The next append numbered itself from that longer list, and the gap got the journal quarantined with keyed calls blocked. Once the rewrite lands, the rewritten rows are always what is served. Each payload deletion fails on its own (warned, retried next launch). +- **A torn-tail quarantine (a, b, c; Major).** It has no guard (it blocks no keyed call), so it was never scanned, and the payload only its torn line names (an outcome fsynced before the append tore) was deleted on the next launch. Evidence is now read from **every** quarantine file whose digest is not accepted. A damaged line still yields its digests, and its call id when visible. An unreadable quarantine file skips retention for that launch. +- **Task detection by byte prefix (a, b, c).** It missed a valid step with another key order and treated a corrupted step as ordinary. It now parses the step through the integrity-checked `payload()`, as the task store does. Unreadable or not an object means kept. +- **First-launch cost (b, c; Minor): accepted as a residual.** Classifying the backlog reads each old result payload once. On a clone of the owner's store the first load took 3.2–6.9 s, and later loads take about 35 ms. It runs on the first control-history use after the upgrade, not at app start. A byte-sniffing shortcut was rejected: that is exactly the kind of guess the task-prefix bug was. + +Tests: 3 new cases (red on `65175615`). They also kill round 2's surviving mutations: the task guard is now exercised with a settled result, and quarantine-only digests with a torn tail. diff --git a/src/main/control/history/FileControlHistory.test.ts b/src/main/control/history/FileControlHistory.test.ts index a65892b80..9edae12a4 100644 --- a/src/main/control/history/FileControlHistory.test.ts +++ b/src/main/control/history/FileControlHistory.test.ts @@ -712,4 +712,70 @@ describe('control history retention (#1274)', () => { expect(await kept(directory, [inside.callId, outside.callId])).toEqual([true, false]) expect(CONTROL_HISTORY_RETENTION_MS).toBe(7 * 24 * 60 * 60 * 1000) }) + + // #1330 round 2 (a, b, c; Blocker): once the rewrite has landed, a failed + // payload deletion must not put the pre-rewrite rows back in memory. Those + // made the next append number itself past the end of the shorter file, and + // the gap got the journal quarantined with every keyed call blocked. + it('serves the rewritten rows when a payload deletion fails after the rewrite', async () => { + const { directory } = await setup() + const old = await call(directory, { at: OLD, result: settled() }) + const recent = await call(directory, { at: RETENTION_NOW.toISOString(), result: settled() }) + await journal(directory, [old, recent]) + // A digest-named DIRECTORY in payloads/: rm() without `recursive` fails. + const { mkdir } = await import('node:fs/promises') + await mkdir(join(directory, 'payloads', `${'e'.repeat(64)}.json`)) + const history = open(directory) + const events = await history.events() + expect(new Set(events.map(event => event.callId))).toEqual(new Set([recent.callId])) + expect(events.map(event => event.sequence)).toEqual(recent.rows.map((_, index) => index + 1)) + // The next append continues the file it is really appending to. + const appended = await history.append({ callId: 'next', instanceId: 'recorded', capabilityId: 'agents.read', caller: 'external:agent-code-control', kind: 'received', at: RETENTION_NOW.toISOString() }) + expect(appended.sequence).toBe(recent.rows.length + 1) + const reports: unknown[] = [] + await open(directory, () => reports.push(1)).events() + expect(reports).toEqual([]) + }) + + // #1330 round 2 (a, b, c): a torn tail blocks no keyed call, so it has no + // guard, but its torn line can be the only row naming a result payload + // fsynced before the append tore. Kept until the operator accepts it. + it('keeps a payload that only an unaccepted torn-tail quarantine names', async () => { + const { directory } = await setup() + const torn = await call(directory, { at: OLD }) + const unknownResult = await writePayload(directory, JSON.stringify({ ok: false, error: { code: 'unavailable', message: 'lost', outcome: 'unknown' }, operation: { callId: torn.callId, instanceId: 'recorded', status: 'outcome_unknown' } })) + await journal(directory, [torn]) + await appendFile(join(directory, 'events.jsonl'), JSON.stringify({ sequence: 3, at: OLD, instanceId: 'recorded', callId: torn.callId, kind: 'result', capabilityId: 'agents.resume', caller: 'external:agent-code-control', payload: unknownResult }).slice(0, -2)) + const reports: Array<{ sha256: string; kind: string }> = [] + await new FileControlHistory(directory, { now: () => RETENTION_NOW, onRecovered: report => reports.push(report as never) }).events() + expect(reports).toEqual([expect.objectContaining({ kind: 'torn-tail' })]) + await open(directory).events() + expect(await open(directory).payload(unknownResult)).toMatchObject({ error: { outcome: 'unknown' } }) + // Accepted, it is ordinary garbage (no kept row names it). + await writeFile(join(directory, 'recovery-accepted.json'), JSON.stringify({ accepted: [reports[0]!.sha256] })) + await open(directory).events() + await expect(open(directory).payload(unknownResult)).rejects.toThrow() + }) + + // #1330 round 2 (a, b, c): a task origin is recognised by what its step + // payload SAYS (the task store parses it), not by its first bytes, and a + // step that cannot be read is unknown, so kept. Settled results here, so + // only the task rule can keep these calls. + it('keeps a settled task origin whose step has another key order, or cannot be read', async () => { + const { directory } = await setup() + const owner = { kind: 'main' as const, generation: 'g' } + const reordered = await call(directory, { at: OLD, result: settled(), steps: [ + { owner, step: 'task.started' }, + { result: { ok: true, value: { newSessionId: 'new' } }, step: 'task.finished' }, + ] }) + const corrupted = await call(directory, { at: OLD, result: settled(), steps: [{ step: 'task.started', owner }] }) + await journal(directory, [reordered, corrupted]) + const stepDigest = corrupted.rows.find(row => row.kind === 'step')!.payload! + await writeFile(join(directory, 'payloads', `${stepDigest}.json`), '{ "tampered": true }') + const history = open(directory) + const read = taskHistoryCapabilities(history, () => false).find(item => item.descriptor.id === 'operations.read')! + expect(await read.execute({ callId: reordered.callId }, { requestId: 'read', owner, caller: { kind: 'external', id: 'operator' } })) + .toMatchObject({ ok: true, value: { status: 'completed' } }) + expect(await kept(directory, [reordered.callId, corrupted.callId])).toEqual([true, true]) + }) }) diff --git a/src/main/control/history/FileControlHistory.ts b/src/main/control/history/FileControlHistory.ts index 9cf28dc14..5abbb6e74 100644 --- a/src/main/control/history/FileControlHistory.ts +++ b/src/main/control/history/FileControlHistory.ts @@ -212,15 +212,27 @@ export class FileControlHistory implements ControlHistory { // at a deleted payload. It also collects orphans from appends that stored // a payload and then failed. A digest any kept row or unaccepted // quarantine names stays. + // + // WHY GC can no longer fail the prune (#1330 round 2, all three; Blocker): + // once the rewrite has landed, the rows on disk ARE `renumbered`, so they + // are what must be served whatever happens next. A failed `rm` used to + // reject the whole prune, open() then served the pre-rewrite rows, the + // next append numbered itself from that longer list, and the gap got the + // journal quarantined with every keyed call blocked. Each deletion now + // fails on its own, and a leftover file is collected next launch. + const served = expired.size > 0 ? renumbered : events const referenced = new Set([...kept.flatMap(event => event.payload ? [event.payload] : []), ...evidence.digests].map(digest => `${digest}.json`)) - for (const name of await readdir(join(this.directory, 'payloads'))) { + const names = await readdir(join(this.directory, 'payloads')).catch(() => [] as string[]) + for (const name of names) { // Only finished payload files: a `.tmp` belongs to a write that has not // renamed yet (none can be in flight here, but that is the writer's // business, not GC's). if (!/^[a-f0-9]{64}\.json$/.test(name) || referenced.has(name)) continue - await rm(join(this.directory, 'payloads', name), { force: true }) + await rm(join(this.directory, 'payloads', name), { force: true }).catch(error => { + console.warn('[control-history] could not remove an unreferenced payload; retrying next launch:', (error as NodeJS.ErrnoException).code ?? 'error') + }) } - return expired.size > 0 ? renumbered : events + return served } // Whether an old, unkeyed, finished call must be kept anyway: its evidence @@ -241,8 +253,12 @@ export class FileControlHistory implements ControlHistory { private async mustOutliveRetention(rows: HistoryEvent[]): Promise { for (const row of rows) { if (row.kind === 'step' && row.payload) { - const head = await this.payloadHead(row.payload, 32) - if (head === null || head.startsWith('{"step":"task.')) return true + // Parsed through the same integrity-checked read the task store uses + // (#1330 round 2): a byte-prefix test missed a valid step written + // with another key order, and treated a corrupted step as an ordinary + // one. Unreadable or not an object: unknown, so kept. + const step = await this.payload(row.payload).catch(() => undefined) as { step?: unknown } | undefined + if (!step || typeof step !== 'object' || (typeof step.step === 'string' && step.step.startsWith('task.'))) return true } if (row.kind === 'result') { if (!row.payload) return true @@ -261,33 +277,37 @@ export class FileControlHistory implements ControlHistory { return false } - private async payloadHead(id: string, bytes: number): Promise { - try { - const file = await open(this.path(id), 'r') - try { - const buffer = Buffer.alloc(bytes) - const { bytesRead } = await file.read(buffer, 0, bytes, 0) - return buffer.subarray(0, bytesRead).toString('utf8') - } finally { await file.close() } - } catch { return null } - } - - // Call ids and payload digests named by quarantine files whose recovery is - // not yet accepted (this.guards is already filtered by acceptance). Rows in - // a quarantine may be damaged, so each line is parsed on its own and a - // line that does not parse still yields every digest-shaped token in it. + // Call ids and payload digests named by every quarantine file whose digest + // is not accepted. WHY every file and not `this.guards` (#1330 round 2, all + // three): guards drop a torn-tail quarantine because it blocks no keyed + // call, but its torn last line can be the ONLY row naming a result payload + // that was fsynced before the append tore, and that payload is the outcome + // the operator reconciles against. Evidence retention asks "has the + // operator accepted this file", not "does it block anything". Rows in a + // quarantine may be damaged, so each line is parsed on its own and a line + // that does not parse still yields every digest-shaped token in it. private async quarantineEvidence(): Promise<{ callIds: Set; digests: Set }> { const callIds = new Set() const digests = new Set() - for (const guard of this.guards) { - if (!QUARANTINE.test(guard.file)) continue - const text = await readFile(join(this.directory, guard.file), 'utf8').catch(() => '') - for (const line of text.split('\n')) { + const accepted = await this.acceptedDigests() + for (const name of await readdir(this.directory)) { + if (!QUARANTINE.test(name)) continue + const bytes = await readFile(join(this.directory, name)).catch(() => null) + // Unreadable evidence cannot be scanned, and unknown must not delete: + // skip retention for this launch entirely. + if (bytes === null) throw new Error('quarantine evidence unreadable') + if (accepted.has(createHash('sha256').update(bytes).digest('hex'))) continue + for (const line of bytes.toString('utf8').split('\n')) { for (const digest of line.match(/[a-f0-9]{64}/g) ?? []) digests.add(digest) try { const row = JSON.parse(line) as { callId?: unknown } if (typeof row.callId === 'string') callIds.add(row.callId) - } catch { /* a damaged line: its digests were collected above */ } + } catch { + // A damaged line: its digests were collected above, and a call id + // is recovered from its text when one is visible. + const callId = /"callId":"([^"]+)"/.exec(line)?.[1] + if (callId) callIds.add(callId) + } } } return { callIds, digests } @@ -376,12 +396,16 @@ export class FileControlHistory implements ControlHistory { // that analysis lifted a block nobody accepted (#1254 review A). The // record's own digest is what accepts it. guards.push(...records) - let accepted = new Set() + const accepted = await this.acceptedDigests() + return guards.filter(guard => (guard.keyedCallsBlocked || guard.blockedPairs.length > 0) && !accepted.has(guard.sha256)) + } + + private async acceptedDigests(): Promise> { try { const parsed = JSON.parse(await readFile(join(this.directory, ACCEPTED), 'utf8')) as { accepted?: unknown } - if (Array.isArray(parsed.accepted)) accepted = new Set(parsed.accepted.filter((id): id is string => typeof id === 'string')) + if (Array.isArray(parsed.accepted)) return new Set(parsed.accepted.filter((id): id is string => typeof id === 'string')) } catch { /* absent or unreadable: nothing accepted, the conservative reading */ } - return guards.filter(guard => (guard.keyedCallsBlocked || guard.blockedPairs.length > 0) && !accepted.has(guard.sha256)) + return new Set() } private refusal(write: HistoryWrite): string | null { From c64dfedaaf6535ee9d7509f6eec8101686182bdd Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sun, 27 Sep 2026 00:20:42 -0700 Subject: [PATCH 05/25] docs(plan): the remaining load- and build-dependent tests (#1107) Co-Authored-By: Claude Opus 5.5 --- docs/plans/2026-09-27-wall-clock-tests.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 docs/plans/2026-09-27-wall-clock-tests.md diff --git a/docs/plans/2026-09-27-wall-clock-tests.md b/docs/plans/2026-09-27-wall-clock-tests.md new file mode 100644 index 000000000..f0b711ae0 --- /dev/null +++ b/docs/plans/2026-09-27-wall-clock-tests.md @@ -0,0 +1,17 @@ +# Tests whose outcome depends on load or build state (#1107, remaining sites) + +## Remaining sites (from the issue and its correction comment) +| Site | Cause | Decision | +|---|---|---| +| `lspServerCreation.system.test.ts` | fixed `setTimeout(20)` | already fixed in #1108 | +| `lazy-prose/index.renderer.test.tsx` (#700) | The first compile of the lazily imported Markdown chunk is measured by a 5 s `findByText`, which equals Vitest's 5 s test default, so its useful message ("never appeared") can never fire | **Load the chunk in `beforeAll`**, awaiting the import itself. `findByText` then measures only the render, with its own default and a real message. | +| `sessionManager.terminalReplay.test.ts` | Dynamic `import()`s of `@xterm/headless` and `sessionManager`'s whole module graph run inside the test body, against the 5 s test budget | **Static imports.** `vi.mock` is hoisted, so the mocks still apply. Module loading moves to collection, outside the test's budget. | +| `workflows/control.system.test.ts` | Forks the BUILT workflow worker (`packages/workflow-mcp/dist/workflowWorker.js`). In a fresh worktree `dist` doesn't exist; the package throws `worker-missing`, the test only sees the operation never completing, and it times out after 5 s with no reason. | **A precondition** that fails at once, saying the worker must be built (`npm run build` in `packages/workflow-mcp`). CI builds it, so CI is unaffected. The test does not build it itself: a build inside a unit of the suite would be slow, and racy across parallel workers. | + +Rules held: +- No budget is widened. +- Each wait is either on its condition or on the real module load. + +## Evidence +- The fresh worktree has no `packages/workflow-mcp/dist`. On `origin/main` the control test fails after 5 s with a `waitFor` timeout; after the fix, it fails immediately with the precondition message. +- For the other two, the load moves out of the test body. The PR records the measured load time that used to count against the budget. From 36d38fa82118826e14a522b357eb2d79453e9344 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sun, 27 Sep 2026 00:30:05 -0700 Subject: [PATCH 06/25] test: wait on the load or the build, not on a budget meant for assertions (#1107) The remaining #1107 sites (lsp half landed in #1108): - lazy-prose (#700): the first compile of the split Markdown chunk was measured by a 5 s findByText, the same number as Vitest's 5 s test default, so the useful "never appeared" message could not fire. The chunk is now imported in beforeAll (awaiting the import, not a timer); findByText measures only the render, with its default. - terminalReplay: SessionManager's whole module graph and @xterm/headless were import()ed inside the test body against its 5 s budget. Static imports (vi.mock stays hoisted) move the load to collection. - workflows/control.system: forks the BUILT workflow worker; without packages/workflow-mcp/dist it timed out after 5 s with no reason. A precondition now fails at once and says to build it. Evidence: control on origin/main without dist: 5010 ms timeout; now 3 ms with the build instruction; with dist built: passes in 380 ms. Co-Authored-By: Claude Opus 5.5 --- src/main/sessionManager.terminalReplay.test.ts | 9 +++++++-- src/main/workflows/control.system.test.ts | 10 ++++++++++ .../lazy-prose/index.renderer.test.tsx | 18 ++++++++++++------ 3 files changed, 29 insertions(+), 8 deletions(-) diff --git a/src/main/sessionManager.terminalReplay.test.ts b/src/main/sessionManager.terminalReplay.test.ts index 943e8da31..9470e1e85 100644 --- a/src/main/sessionManager.terminalReplay.test.ts +++ b/src/main/sessionManager.terminalReplay.test.ts @@ -1,6 +1,9 @@ import { EventEmitter } from 'node:events' +import { Terminal } from '@xterm/headless' import { expect, it, vi } from 'vitest' +import { SessionManager } from './sessionManager' + // #843 / #1041 review: the SHELL attach path (attachTerminal) must replay the // modes its evicted bytes set, like the agent path. Reverting it to read() // passed every test until this one. A full-screen program run from a shell @@ -26,9 +29,11 @@ vi.mock('@main/performance/PerformanceService.js', () => ({ })) vi.mock('@main/storage/feedDebugLog.js', () => ({ forgetFeedDebugSession: vi.fn() })) +// WHY static imports (#1107): the dynamic import()s that stood here loaded SessionManager's whole +// module graph INSIDE the test body, against the 5 s test budget, so the test passed alone and +// timed out under full-suite parallelism. vi.mock is hoisted above these imports, so the mocks +// still apply; the load now happens at collection, outside any test's budget. it('a remounted shell terminal gets the alternate screen and mouse mode its evicted bytes set', async () => { - const { Terminal } = await import('@xterm/headless') - const { SessionManager } = await import('./sessionManager') // No tmux: a direct PTY terminal, the case every machine without tmux runs. const manager = new SessionManager({ isAvailable: () => false, getBinary: () => null } as never) const { sessionId } = await manager.spawn({ kind: 'terminal', cwd: '/tmp/project' }) diff --git a/src/main/workflows/control.system.test.ts b/src/main/workflows/control.system.test.ts index 6f6f857a7..e69bdf3e2 100644 --- a/src/main/workflows/control.system.test.ts +++ b/src/main/workflows/control.system.test.ts @@ -1,3 +1,4 @@ +import { existsSync } from 'node:fs' import { mkdtemp, mkdir, copyFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -10,9 +11,18 @@ import { taskHistoryCapabilities } from '@main/control/history/tasks' import { workflowControlCapabilities } from './control' // This starts the real isolated workflow worker, so it belongs to the system // tier even though the workflow fixture never launches a provider agent. +// +// WHY a precondition (#1107): the worker is forked from the BUILT package +// (packages/workflow-mcp/dist/workflowWorker.js; see workerFilePath in runWorkflow.ts). A fresh git +// worktree has no dist, the package throws `worker-missing` inside the run, and all this test used +// to see was an operation that never completed — a 5 s waitFor timeout that looked like load. It +// now fails at once and says why. It does not build the package itself: a build inside one test is +// slow and races parallel workers; CI builds it before the suite. +const BUILT_WORKER = 'packages/workflow-mcp/dist/workflowWorker.js' const directories: string[] = [], services: WorkflowService[] = [] afterEach(async () => { await Promise.all(services.splice(0).map(service => service.quiesce())); await Promise.all(directories.splice(0).map(path => rm(path, { recursive: true, force: true }))) }) it('admits a main-host task before source approval and persists external ownership through a real existing workflow run', async () => { + expect(existsSync(BUILT_WORKER), `${BUILT_WORKER} is missing: run \`npm run build\` in packages/workflow-mcp (this test forks the built worker)`).toBe(true) const cwd = await mkdtemp(join(tmpdir(), 'ac-workflow-operator-')); directories.push(cwd) await mkdir(join(cwd, '.claude/workflows'), { recursive: true }) await copyFile('packages/workflow-mcp/test/fixtures/workflow-corpus/minimal.js', join(cwd, '.claude/workflows/minimal.js')) diff --git a/src/providers/shared/renderer/components/lazy-prose/index.renderer.test.tsx b/src/providers/shared/renderer/components/lazy-prose/index.renderer.test.tsx index cbfb3dd10..08c9938cb 100644 --- a/src/providers/shared/renderer/components/lazy-prose/index.renderer.test.tsx +++ b/src/providers/shared/renderer/components/lazy-prose/index.renderer.test.tsx @@ -1,16 +1,22 @@ import { render, screen } from '@testing-library/react' -import { describe, expect, it } from 'vitest' +import { beforeAll, describe, expect, it } from 'vitest' import { LazyTextProse } from '@providers/shared/renderer/components/lazy-prose' describe('LazyTextProse browser boundary', () => { + // WHY load the split chunk here (#1107, #700): its first transform under Vitest can take seconds + // on a loaded machine, and the test used to measure that with a 5 s findByText — the same number + // as Vitest's 5 s test default, so the test-level timeout always won and the useful message + // ("the element never appeared") could never fire. Awaiting the import itself (not a timer) moves + // compiler warm-up out of the assertion; production reuses a loaded chunk the same way. The lazy + // boundary is still exercised: LazyTextProse resolves its own import() on render. + beforeAll(async () => { + await import('@renderer/features/feed/ui/markdown') + }) + it('loads the bounded Markdown surface on demand in a renderer environment', async () => { render() - // The first transform of the split Markdown chunk can exceed Testing - // Library's one-second default in CI/dev Vitest; production reuses the - // loaded chunk. This timeout tests eventual ownership without turning - // compiler warm-up into a renderer failure. - const strong = await screen.findByText('Evidence-backed', {}, { timeout: 5_000 }) + const strong = await screen.findByText('Evidence-backed') expect(strong.tagName).toBe('STRONG') expect(strong.parentElement).toHaveTextContent('Evidence-backed rendering') }) From 70303a8e59f502bf122c008785aee6803b7f77f7 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sun, 27 Sep 2026 00:49:25 -0700 Subject: [PATCH 07/25] feat(control): 90-day control-history window, the owner's decision (#1330) Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-09-26-control-history-retention.md | 7 +++++++ .../control/history/FileControlHistory.test.ts | 9 +++++++-- src/main/control/history/FileControlHistory.ts | 15 ++++++++++----- src/main/control/history/control.ts | 4 ++-- 4 files changed, 26 insertions(+), 9 deletions(-) diff --git a/docs/plans/2026-09-26-control-history-retention.md b/docs/plans/2026-09-26-control-history-retention.md index 9f00ade3a..495b498e8 100644 --- a/docs/plans/2026-09-26-control-history-retention.md +++ b/docs/plans/2026-09-26-control-history-retention.md @@ -56,3 +56,10 @@ Other changes: - **First-launch cost (b, c; Minor): accepted as a residual.** Classifying the backlog reads each old result payload once. On a clone of the owner's store the first load took 3.2–6.9 s, and later loads take about 35 ms. It runs on the first control-history use after the upgrade, not at app start. A byte-sniffing shortcut was rejected: that is exactly the kind of guess the task-prefix bug was. Tests: 3 new cases (red on `65175615`). They also kill round 2's surviving mutations: the task guard is now exercised with a settled result, and quarantine-only digests with a torn tail. + +## Owner decision (2026-09-27) +**The window is 90 days** (`temp/manager/owner-decisions-2026-09-27.md`: "#1330, control history: 90 days", with the standing preference "infrequent deletion"). This replaces the UNCONFIRMED 7-day default. +- `CONTROL_HISTORY_RETENTION_MS = 90 days`, pinned by value in the boundary test. +- Both `history.read` / `history.list` descriptions now say 90 days. +- **Tests:** the recorded-store retention cases move "now" 83 days later, the amount the window grew. Every recorded call keeps its position relative to the edge, so the same rows are pruned and kept. +- **Cost:** on the owner's store today, 30 days already kept everything, so the 90-day window deletes nothing yet. At ~5 MB of payloads a day, steady state is up to ~450 MB. What stays bounded is long-run growth of a journal held whole in memory. diff --git a/src/main/control/history/FileControlHistory.test.ts b/src/main/control/history/FileControlHistory.test.ts index 9edae12a4..0ff430c85 100644 --- a/src/main/control/history/FileControlHistory.test.ts +++ b/src/main/control/history/FileControlHistory.test.ts @@ -475,7 +475,11 @@ const retentionRows = JSON.parse(await readFile(join(import.meta.dirname, '../../../../testing/fixtures/control-history/retention-rows-2026-09-27.json'), 'utf8')) as { rows: Array<{ sequence: number; callId: string; kind: string; payload?: string; requestKey?: string }> } -const RETENTION_NOW = new Date('2026-09-27T12:00:00.000Z') +// "Now" for retention cases. It was 2026-09-27 against a 7-day window; the +// owner's 90-day window (2026-09-27) is 83 days longer, so "now" moves 83 days +// later. Every recorded call then sits exactly where it did relative to the +// window's edge, and the same rows are pruned and kept. +const RETENTION_NOW = new Date('2026-12-19T12:00:00.000Z') const OLD_UNKEYED = ['95caa49c-eb60-44e2-9b62-938ce2243d11', '5ea10842-a1c8-463c-aee9-5d0e238c56e1', '37763f2f-c479-49b7-8d79-4c990bc0784c'] const KEPT = ['95c97fce-bca0-4bc7-9901-90b45d988d1c', '13e43d53-5120-41ae-88d7-820dc9088728', 'a346f752-9eec-4b37-b7df-145dfaf1aaf5', 'e8a21d19-b139-4012-8e09-6139b5643bb5', '2ebe82c0-c877-4bb6-9877-56ded38d4739'] @@ -710,7 +714,8 @@ describe('control history retention (#1274)', () => { const outside = await call(directory, { at: new Date(RETENTION_NOW.getTime() - CONTROL_HISTORY_RETENTION_MS - minute).toISOString(), result: settled() }) await journal(directory, [inside, outside]) expect(await kept(directory, [inside.callId, outside.callId])).toEqual([true, false]) - expect(CONTROL_HISTORY_RETENTION_MS).toBe(7 * 24 * 60 * 60 * 1000) + // The owner's 90-day window (2026-09-27); pinned by value, not only through the constant. + expect(CONTROL_HISTORY_RETENTION_MS).toBe(90 * 24 * 60 * 60 * 1000) }) // #1330 round 2 (a, b, c; Blocker): once the rewrite has landed, a failed diff --git a/src/main/control/history/FileControlHistory.ts b/src/main/control/history/FileControlHistory.ts index 5abbb6e74..e1416d909 100644 --- a/src/main/control/history/FileControlHistory.ts +++ b/src/main/control/history/FileControlHistory.ts @@ -57,11 +57,16 @@ const EXECUTOR_KINDS = new Set(['received', 'dispatched', 'result', 'dup // the marker: its block cannot be read, so it stays a global block of its own. const INVALID_MARKER = /^recovery\.invalid-.+\.json$/ // How long a call that can never be looked up for dedupe stays readable -// (#1274). UNCONFIRMED product default: history.read/list serve an agent or -// operator inspecting recent work, which is hours, not weeks. On the owner's -// rate (~5 MB of payloads a day, nearly all transcripts.page reads) a week is -// about 20-35 MB instead of an unbounded journal held whole in memory. -export const CONTROL_HISTORY_RETENTION_MS = 7 * 24 * 60 * 60 * 1000 +// (#1274). OWNER DECISION (2026-09-27, temp/manager/owner-decisions-2026-09-27.md): +// 90 days, with the standing preference "infrequent deletion": default to +// longer retention. The PR first proposed 7 days (an agent inspecting its +// own work looks back hours, not weeks). The owner chose the longer window. +// Cost, on the owner's rate (~5 MB of payloads a day, nearly all +// transcripts.page reads): up to ~450 MB at steady state. Today's store is +// under 30 days old, so this window deletes nothing yet. What it bounds is +// the long run: the journal, which is held whole in memory, no longer grows +// without limit. +export const CONTROL_HISTORY_RETENTION_MS = 90 * 24 * 60 * 60 * 1000 const recoveryFileSchema = z.object({ quarantines: z.array(z.object({ file: z.string().refine(name => QUARANTINE.test(name) || INVALID_MARKER.test(name)), diff --git a/src/main/control/history/control.ts b/src/main/control/history/control.ts index 78721c097..9d0d5aae1 100644 --- a/src/main/control/history/control.ts +++ b/src/main/control/history/control.ts @@ -5,7 +5,7 @@ export function historyCapabilities(history: ControlHistory) { return [ defineCapability({ id: 'history.read', title: 'Inspect one control call', execution: 'main', effect: 'read', - description: 'Return every durable event and payload reference for a call, including retries and unresolved outcomes. A settled call without a request key that finished more than 7 days ago (not a task origin, not an unknown or pending outcome) reads as not_found. Retrieve each payload with history.payloadRead.', + description: 'Return every durable event and payload reference for a call, including retries and unresolved outcomes. A settled call without a request key that finished more than 90 days ago (not a task origin, not an unknown or pending outcome) reads as not_found. Retrieve each payload with history.payloadRead.', input: z.object({ callId: z.string().min(1).describe('operation.callId from a tool result, or callId from history.list.') }).strict(), output: z.object({ callId: z.string(), events: z.array(historyEventSchema), relatedCalls: z.array(z.string()), state: z.enum(['recorded', 'outcome_unknown', 'not_found']) }), @@ -21,7 +21,7 @@ export function historyCapabilities(history: ControlHistory) { // The retention window is part of what this tool promises (#1274): a page // is never clipped, but calls outside the window are gone from the // journal, and an agent reading an empty result must know why. - description: 'Read durable invocation events. Kept indefinitely: calls with a request key, calls without a result or whose outcome is unknown or still pending, lifecycle task origins, calls a kept retry reuses, and calls named by unaccepted recovery evidence. Other finished calls are kept for 7 days. Carry snapshot through paging so reading history does not chase its own new records.', + description: 'Read durable invocation events. Kept indefinitely: calls with a request key, calls without a result or whose outcome is unknown or still pending, lifecycle task origins, calls a kept retry reuses, and calls named by unaccepted recovery evidence. Other finished calls are kept for 90 days. Carry snapshot through paging so reading history does not chase its own new records.', input: z.object({ after: z.number().int().nonnegative().default(0).describe('Exclusive event sequence boundary; use nextAfter from the previous page.'), snapshot: z.number().int().nonnegative().optional().describe('Keep the first page’s snapshot unchanged to finish a finite history read while new calls are recorded.'), limit: z.number().int().min(1).max(200).default(50).describe('Maximum events per page; a page is never silently truncated.'), callId: z.string().optional().describe('Optional exact call ID to filter events.') }).strict(), output: z.object({ events: z.array(historyEventSchema), snapshot: z.number().int(), nextAfter: z.number().int().nullable(), complete: z.boolean() }), From acc070d8a611dbb6d6822d20d068696fd3b399d8 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sun, 27 Sep 2026 01:18:32 -0700 Subject: [PATCH 08/25] test(control): pin the exact retention edge as kept (#1330 verification) Co-Authored-By: Claude Opus 5.5 --- src/main/control/history/FileControlHistory.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/main/control/history/FileControlHistory.test.ts b/src/main/control/history/FileControlHistory.test.ts index 0ff430c85..0df2f1706 100644 --- a/src/main/control/history/FileControlHistory.test.ts +++ b/src/main/control/history/FileControlHistory.test.ts @@ -712,8 +712,12 @@ describe('control history retention (#1274)', () => { const minute = 60_000 const inside = await call(directory, { at: new Date(RETENTION_NOW.getTime() - CONTROL_HISTORY_RETENTION_MS + minute).toISOString(), result: settled() }) const outside = await call(directory, { at: new Date(RETENTION_NOW.getTime() - CONTROL_HISTORY_RETENTION_MS - minute).toISOString(), result: settled() }) - await journal(directory, [inside, outside]) - expect(await kept(directory, [inside.callId, outside.callId])).toEqual([true, false]) + // Exactly at the edge is still inside (#1330 verification: `>=` vs `>` + // survived every other case). With infrequent deletion as the owner's + // standing preference, the boundary instant is kept. + const edge = await call(directory, { at: new Date(RETENTION_NOW.getTime() - CONTROL_HISTORY_RETENTION_MS).toISOString(), result: settled() }) + await journal(directory, [inside, outside, edge]) + expect(await kept(directory, [inside.callId, outside.callId, edge.callId])).toEqual([true, false, true]) // The owner's 90-day window (2026-09-27); pinned by value, not only through the constant. expect(CONTROL_HISTORY_RETENTION_MS).toBe(90 * 24 * 60 * 60 * 1000) }) From d792da958665e46a6762f38216fb6b38db4ae8eb Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sun, 27 Sep 2026 01:22:20 -0700 Subject: [PATCH 09/25] docs(plans): Codex compaction boundary (#1289) Co-Authored-By: Claude Opus 5.5 --- .../2026-09-27-codex-compaction-boundary.md | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 docs/plans/2026-09-27-codex-compaction-boundary.md diff --git a/docs/plans/2026-09-27-codex-compaction-boundary.md b/docs/plans/2026-09-27-codex-compaction-boundary.md new file mode 100644 index 000000000..a8a4e24ee --- /dev/null +++ b/docs/plans/2026-09-27-codex-compaction-boundary.md @@ -0,0 +1,24 @@ +# Codex compaction never renders (#1289) + +## Evidence (verified 2026-09-27 on the local corpus, about 1,500 `compacted` lines) +1. **The branch never runs.** `mapCodexRolloutToFeedEntries` returns early when `payload.type` is not a string. A `compacted` line's payload has no `type`, so the `entry.type === 'compacted'` branch never runs and Codex compaction never renders. +2. **The boundary has no timestamp.** The boundary entry is built without one, and `rendering/model/order.ts` sorts timestamp-less rows to the end of their phase. So enabling the branch as-is would paint "Conversation compacted" at the bottom of the feed, not where it happened. +3. **`replacement_history` is not new conversation.** It is the context Codex retains across the compaction: developer instructions, the AGENTS.md block, earlier user prompts and (0.155+) a `compaction` item whose summary is `encrypted_content` (13–23 KB). Mapping it repaints prompts already in the feed (#1289: 17,326 of 20,343 sampled replacement messages duplicate an earlier user message). +4. **The summary `message` is empty in every 0.15x rollout.** Only 56 of about 1,500 compactions carry readable text, all from older CLIs. +5. **The boundary stores the whole payload as `compactMetadata`.** That is `replacement_history`, the retained user prompts, resume metadata and the encrypted blob, kept on a feed entry and in every debug bundle, although nothing reads more than identity. + +## Change +- Handle `compacted` before the `payload.type` guard. +- The boundary carries the line's timestamp, so it sorts where compaction happened. +- `replacement_history` is never mapped. The summary entry is emitted only when `message` is non-empty (older CLIs). +- `compactMetadata` keeps only the scalar identity fields (`window_number`, `window_id`, `previous_window_id`, `first_window_id`, `compaction_response_id`), not the retained history. +- Catalog: add Codex durable shapes for the boundary and the summary. They are the same shared.compaction dispositions Claude's entries have, pinned by curated fixtures. + +## Tests +Two real `compacted` lines: a 0.157.0 one (empty message, `compaction` item) and an older one with a readable message. Texts are redacted at equal length; the structure and keys are verbatim. +- Both map to a timestamped boundary. +- Only the older one also maps to a summary. +- No replacement-history entry is emitted. +- `compactMetadata` holds only the identity fields. +- Red on main, where both map to `[]`. +- The dispatcher renders the boundary through `shared.compaction`, and the catalog classifies it `known-claimed`. From f523ff384140581cd74c50df7892fc61d95b6b6d Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sun, 27 Sep 2026 01:37:06 -0700 Subject: [PATCH 10/25] fix(codex): render committed compaction as a timestamped boundary A compacted rollout line's payload has no type, so the mapper's early return swallowed every one (about 1,500 local lines) and Codex compaction never rendered. The dead branch also had two latent bugs. The boundary had no timestamp, so it would sort to the bottom of the feed. And it replayed replacement_history: the retained instructions and earlier prompts, which repainted prompts already in the feed (17,326 of 20,343 sampled replacement messages). Compaction is now handled before the type guard. It maps to a timestamped boundary, plus the summary only when the CLI wrote a readable message (older CLIs; 0.15x encrypts it). replacement_history is never replayed, and the boundary no longer embeds the whole payload (retained prompts plus a 13-23 KB encrypted blob) as compactMetadata, which nothing reads. Codex catalog entries are added for the durable boundary and summary. The curated-fixture sweep now names system entries type:subtype like the bundle sweep, which no curated fixture had exercised. Fixes #1289 Co-Authored-By: Claude Opus 5.5 --- .../2026-09-27-codex-compaction-boundary.md | 4 +- ...ction.committed.evidence.renderer.test.tsx | 74 +++ src/providers/codex/renderer/shapes.ts | 37 ++ .../codex/renderer/transcript/rollout.ts | 68 +-- .../rendering/evidence/bundleShapeSweep.ts | 11 +- .../codex/compaction/committed-compacted.json | 447 ++++++++++++++++++ 6 files changed, 609 insertions(+), 32 deletions(-) create mode 100644 src/providers/codex/renderer/compaction.committed.evidence.renderer.test.tsx create mode 100644 testing/fixtures/rendering-shapes/codex/compaction/committed-compacted.json diff --git a/docs/plans/2026-09-27-codex-compaction-boundary.md b/docs/plans/2026-09-27-codex-compaction-boundary.md index a8a4e24ee..a8466cac6 100644 --- a/docs/plans/2026-09-27-codex-compaction-boundary.md +++ b/docs/plans/2026-09-27-codex-compaction-boundary.md @@ -11,7 +11,7 @@ - Handle `compacted` before the `payload.type` guard. - The boundary carries the line's timestamp, so it sorts where compaction happened. - `replacement_history` is never mapped. The summary entry is emitted only when `message` is non-empty (older CLIs). -- `compactMetadata` keeps only the scalar identity fields (`window_number`, `window_id`, `previous_window_id`, `first_window_id`, `compaction_response_id`), not the retained history. +- **No `compactMetadata`.** Nothing in the app reads it for Codex, and a varying metadata object also made every boundary a different rendering shape. - Catalog: add Codex durable shapes for the boundary and the summary. They are the same shared.compaction dispositions Claude's entries have, pinned by curated fixtures. ## Tests @@ -19,6 +19,6 @@ Two real `compacted` lines: a 0.157.0 one (empty message, `compaction` item) and - Both map to a timestamped boundary. - Only the older one also maps to a summary. - No replacement-history entry is emitted. -- `compactMetadata` holds only the identity fields. +- The boundary carries no retained history. - Red on main, where both map to `[]`. - The dispatcher renders the boundary through `shared.compaction`, and the catalog classifies it `known-claimed`. diff --git a/src/providers/codex/renderer/compaction.committed.evidence.renderer.test.tsx b/src/providers/codex/renderer/compaction.committed.evidence.renderer.test.tsx new file mode 100644 index 000000000..973562548 --- /dev/null +++ b/src/providers/codex/renderer/compaction.committed.evidence.renderer.test.tsx @@ -0,0 +1,74 @@ +import { render } from '@testing-library/react' +import { describe, expect, it } from 'vitest' + +import fixture from '../../../../testing/fixtures/rendering-shapes/codex/compaction/committed-compacted.json' + +import { renderCodexDurableEntry } from '@providers/codex/renderer/entries/dispatch' +import { CODEX_RENDER_SHAPES } from '@providers/codex/renderer/shapes' +import { mapCodexRolloutToFeedEntries } from '@providers/codex/renderer/transcript/rollout' +import { buildFingerprintIndex, classifySighting } from '@renderer/rendering/evidence/catalogCoverage' +import { fingerprintRenderShape } from '@renderer/rendering/evidence/shapeFingerprint' +import type { Entry } from '@shared/types/transcript' + +// #1289: a Codex `compacted` rollout line has no payload.type, so the mapper's +// early return swallowed it and Codex compaction never rendered. The fixture is +// two real lines (see its `evidence`): a 0.157.0 one with an empty message and +// an encrypted `compaction` item, and an older one with a readable summary. +const [modern, legacy] = fixture.records as Array & { timestamp: string }> +const catalogIndex = buildFingerprintIndex([CODEX_RENDER_SHAPES]) + +const kinds = (entries: Entry[]) => entries.map(entry => + (entry as { subtype?: string }).subtype ?? ((entry as { isCompactSummary?: boolean }).isCompactSummary ? 'compact_summary' : entry.type)) + +describe('Codex committed compaction (#1289)', () => { + it('keeps the curated carrier identical to what the mapper produces', () => { + // The catalog coverage gate sweeps `cases`; pinning them to the mapper's + // output means the gate and these tests read the same shapes. + expect(fixture.cases).toEqual(fixture.records.flatMap(record => + mapCodexRolloutToFeedEntries(record as Record).map(transcriptEntry => ({ transcriptEntry })))) + }) + + it('maps a 0.157 compaction to one timestamped boundary and nothing else', () => { + const entries = mapCodexRolloutToFeedEntries(modern!) + // No summary (the message is empty) and no replay of replacement_history, + // which only restates context and prompts already in the feed. + expect(kinds(entries)).toEqual(['compact_boundary']) + // A timestamp-less row sorts to the bottom of the feed (order.ts). + expect(entries[0]!.timestamp).toBe(modern!.timestamp) + }) + + it('carries none of the retained history on the boundary', () => { + // The boundary used to embed the whole payload as compactMetadata: + // retained prompts, instructions and a 13–23 KB encrypted summary. + const [boundary] = mapCodexRolloutToFeedEntries(modern!) + expect(boundary).not.toHaveProperty('compactMetadata') + expect(JSON.stringify(boundary).length).toBeLessThan(300) + }) + + it('maps an older compaction with a readable message to boundary then summary', () => { + const entries = mapCodexRolloutToFeedEntries(legacy!) + expect(kinds(entries)).toEqual(['compact_boundary', 'compact_summary']) + for (const entry of entries) expect(entry.timestamp).toBe(legacy!.timestamp) + }) + + it('renders both through shared.compaction, and the catalog claims them', () => { + const entries = [...mapCodexRolloutToFeedEntries(modern!), ...mapCodexRolloutToFeedEntries(legacy!)] + expect(entries).toHaveLength(3) + for (const entry of entries) { + const decision = renderCodexDurableEntry({ entry } as Parameters[0]) + if (decision?.action !== 'render') throw new Error('expected a rendered compaction entry') + const eventType = entry.type === 'system' ? `system:${(entry as { subtype: string }).subtype}` : entry.type + const fingerprint = fingerprintRenderShape({ provider: 'codex', plane: 'transcript-entry', eventType, payload: entry }).fingerprint + const definition = catalogIndex.byFingerprint.get(fingerprint) + expect({ eventType, claimed: definition?.id }).toEqual({ eventType, claimed: expect.stringMatching(/^codex\.entry\./) }) + expect(classifySighting({ + structuralFingerprint: fingerprint, + lifecycle: 'durable', + outcome: { kind: 'specialized', shapeId: definition!.id, rendererId: decision.receipt.rendererId, protocolId: decision.receipt.protocolId }, + } as Parameters[0], catalogIndex)).toEqual({ kind: 'known-claimed', shapeId: definition!.id }) + const view = render(decision.node) + expect(view.container.textContent?.length).toBeGreaterThan(0) + view.unmount() + } + }) +}) diff --git a/src/providers/codex/renderer/shapes.ts b/src/providers/codex/renderer/shapes.ts index a1dbbb617..f95d3b78e 100644 --- a/src/providers/codex/renderer/shapes.ts +++ b/src/providers/codex/renderer/shapes.ts @@ -111,6 +111,43 @@ export const CODEX_RENDER_SHAPES = defineRenderShapeCatalog('codex', { disposition: { kind: 'specialized', rendererId: 'shared.compaction', protocolId: 'compaction.live' }, why: 'GRADUATED Phase 10 from the captured structured Codex semantic item: prefix/final lifecycle paints compaction progress without screen parsing, and the later durable compacted rollout remains the replay source of truth for boundary + summary.', }), + // #1289: the committed compaction a `compacted` rollout line maps to. It + // never rendered before: the mapper returned early on the line's typeless + // payload. Same shared.compaction dispositions as Claude's durable entries. + 'codex.entry.system-compact-boundary.v1': defineRenderShape({ + id: 'codex.entry.system-compact-boundary.v1', + provider: 'codex', + fingerprints: ["fp2-c5b90915"], + eventTypes: ["system:compact_boundary"], + planes: ["transcript-entry"] as const, + lifecycles: ["durable"] as const, + observed: { + providerVersions: ['0.157.0'], + models: [], + firstSeen: '2026-07-13', + lastSeen: '2026-09-25', + }, + fixtures: { final: ["rendering-shapes/codex/compaction/committed-compacted.json"], prefixes: [] }, + disposition: { kind: 'specialized', rendererId: 'shared.compaction', protocolId: 'compaction.boundary' }, + why: 'Codex durable-entry dispatch owns the boundary a committed `compacted` line maps to (timestamped, no retained history) and delegates only the provider-neutral grammar to shared.compaction. The durable rollout line is the replay source of truth.', + }), + 'codex.entry.compact-summary.v1': defineRenderShape({ + id: 'codex.entry.compact-summary.v1', + provider: 'codex', + fingerprints: ["fp2-85ed68b2"], + eventTypes: ["user"], + planes: ["transcript-entry"] as const, + lifecycles: ["durable"] as const, + observed: { + providerVersions: [], + models: [], + firstSeen: '2026-07-13', + lastSeen: '2026-07-13', + }, + fixtures: { final: ["rendering-shapes/codex/compaction/committed-compacted.json"], prefixes: [] }, + disposition: { kind: 'specialized', rendererId: 'shared.compaction', protocolId: 'compaction.summary' }, + why: 'Only older Codex CLIs write a readable compaction `message` (56 of about 1,500 local compactions); 0.15x encrypts the summary. When present it is the committed summary, painted by shared.compaction.', + }), 'codex.semantic.exec.v1': defineRenderShape({ id: 'codex.semantic.exec.v1', provider: 'codex', diff --git a/src/providers/codex/renderer/transcript/rollout.ts b/src/providers/codex/renderer/transcript/rollout.ts index 15debb708..e1bb1edd5 100644 --- a/src/providers/codex/renderer/transcript/rollout.ts +++ b/src/providers/codex/renderer/transcript/rollout.ts @@ -195,19 +195,51 @@ function codexConversationEntryFromMessageItem( } } +// WHY no `compactMetadata` (#1289): the boundary used to carry the whole +// `compacted` payload, including `replacement_history` (the retained developer +// instructions, AGENTS.md, earlier user prompts, and an encrypted summary of +// 13–23 KB). That kept a second copy of that text in memory and in every debug +// bundle, and nothing in the app reads a Codex boundary's metadata; its uuid +// already identifies the rollout line. A varying metadata object also made +// each boundary a different rendering shape. function codexCompactBoundaryEntry( uuid: string, - payload: Record, + timestamp: string | undefined, ): Entry { return { type: 'system', subtype: 'compact_boundary', content: 'Conversation compacted', uuid, - compactMetadata: payload, + // WHY a timestamp (#1289): rendering/model/order.ts sorts timestamp-less + // rows to the end of their phase, so without it the boundary painted at + // the bottom of the feed instead of where the compaction happened. + timestamp, } } +// A committed Codex compaction: a timestamped boundary, then the summary when +// the CLI wrote a readable one (#1289). +// +// WHY `replacement_history` is never mapped: it is not new conversation. It +// is the context Codex keeps across the compaction (developer instructions, +// the AGENTS.md block, earlier user prompts, and from 0.155 an encrypted +// `compaction` summary item). Mapped, it repainted prompts already in the +// feed: 17,326 of 20,343 sampled replacement messages duplicated an earlier +// user message. WHY the summary is conditional: `message` is empty in every +// 0.15x rollout, where the summary is encrypted; only 56 of about 1,500 +// local compactions (older CLIs) carry readable text. +function mapCodexCompacted( + uuid: string, + timestamp: string | undefined, + payload: Record, +): Entry[] { + const out: Entry[] = [codexCompactBoundaryEntry(`${uuid}:compact-boundary`, timestamp)] + const message = typeof payload.message === 'string' ? payload.message.trim() : '' + if (message) out.push(codexCompactSummaryEntry(`${uuid}:compact-summary`, timestamp, message)) + return out +} + function codexCompactSummaryEntry( uuid: string, timestamp: string | undefined, @@ -266,6 +298,11 @@ export function mapCodexRolloutToFeedEntries(entry: Record): En const timestamp = typeof entry.timestamp === 'string' ? entry.timestamp : undefined + // WHY before the payload.type guard (#1289): a `compacted` line's payload + // has no `type` (every one of about 1,500 local lines), so that guard + // returned [] for all of them and Codex compaction never rendered. + if (entry.type === 'compacted' && payload) return mapCodexCompacted(uuid, timestamp, payload) + if (!payload || typeof payload.type !== 'string') return [] if (entry.type === 'event_msg') { @@ -368,33 +405,6 @@ export function mapCodexRolloutToFeedEntries(entry: Record): En return [] } - if (entry.type === 'compacted') { - const out: Entry[] = [ - codexCompactBoundaryEntry(`${uuid}:compact-boundary`, payload), - ] - - const message = typeof payload.message === 'string' ? payload.message.trim() : '' - if (message) { - out.push(codexCompactSummaryEntry(`${uuid}:compact-summary`, timestamp, message)) - } - - const replacementHistory = Array.isArray(payload.replacement_history) - ? payload.replacement_history - : [] - for (let i = 0; i < replacementHistory.length; i += 1) { - const item = asRecord(replacementHistory[i]) - if (!item) continue - const mapped = codexConversationEntryFromMessageItem( - `${uuid}:replacement:${i}`, - timestamp, - item, - ) - if (mapped) out.push(mapped) - } - - return out - } - if (entry.type !== 'response_item') return [] const conversationEntry = codexConversationEntryFromMessageItem(uuid, timestamp, payload) diff --git a/src/renderer/src/rendering/evidence/bundleShapeSweep.ts b/src/renderer/src/rendering/evidence/bundleShapeSweep.ts index 52c28f7cd..413aaaf65 100644 --- a/src/renderer/src/rendering/evidence/bundleShapeSweep.ts +++ b/src/renderer/src/rendering/evidence/bundleShapeSweep.ts @@ -240,10 +240,19 @@ export function sweepCuratedShapeFixture( } const transcriptEntry = asRecord(carrier.transcriptEntry) if (transcriptEntry && typeof transcriptEntry.type === 'string') { + // Same event-type rule as the bundle sweep above (EntryRow's + // `type:subtype` for non-conversation entries). The curated path used + // the bare type, which no fixture exercised until the first curated + // system entry (Codex's compact boundary, #1289); a catalog entry for + // `system:compact_boundary` then could never match its own fixture. + const isConversation = transcriptEntry.type === 'user' || transcriptEntry.type === 'assistant' + const subtype = !isConversation && typeof transcriptEntry.subtype === 'string' + ? `:${transcriptEntry.subtype}` + : '' observe( 'transcript-entry', 'durable', - transcriptEntry.type, + `${transcriptEntry.type}${subtype}`, transcriptEntry, ) } diff --git a/testing/fixtures/rendering-shapes/codex/compaction/committed-compacted.json b/testing/fixtures/rendering-shapes/codex/compaction/committed-compacted.json new file mode 100644 index 000000000..c9b5c3d8b --- /dev/null +++ b/testing/fixtures/rendering-shapes/codex/compaction/committed-compacted.json @@ -0,0 +1,447 @@ +{ + "evidence": "Two real Codex rollout `compacted` lines: a codex-cli 0.157.0 one (empty message; replacement_history ends in an encrypted `compaction` item) and an older one whose `message` is a readable summary. Every key, the item types/roles/phases, window numbers and opaque ids are verbatim; every other string (instructions, prompts, summary text, encrypted content, paths) is replaced by the same number of x characters.", + "records": [ + { + "timestamp": "2026-09-25T06:07:41.275Z", + "type": "compacted", + "payload": { + "message": "", + "replacement_history": [ + { + "type": "message", + "id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "role": "user", + "content": [ + { + "type": "input_text", + "text": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + } + ], + "internal_chat_message_metadata_passthrough": { + "turn_id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "create_time": 1790315043.248249, + "content_item_kinds": [ + "xxxxxxxxx" + ] + } + }, + { + "type": "message", + "id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "role": "user", + "content": [ + { + "type": "input_text", + "text": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + } + ], + "internal_chat_message_metadata_passthrough": { + "turn_id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "create_time": 1790315169.232323, + "content_item_kinds": [ + "xxxxxxxxx" + ] + } + }, + { + "type": "message", + "id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "role": "developer", + "content": [ + { + "type": "input_text", + "text": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + }, + { + "type": "input_text", + "text": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + }, + { + "type": "input_text", + "text": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + } + ], + "internal_chat_message_metadata_passthrough": { + "turn_id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "content_item_kinds": [ + "xxxxxxxxxxxxxxxxxxxxxxxx", + "xxxxxxxxxxxxxxxxxxxxxxxx", + "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + ] + } + }, + { + "type": "message", + "id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "role": "developer", + "content": [ + { + "type": "input_text", + "text": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + } + ], + "internal_chat_message_metadata_passthrough": { + "turn_id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "content_item_kinds": [ + "xxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + ] + } + }, + { + "type": "message", + "id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "role": "developer", + "content": [ + { + "type": "input_text", + "text": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + } + ], + "internal_chat_message_metadata_passthrough": { + "turn_id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "content_item_kinds": [ + "xxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + ] + } + }, + { + "type": "message", + "id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "role": "user", + "content": [ + { + "type": "input_text", + "text": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + }, + { + "type": "input_text", + "text": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + } + ], + "internal_chat_message_metadata_passthrough": { + "turn_id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "content_item_kinds": [ + "xxxxxxxxxxxxxxxxxxxxxx", + "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + ] + } + }, + { + "type": "message", + "id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "role": "user", + "content": [ + { + "type": "input_text", + "text": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + } + ], + "internal_chat_message_metadata_passthrough": { + "turn_id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "create_time": 1790315510.078018, + "content_item_kinds": [ + "xxxxxxxxx" + ] + } + }, + { + "type": "compaction", + "id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "encrypted_content": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "internal_chat_message_metadata_passthrough": { + "turn_id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + } + } + ], + "replacement_history_metadata": [ + { + "client_authored": false, + "user_input_order": 0, + "mcp_attribution": { + "status": "xxxx" + } + }, + { + "client_authored": false, + "user_input_order": 6, + "mcp_attribution": { + "status": "xxxxxxxx", + "sources": [ + { + "server_name": "xxxxxxxxxx", + "tool_name": "xxxxxxxx", + "first_turn_id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + }, + { + "server_name": "xxxxxxxxxx", + "tool_name": "xxxxxxxxxxxxxxx", + "first_turn_id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + }, + { + "server_name": "xxxxxxxxxx", + "tool_name": "xxxxxxxxxxx", + "first_turn_id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + }, + { + "server_name": "xxxxxxxxxx", + "tool_name": "xxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "first_turn_id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + }, + { + "server_name": "xxxxxxxxxx", + "tool_name": "xxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "first_turn_id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + }, + { + "server_name": "xxxxxxxxxx", + "tool_name": "xxxxxxxxxxxxxxxxxxxxxxxxxxx", + "first_turn_id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + }, + { + "server_name": "xxxxxxxxxx", + "tool_name": "xxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "first_turn_id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + } + ] + } + }, + { + "client_authored": false + }, + { + "client_authored": false + }, + { + "client_authored": false + }, + { + "client_authored": false + }, + { + "client_authored": false, + "user_input_order": 7, + "mcp_attribution": { + "status": "xxxxxxxx", + "sources": [ + { + "server_name": "xxxxxxxxxx", + "tool_name": "xxxxxxxx", + "first_turn_id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + }, + { + "server_name": "xxxxxxxxxx", + "tool_name": "xxxxxxxxxxxxxxx", + "first_turn_id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + }, + { + "server_name": "xxxxxxxxxx", + "tool_name": "xxxxxxxxxxx", + "first_turn_id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + }, + { + "server_name": "xxxxxxxxxx", + "tool_name": "xxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "first_turn_id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + }, + { + "server_name": "xxxxxxxxxx", + "tool_name": "xxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "first_turn_id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + }, + { + "server_name": "xxxxxxxxxx", + "tool_name": "xxxxxxxxxxxxxxxxxxxxxxxxxxx", + "first_turn_id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + }, + { + "server_name": "xxxxxxxxxx", + "tool_name": "xxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "first_turn_id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + } + ] + } + }, + { + "client_authored": false, + "compaction_model_hash": "xxxx", + "mcp_attribution": { + "status": "xxxxxxxx", + "sources": [ + { + "server_name": "xxxxxxxxxx", + "tool_name": "xxxxxxxx", + "first_turn_id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + }, + { + "server_name": "xxxxxxxxxx", + "tool_name": "xxxxxxxxxxxxxxx", + "first_turn_id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + }, + { + "server_name": "xxxxxxxxxx", + "tool_name": "xxxxxxxxxxx", + "first_turn_id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + }, + { + "server_name": "xxxxxxxxxx", + "tool_name": "xxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "first_turn_id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + }, + { + "server_name": "xxxxxxxxxx", + "tool_name": "xxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "first_turn_id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + }, + { + "server_name": "xxxxxxxxxx", + "tool_name": "xxxxxxxxxxxxxxxxxxxxxxxxxxx", + "first_turn_id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + }, + { + "server_name": "xxxxxxxxxx", + "tool_name": "xxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "first_turn_id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + } + ] + } + } + ], + "retained_context": { + "verified_answers": [], + "incomplete": false, + "user_messages": [ + { + "order": 0, + "turn_id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "message_id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "text": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "complete": true + }, + { + "order": 6, + "turn_id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "message_id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "text": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "complete": true + }, + { + "order": 7, + "turn_id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "message_id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "text": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "complete": true + } + ], + "user_messages_incomplete": false, + "next_order": 27 + }, + "window_number": 1, + "first_window_id": "01a0d714-17ae-7631-888c-d7131e28e97a", + "previous_window_id": "01a0d714-17ae-7631-888c-d7131e28e97a", + "window_id": "01a0d72d-3cb1-7962-a9f9-9a3fb6e19064", + "compaction_response_id": "resp_0a91f5dd941306f3016ab60f8c1df887d095caefac839fe926", + "latest_token_usage_record": { + "thread_id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "turn_id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "session_id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "root_turn_id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "response_id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "usage": { + "input_tokens": 241508, + "cached_input_tokens": 240384, + "cache_write_input_tokens": 0, + "output_tokens": 2374, + "reasoning_output_tokens": 0, + "total_tokens": 243882 + }, + "turn_token_usage": { + "input_tokens": 12135135, + "cached_input_tokens": 11988224, + "cache_write_input_tokens": 0, + "output_tokens": 22510, + "reasoning_output_tokens": 5305, + "total_tokens": 12157645 + }, + "thread_token_usage": { + "input_tokens": 13424164, + "cached_input_tokens": 13175808, + "cache_write_input_tokens": 0, + "output_tokens": 29769, + "reasoning_output_tokens": 7131, + "total_tokens": 13453933 + } + }, + "resume_metadata": { + "multi_agent_version": "xx", + "last_started_turn_id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "previous_turn_settings": { + "model": "xxxxxxxxx", + "comp_hash": "xxxx", + "realtime_active": false + } + } + } + }, + { + "timestamp": "2026-07-13T13:39:12.157Z", + "type": "compacted", + "payload": { + "message": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "replacement_history": [ + { + "type": "message", + "role": "user", + "content": [ + { + "type": "input_text", + "text": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + } + ] + } + ], + "compaction_response_id": null, + "latest_token_usage_record": null + } + } + ], + "casesEvidence": "The curated carrier form read by the catalog coverage gate: exactly what mapCodexRolloutToFeedEntries produces from `records` (compaction.committed.evidence.renderer.test.tsx pins the equality).", + "cases": [ + { + "transcriptEntry": { + "type": "system", + "subtype": "compact_boundary", + "content": "Conversation compacted", + "uuid": "2026-09-25T06:07:41.275Z:compacted:d7142abc:compact-boundary", + "timestamp": "2026-09-25T06:07:41.275Z" + } + }, + { + "transcriptEntry": { + "type": "system", + "subtype": "compact_boundary", + "content": "Conversation compacted", + "uuid": "2026-07-13T13:39:12.157Z:compacted:2fd997f7:compact-boundary", + "timestamp": "2026-07-13T13:39:12.157Z" + } + }, + { + "transcriptEntry": { + "type": "user", + "uuid": "2026-07-13T13:39:12.157Z:compacted:2fd997f7:compact-summary", + "parentUuid": null, + "timestamp": "2026-07-13T13:39:12.157Z", + "isCompactSummary": true, + "isVisibleInTranscriptOnly": true, + "message": { + "role": "user", + "content": [ + { + "type": "text", + "text": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + } + ] + } + } + } + ] +} \ No newline at end of file From ed37d2d8f75f5a5117411e70a55ab85a9a255a1b Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sun, 27 Sep 2026 02:28:37 -0700 Subject: [PATCH 11/25] fix(orchestration): a compaction summary is not an agent message; document the resumed-compaction gap Review a of #1386: the agent read API counted the synthetic compaction summary as a user message. The mapper comment claimed retained history is always a duplicate; resumed rollouts that start with compacted are the exception (follow-up #1393). Co-Authored-By: Claude Opus 5.5 --- .../codex/renderer/transcript/rollout.ts | 26 +++++++--- .../orchestrationMcp.compactSummary.test.ts | 49 +++++++++++++++++++ .../src/workspace/orchestrationMcp.ts | 23 +++++++-- 3 files changed, 87 insertions(+), 11 deletions(-) create mode 100644 src/renderer/src/workspace/orchestrationMcp.compactSummary.test.ts diff --git a/src/providers/codex/renderer/transcript/rollout.ts b/src/providers/codex/renderer/transcript/rollout.ts index e1bb1edd5..22890617c 100644 --- a/src/providers/codex/renderer/transcript/rollout.ts +++ b/src/providers/codex/renderer/transcript/rollout.ts @@ -221,14 +221,24 @@ function codexCompactBoundaryEntry( // A committed Codex compaction: a timestamped boundary, then the summary when // the CLI wrote a readable one (#1289). // -// WHY `replacement_history` is never mapped: it is not new conversation. It -// is the context Codex keeps across the compaction (developer instructions, -// the AGENTS.md block, earlier user prompts, and from 0.155 an encrypted -// `compaction` summary item). Mapped, it repainted prompts already in the -// feed: 17,326 of 20,343 sampled replacement messages duplicated an earlier -// user message. WHY the summary is conditional: `message` is empty in every -// 0.15x rollout, where the summary is encrypted; only 56 of about 1,500 -// local compactions (older CLIs) carry readable text. +// WHY `replacement_history` is not mapped here: in the common case it is not +// new conversation. It is the context Codex keeps across the compaction +// (developer instructions, the AGENTS.md block, earlier user prompts, and from +// 0.155 an encrypted `compaction` summary item). Mapped, it repainted prompts +// already in the feed: 17,326 of 20,343 sampled replacement messages +// duplicated an earlier user message. +// +// KNOWN GAP (review a of #1386, follow-up #1393): it is NOT +// always a duplicate. 82 local rollouts (68 sessions) are resumed files that +// START with a `compacted` line, so its retained user prompts are the only +// copy of that earlier conversation in the file. This line-at-a-time mapper +// cannot tell that case apart (a paged older-history load can also start a +// page with a `compacted` line that has predecessors in the previous page), +// so the fix belongs where the loader knows it is mapping from file offset 0. +// Before #1386 no `compacted` line rendered at all, so this is not a +// regression. WHY the summary is conditional: `message` is empty in every +// 0.15x rollout, where the summary is encrypted; only 56 of about 1,500 local +// compactions (older CLIs) carry readable text. function mapCodexCompacted( uuid: string, timestamp: string | undefined, diff --git a/src/renderer/src/workspace/orchestrationMcp.compactSummary.test.ts b/src/renderer/src/workspace/orchestrationMcp.compactSummary.test.ts new file mode 100644 index 000000000..3546e7099 --- /dev/null +++ b/src/renderer/src/workspace/orchestrationMcp.compactSummary.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' + +import fixture from '../../../../testing/fixtures/rendering-shapes/codex/compaction/committed-compacted.json' + +import { mapCodexRolloutToFeedEntries } from '@providers/codex/renderer/transcript/rollout' +import { emptyRuntime } from '@renderer/session-runtime/state' +import type { SessionRuntime } from '@renderer/session-runtime/state' +import { visibleMessageSummary } from '@renderer/workspace/orchestrationMcp' +import type { SessionMeta } from '@renderer/workspace/types' + +// #1386 review a (P2): a readable Codex compaction summary is a synthetic +// `type: 'user'` entry. The agent read API counted it as a message, so a +// coordinator reading the child's one-message tail got the summary back as the +// child's latest user instruction. The input is the fixture's REAL older +// `compacted` line (readable `message`) through the real Codex mapper, with no +// actual prompt anywhere in the transcript. +const legacy = (fixture.records as Array>)[1]! +const meta = { kind: 'codex', cwd: '/repo' } as SessionMeta + +function runtimeWith(entries: SessionRuntime['entries']): SessionRuntime { + return { ...emptyRuntime(), entries } +} + +describe('agent read API and compaction summaries', () => { + it('does not report a compaction summary as a user message', () => { + const entries = mapCodexRolloutToFeedEntries(legacy) + // Guard the premise: the mapper really emits a user-typed summary row. + expect(entries.some(entry => entry.type === 'user')).toBe(true) + + const summary = visibleMessageSummary(runtimeWith(entries), meta, 1, 4_000, 16_000) + expect(summary.messageCount).toBe(0) + expect(summary.messages).toEqual([]) + }) + + it('still reports a real prompt next to the summary', () => { + const prompt = { + type: 'user', + uuid: 'prompt-1', + parentUuid: null, + timestamp: '2026-09-25T06:08:00.000Z', + message: { role: 'user', content: [{ type: 'text', text: 'continue with the plan' }] }, + } as SessionRuntime['entries'][number] + const entries = [...mapCodexRolloutToFeedEntries(legacy), prompt] + + const summary = visibleMessageSummary(runtimeWith(entries), meta, 5, 4_000, 16_000) + expect(summary.messageCount).toBe(1) + expect(summary.messages.map(message => message.text)).toEqual(['continue with the plan']) + }) +}) diff --git a/src/renderer/src/workspace/orchestrationMcp.ts b/src/renderer/src/workspace/orchestrationMcp.ts index 01feb36ff..9845b9e73 100644 --- a/src/renderer/src/workspace/orchestrationMcp.ts +++ b/src/renderer/src/workspace/orchestrationMcp.ts @@ -8,6 +8,7 @@ import type { OrchestrationCloseResult, OrchestrationLifecycleState, } from '@mcp/shared/orchestrationTypes' +import { isCompactSummaryEntry } from '@shared/types/transcript' import { entryTextContent } from '@renderer/session-runtime/entries' import { isSessionExited } from '@renderer/workspace/providerSessionIdentity' import type { SessionRuntime } from '@renderer/session-runtime/state' @@ -644,7 +645,7 @@ function visibleMessages( const messages: OrchestrationAgentMessage[] = [] const entries = orchestrationVisibleEntries(runtime, meta) for (const entry of entries) { - if (entry.type !== 'user' && entry.type !== 'assistant') continue + if (!isAgentMessageEntry(entry)) continue const text = entryTextContent(entry) if (!text?.trim()) continue messages.push({ @@ -772,7 +773,7 @@ export function visibleMessageSummary( const entries = entriesOverride ?? orchestrationVisibleEntries(runtime, meta) for (let index = entries.length - 1; index >= 0; index -= 1) { const entry = entries[index]! - if (entry.type !== 'user' && entry.type !== 'assistant') continue + if (!isAgentMessageEntry(entry)) continue messageCount += 1 const needsTailMessage = !budgetExhausted && messagesReversed.length < maxMessages @@ -865,6 +866,22 @@ function hasAssistantOutput( )) } +// WHY a compaction summary is not a message (#1386 review a): providers write +// it as a synthetic `type: 'user'` entry (Claude's `isCompactSummary` row, and +// Codex's readable `compacted.message` from older CLIs). Counted here, a +// coordinator reading a child with maxMessages=1 got the summary back as the +// child's latest user instruction, and messageCount grew with no prompt. The +// feed, the preview turn count and latestUserPrompts already skip it; the +// three agent read paths below (tail summary, full list, cheap count) must +// agree with them, so they share this one predicate. +type AgentMessageEntry = SessionRuntime['entries'][number] & { type: 'user' | 'assistant' } + +// A type guard, so callers keep the `user | assistant` narrowing they had from +// the inline check this replaced. +function isAgentMessageEntry(entry: SessionRuntime['entries'][number]): entry is AgentMessageEntry { + return (entry.type === 'user' || entry.type === 'assistant') && !isCompactSummaryEntry(entry) +} + function cheapMessageCount( runtime: SessionRuntime | null, meta?: SessionMeta, @@ -872,7 +889,7 @@ function cheapMessageCount( if (!runtime) return 0 let count = 0 for (const entry of orchestrationVisibleEntries(runtime, meta)) { - if (entry.type === 'assistant' || entry.type === 'user') count += 1 + if (isAgentMessageEntry(entry)) count += 1 } if (runtime.semantic.currentTurn?.text?.trim()) count += 1 return count From 335408bcb96e2c0c1743d1a808d381e40d9bc2cc Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sun, 27 Sep 2026 03:07:02 -0700 Subject: [PATCH 12/25] docs(plans): floating-surface layers, side-panel shell and unwired dismiss stack (#512) States the q95 sequencing: no useKeybinds.ts edits until #1394 merges. Co-Authored-By: Claude Opus 5.5 --- .../2026-09-27-floating-surface-layers.md | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 docs/plans/2026-09-27-floating-surface-layers.md diff --git a/docs/plans/2026-09-27-floating-surface-layers.md b/docs/plans/2026-09-27-floating-surface-layers.md new file mode 100644 index 000000000..c7d75d57c --- /dev/null +++ b/docs/plans/2026-09-27-floating-surface-layers.md @@ -0,0 +1,41 @@ +# Floating-surface layer: named layers, side-panel shell, dismiss stack (#512) + +## Where #512 stands on main (inventory, 2026-09-27) +- **Done by earlier work:** shared Radix `dialog.tsx` for modals, shared `dropdown-menu.tsx` (AppearanceMenu and the Skills menu moved onto it), one toast (CaffeinateToast forwards to `GlobalToast`), and the shared side-panel header `PanelHeader`. +- **Left:** + 1. **No named layer scale.** The bands are real but spelled as magic numbers in about ten files: + - pane overlays z-40/50; + - pane dialog z-[60..62]; + - dialog z-[1100]; + - menus z-[1150]; + - toast/dictation z-[1200]; + - debug highlight z-[10000]. + Comments in `registry.tsx`, `App.tsx` and `surfaces/types.ts` still describe the old "everything z-50, DOM order breaks ties" model. + 2. **No shared side-panel shell.** Git and Worktrees are identical; Agent Status is near-identical. The six debug panels copy their own shell and header, and their closes have no accessible name. + 3. **RemotePanel is registered as a side panel but renders a centered Dialog.** + 4. **No dismiss stack / `anySurfaceOpen()`.** Escape arbitration is Radix's own layer stack, plus the central `useKeybinds` branches, plus per-component handlers. + 5. **Three hand-rolled anchored popovers remain:** + - CommandSortControl: keeps focus in the palette input; + - PathInput suggestions: a combobox; + - ExplorerPane context menu: pointer-anchored, with a WHY for not moving. + +## This PR +- **`ui/layers.ts`:** ONE table of named layers as literal Tailwind classes (Tailwind only emits classes it sees spelled out, the rule `PANE_DIALOG_LAYERS` already follows). Every magic z value in the bands above moves onto it, and `PANE_DIALOG_LAYERS` becomes a view of it. The stale comments are corrected. +- **`components/ui/side-panel.tsx` ``:** the shared outer shell (`aside`, fixed width, border, surface, column, overflow). Git, Worktrees, Agent Status and the debug panels render through it. The debug panels also move to `PanelHeader`, which gives their closes a name. +- **RemotePanel** moves from `sidePanelSurfaces` to the modal surfaces it actually is. +- **`ui/dismissStack.ts`:** the dismiss stack as a standalone primitive with tests: + - `pushDismissLayer` returns `release`; + - `dismissTopmost(event)` pops only the topmost layer; + - it honours `escape: false` while busy; + - it exposes `anySurfaceOpen()`. + +## Sequencing (steering q95) +W1's open #1394 edits `useKeybinds.ts` and its ownership tests, and owner keyboard work (#1221 follow-ups) is in flight. Until #1394 merges, this PR does NOT touch `useKeybinds.ts` or its tests. The dismiss stack ships unwired. Wiring it into `useKeybinds.ts` (replacing the hardcoded surface bailouts with `anySurfaceOpen()`) is a follow-up on a fresh origin/main base once #1394 is on main. + +## Not in this PR, and why +The three remaining anchored popovers stay custom. Each has a documented reason a menu primitive does not fit: +- the palette input must keep DOM focus; +- the path field is a combobox; +- the explorer menu is anchored at the pointer. + +A popover primitive shaped around one of them would be fitted to one caller. The PR therefore says `Refs #512`, not `Fixes`. #512 stays open for the useKeybinds wiring and the popover decision. From 77734732d3e15f0c14b8a7b1b52f3c70f77d9883 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sun, 27 Sep 2026 03:12:37 -0700 Subject: [PATCH 13/25] refactor(ui): named layers, one side-panel shell, RemotePanel with the modals, unwired dismiss stack (#512) - ui/layers.ts: every magic stacking band (pane overlay/takeover, pane dialog, in-surface popover, dialog, menu, toast, debug) is a named literal class; PANE_DIALOG_LAYERS is a view of it. Stale z-50 comments corrected. - components/ui/side-panel.tsx: the docked panel shell for Git, Worktrees, Agent Status and the six debug panels; DebugPanelHeader gives the debug closes an accessible name. - RemotePanel renders a Dialog, so it is registered with the modals. - ui/dismissStack.ts: topmost-only Escape + anySurfaceOpen(), shipped unwired; useKeybinds.ts is untouched until #1394 merges (q95). Co-Authored-By: Claude Opus 5.5 --- src/renderer/src/app/App.tsx | 9 +- src/renderer/src/app/surfaces/registry.tsx | 41 +++++--- src/renderer/src/app/surfaces/types.ts | 2 +- src/renderer/src/components/ui/dialog.tsx | 7 +- .../src/components/ui/dropdown-menu.tsx | 4 +- .../src/components/ui/pane-dialog.tsx | 13 +-- .../ui/side-panel.renderer.test.tsx | 28 ++++++ src/renderer/src/components/ui/side-panel.tsx | 46 +++++++++ .../agent-status/ui/AgentStatusPanel.tsx | 10 +- .../browser-pocket/ui/PocketStrip.tsx | 3 +- .../command-palette/ui/CommandSortControl.tsx | 7 +- .../RenderingDebugInspector.tsx | 13 ++- .../src/features/debug/ui/DebugPanel.tsx | 29 +----- .../features/debug/ui/DebugPanelHeader.tsx | 47 +++++++++ .../src/features/debug/ui/DevDebugPanel.tsx | 27 +---- .../src/features/debug/ui/FeedDebugPanel.tsx | 27 +---- .../src/features/debug/ui/HtmlDebugPanel.tsx | 36 +++---- .../src/features/debug/ui/ProxyDebugPanel.tsx | 31 +----- .../src/features/editor/ui/ExplorerPane.tsx | 3 +- src/renderer/src/features/git/ui/GitBar.tsx | 11 +-- .../src/features/goal-loop/GoalLoopPane.tsx | 5 +- .../src/features/path-picker/ui/PathInput.tsx | 3 +- .../src/features/tldr/TldrOverlay.tsx | 3 +- .../ui/VoiceDictationOverlay.tsx | 3 +- .../workspace/ui/NewAgentPlacementOverlay.tsx | 3 +- .../features/worktrees/ui/WorktreesBar.tsx | 5 +- src/renderer/src/ui/GlobalToast.tsx | 3 +- .../src/ui/dismissStack.renderer.test.tsx | 99 +++++++++++++++++++ src/renderer/src/ui/dismissStack.ts | 93 +++++++++++++++++ src/renderer/src/ui/layers.test.ts | 21 ++++ src/renderer/src/ui/layers.ts | 54 ++++++++++ 31 files changed, 503 insertions(+), 183 deletions(-) create mode 100644 src/renderer/src/components/ui/side-panel.renderer.test.tsx create mode 100644 src/renderer/src/components/ui/side-panel.tsx create mode 100644 src/renderer/src/features/debug/ui/DebugPanelHeader.tsx create mode 100644 src/renderer/src/ui/dismissStack.renderer.test.tsx create mode 100644 src/renderer/src/ui/dismissStack.ts create mode 100644 src/renderer/src/ui/layers.test.ts create mode 100644 src/renderer/src/ui/layers.ts diff --git a/src/renderer/src/app/App.tsx b/src/renderer/src/app/App.tsx index 716e8b5c7..245dd253a 100644 --- a/src/renderer/src/app/App.tsx +++ b/src/renderer/src/app/App.tsx @@ -143,11 +143,12 @@ export default function App() { RetainedWorkspaceSurface: a that is ever detached from the DOM is destroyed, so no layout change or takeover may own it. Each guest is its own fixed element at z-index 20 — above the lanes - it overlays, below every z-50 overlay and modal after this line. */} + it overlays, below every pane overlay and app layer + (ui/layers.ts). */} - {/* Mount order here IS the z-order contract: overlays and modals - are fixed-position siblings and mostly share z-50, so DOM - order is the paint-order tiebreaker. Overlays render first + {/* Mount order here IS the tie-break contract: the modals share one + named layer (LAYERS.dialog, ui/layers.ts), so mount order decides + which of them paints on top. Overlays render first (paint under); anything that must sit at a specific height within the modal stack lives in modalSurfaces at an explicit index — see app/surfaces/registry.tsx. Do not swap these. */} diff --git a/src/renderer/src/app/surfaces/registry.tsx b/src/renderer/src/app/surfaces/registry.tsx index 974273041..893375316 100644 --- a/src/renderer/src/app/surfaces/registry.tsx +++ b/src/renderer/src/app/surfaces/registry.tsx @@ -46,12 +46,14 @@ import { AgentMcpServersSurface } from '@renderer/features/mcp/surfaces/AgentMcp // // ORDER MATTERS within each array, AND the mount order of the groups in // App.tsx (overlays → modals) is part of the same contract: together they -// define the DOM sibling order at the app root, which IS the paint order -// whenever z-indexes tie. Most of these surfaces are `position: fixed` -// z-50, so "which array, at which index" decides what covers what. The -// order below is the exact order App.tsx rendered these surfaces before -// the extraction — keep new entries at the END unless you have a stacking -// reason and write it down. +// define the render order at the app root, which IS the paint order +// whenever layers tie. The layers themselves are named in ui/layers.ts +// (#512): every modal here is the shared Radix Dialog at LAYERS.dialog, +// portaled into , so within that one band the dialog mounted LATER +// paints on top, and surfaces that mount in the same commit mount in the +// order of these arrays. The order below is the exact order App.tsx +// rendered these surfaces before the extraction — keep new entries at the +// END unless you have a stacking reason and write it down. /** Rendered at the app root, after the overlays. */ export const modalSurfaces: SurfaceEntry[] = [ @@ -60,8 +62,10 @@ export const modalSurfaces: SurfaceEntry[] = [ // ⚠ Two non-modal surfaces interleaved into the modal stack ON PURPOSE. // Pre-refactor App.tsx rendered them exactly here — after the palette // and path picker, before the tile-tabs..usage modals — and that DOM - // position is load-bearing because all three of palette / dispatch-count - // / toast are fixed z-50, so sibling order is the only tiebreaker: + // position is load-bearing because palette and dispatch-count are both + // dialogs in the same layer (LAYERS.dialog), so order is the only + // tiebreaker (the caffeinate entry now forwards to the app toast, which + // has its own higher layer): // - tiled-dispatch-count must paint ABOVE the command palette. Tiled // dispatch can fire while the palette is open (native menu; the // palette deliberately stays open for keepPaletteOpen-style flows), @@ -93,8 +97,9 @@ export const modalSurfaces: SurfaceEntry[] = [ { id: 'rewind-to-prompt', Component: RewindToPromptSurface }, { id: 'agent-title-prompt', Component: AgentTitlePromptSurface }, { id: 'usage', Component: UsageModalSurface }, - // New modals append so their z-50 sibling order cannot accidentally move an - // established surface below one it used to cover; see the registry contract. + // New modals append so their order within the dialog layer cannot + // accidentally move an established surface below one it used to cover; see + // the registry contract. { id: 'provider-switch-picker', Component: ProviderSwitchPickerSurface }, { id: 'key-vault', Component: KeyVaultModalSurface }, // Appended per the contract above. It is only opened from a command, which @@ -139,16 +144,21 @@ export const modalSurfaces: SurfaceEntry[] = [ // whichever surface asked. It renders nothing until requestConfirm queues // a request. { id: 'confirm-dialog', Component: ConfirmHost }, + // #512: RemotePanel renders a centred Radix Dialog, but was registered as a + // side panel, so it mounted inside the main row and only painted as a modal + // because DialogContent portals out. It is a modal; it lives here, appended + // per the contract above (its portal stacks by open order either way). + { id: 'remote-panel', Component: RemotePanelSurface }, ] /** * Rendered at the app root, after the main row, BEFORE the modals — so * everything in this array paints UNDER the modal stack when z-indexes - * tie. Only surfaces that must never cover a modal belong here (voice - * dictation is z-40, below the z-50 stack regardless). A z-50 surface - * that needs a specific position relative to the modals goes into - * modalSurfaces at an explicit index instead — see the interleaved - * entries there for why. + * tie. Voice dictation's chip is in LAYERS.toast, above every dialog on + * purpose (dictating into a dialog must stay visible), so its position here + * no longer decides its stacking. A surface that needs a specific position + * relative to the modals goes into modalSurfaces at an explicit index + * instead — see the interleaved entries there for why. */ export const overlaySurfaces: SurfaceEntry[] = [ { id: 'voice-dictation', Component: VoiceDictationSurface }, @@ -159,6 +169,5 @@ export const sidePanelSurfaces: SurfaceEntry[] = [ { id: 'git-bar', Component: GitBarSurface }, { id: 'worktrees-bar', Component: WorktreesBarSurface }, { id: 'agent-status-panel', Component: AgentStatusPanelSurface }, - { id: 'remote-panel', Component: RemotePanelSurface }, { id: 'debug-surfaces', Component: DebugSurfaces }, ] diff --git a/src/renderer/src/app/surfaces/types.ts b/src/renderer/src/app/surfaces/types.ts index 3e21530fa..8d97be65d 100644 --- a/src/renderer/src/app/surfaces/types.ts +++ b/src/renderer/src/app/surfaces/types.ts @@ -30,7 +30,7 @@ export type SurfaceEntry = { * before — every first-party entry omits `layer` and keeps its documented * position. The field exists so a NON-first-party surface (an extension one, * WS7) can be given a distinct band it cannot escape: it can never tie-break - * into the first-party z-50 stack and silently reorder it, which is the exact + * into the first-party modal band (LAYERS.dialog) and silently reorder it, which is the exact * class of bug PR #505 hit. First-party entries should not set it. */ layer?: number diff --git a/src/renderer/src/components/ui/dialog.tsx b/src/renderer/src/components/ui/dialog.tsx index fce4786a0..3cc225913 100644 --- a/src/renderer/src/components/ui/dialog.tsx +++ b/src/renderer/src/components/ui/dialog.tsx @@ -9,6 +9,7 @@ import { } from '@renderer/components/ui/pane-dialog' import { APP_INTERACTION_OWNER_ATTRIBUTE } from '@renderer/lib/interaction-ownership' import { cn } from '@renderer/lib/utils' +import { LAYERS } from '@renderer/ui/layers' // Adapted from https://ui.shadcn.com/docs/components/dialog. // @@ -52,7 +53,8 @@ const DialogOverlay = React.forwardRef< ref={ref} data-slot="dialog-overlay" className={cn( - 'fixed inset-0 z-[1100] bg-overlay-scrim-strong', + 'fixed inset-0 bg-overlay-scrim-strong', + LAYERS.dialog, className, )} {...props} @@ -157,7 +159,8 @@ const DialogContent = React.forwardRef< // the TRACK minimum lets the child shrink first; each child still owns // whether its content truncates, wraps, or scrolls. className={cn( - 'fixed left-1/2 top-1/2 z-[1100] grid grid-cols-[minmax(0,1fr)] -translate-x-1/2 -translate-y-1/2 rounded-float border border-border-hi bg-surface text-ink shadow-[0_16px_48px_var(--theme-shadow-color)] outline-none', + LAYERS.dialog, + 'fixed left-1/2 top-1/2 grid grid-cols-[minmax(0,1fr)] -translate-x-1/2 -translate-y-1/2 rounded-float border border-border-hi bg-surface text-ink shadow-[0_16px_48px_var(--theme-shadow-color)] outline-none', dialogSizes[size], // The corner `× ⎋` (below) sits over the header's right end, so the // header must leave room for it. Owned HERE (UI pass, G-20): five diff --git a/src/renderer/src/components/ui/dropdown-menu.tsx b/src/renderer/src/components/ui/dropdown-menu.tsx index 179cee6f9..d4270b516 100644 --- a/src/renderer/src/components/ui/dropdown-menu.tsx +++ b/src/renderer/src/components/ui/dropdown-menu.tsx @@ -2,6 +2,7 @@ import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu' import * as React from 'react' import { cn } from '@renderer/lib/utils' +import { LAYERS } from '@renderer/ui/layers' // Adapted from https://ui.shadcn.com/docs/components/dropdown-menu. // @@ -51,7 +52,8 @@ const DropdownMenuContent = React.forwardRef< data-slot="dropdown-menu-content" sideOffset={sideOffset} className={cn( - 'modal-pop z-[1150] min-w-[180px] overflow-hidden rounded-float border border-popover-border bg-popover-bg p-1 font-code text-ink shadow-[0_8px_24px_var(--theme-shadow-color)] outline-none', + LAYERS.menu, + 'modal-pop min-w-[180px] overflow-hidden rounded-float border border-popover-border bg-popover-bg p-1 font-code text-ink shadow-[0_8px_24px_var(--theme-shadow-color)] outline-none', className, )} {...props} diff --git a/src/renderer/src/components/ui/pane-dialog.tsx b/src/renderer/src/components/ui/pane-dialog.tsx index 453a574a7..21a663ad9 100644 --- a/src/renderer/src/components/ui/pane-dialog.tsx +++ b/src/renderer/src/components/ui/pane-dialog.tsx @@ -3,6 +3,7 @@ import { createPortal } from 'react-dom' import { PANE_INTERACTION_OWNER_ATTRIBUTE } from '@renderer/lib/interaction-ownership' import { cn } from '@renderer/lib/utils' +import { LAYERS } from '@renderer/ui/layers' // Pane-scoped dialogs (#713, keyboard-first plan X1). // @@ -55,9 +56,9 @@ import { cn } from '@renderer/lib/utils' // leaves the pane. /** - * The pane's stacking levels while a pane dialog is up, as literal Tailwind - * classes (Tailwind only emits classes it can see spelled out). ONE table so - * the order cannot drift between files: + * The pane's stacking levels while a pane dialog is up. The classes are + * spelled in the app-wide layer table (ui/layers.ts, #512); this names the + * three a pane dialog uses: * scrim covers the pane, including the composer it must not let you type into; * content the dialog itself; * feedback the pane's own status toast. #713's second half was a refusal @@ -67,9 +68,9 @@ import { cn } from '@renderer/lib/utils' * composer stays covered. */ export const PANE_DIALOG_LAYERS = { - scrim: 'z-[60]', - content: 'z-[61]', - feedback: 'z-[62]', + scrim: LAYERS.paneDialogScrim, + content: LAYERS.paneDialogContent, + feedback: LAYERS.paneDialogFeedback, } as const export type PaneDialogHost = { diff --git a/src/renderer/src/components/ui/side-panel.renderer.test.tsx b/src/renderer/src/components/ui/side-panel.renderer.test.tsx new file mode 100644 index 000000000..80a12b6ec --- /dev/null +++ b/src/renderer/src/components/ui/side-panel.renderer.test.tsx @@ -0,0 +1,28 @@ +import { render, screen } from '@testing-library/react' +import { expect, it, vi } from 'vitest' + +import { DebugPanelHeader } from '@renderer/features/debug/ui/DebugPanelHeader' +import { SidePanel } from './side-panel' + +// #512: one shell for the docked side-panel slot, and a named close on the +// debug-family header (five copies drew a bare "×" with no accessible name). +it('renders a named complementary landmark and keeps the panel width', () => { + render(body) + const panel = screen.getByRole('complementary', { name: 'Git' }) + expect(panel.className).toContain('w-[280px]') + expect(panel.className).toContain('border-l') +}) + +it('lets a panel override the border colour instead of stacking two', () => { + render(body) + const panel = screen.getByRole('complementary', { name: 'Rendering debug' }) + expect(panel.className).toContain('border-red-500/60') + expect(panel.className).not.toMatch(/(^|\s)border-border(\s|$)/) +}) + +it('gives the debug header close an accessible name', () => { + const onClose = vi.fn() + render() + screen.getByRole('button', { name: 'Close dev debug' }).click() + expect(onClose).toHaveBeenCalledTimes(1) +}) diff --git a/src/renderer/src/components/ui/side-panel.tsx b/src/renderer/src/components/ui/side-panel.tsx new file mode 100644 index 000000000..a4d8c575e --- /dev/null +++ b/src/renderer/src/components/ui/side-panel.tsx @@ -0,0 +1,46 @@ +import * as React from 'react' + +import { cn } from '@renderer/lib/utils' + +/** + * The outer shell of a docked side panel: the column that sits in the main + * row to the right of the workspace (Git, Worktrees, Agent Status, the debug + * panels). Pair it with `PanelHeader` for the header (#512). + * + * WHY a shell component: nine panels carried copies of the same class string + * (`h-full w-[N] flex-shrink-0 border-l border-border bg-surface flex flex-col + * overflow-hidden`), which had already drifted (one `aside` among `div`s, one + * without the code font, one with its own border colour). The slot is one + * thing in the window, so its chrome is one component. + * + * WHY width stays a className (`w-[280px]`) and not a number prop: Tailwind + * only emits classes it sees spelled out, so a runtime `w-[${n}px]` would + * produce no CSS. Each panel keeps its width because each was sized for its + * content (HTML wraps badly under 540px, for one). + * + * WHY no z-index: side panels are in normal flow in the main row, never + * floating. A panel that needs to float is not a side panel (RemotePanel was + * registered here while rendering a centred dialog; it now lives with the + * modals). + */ +export function SidePanel({ + label, + className, + children, + ...props +}: React.ComponentProps<'aside'> & { + /** The panel's name for assistive technology ("Git"); the aside is a + * landmark, so it needs one. */ + label: string +}) { + return ( + + ) +} diff --git a/src/renderer/src/features/agent-status/ui/AgentStatusPanel.tsx b/src/renderer/src/features/agent-status/ui/AgentStatusPanel.tsx index fa19295fd..e7a17ca72 100644 --- a/src/renderer/src/features/agent-status/ui/AgentStatusPanel.tsx +++ b/src/renderer/src/features/agent-status/ui/AgentStatusPanel.tsx @@ -14,6 +14,7 @@ import { } from '@renderer/features/agent-status/model/formatAgentStatus' import type { AgentStatusField } from '@renderer/features/agent-status/model/formatAgentStatus' import type { Workspace } from '@renderer/workspace/workspaceStore' +import { SidePanel } from '@renderer/components/ui/side-panel' type Props = { sessionId: string @@ -29,12 +30,7 @@ export function AgentStatusPanel({ sessionId, workspace, onClose }: Props) { ) return ( - + ) } diff --git a/src/renderer/src/features/browser-pocket/ui/PocketStrip.tsx b/src/renderer/src/features/browser-pocket/ui/PocketStrip.tsx index bf051e75c..27da7c846 100644 --- a/src/renderer/src/features/browser-pocket/ui/PocketStrip.tsx +++ b/src/renderer/src/features/browser-pocket/ui/PocketStrip.tsx @@ -9,6 +9,7 @@ import { attachPocket, setPocketView } from '../actions' import { requestPocket } from '../state/pocketBus' import { usePocketLive, usePocketLiveStore } from '../state/pocketLiveStore' import { useLanePorts } from '../state/lanePortsStore' +import { LAYERS } from '@renderer/ui/layers' /** * The 22 px collapsed pocket in a lane (spec §4.1): glanceable, never a @@ -96,7 +97,7 @@ export function PocketStrip({ sessionId, workspace }: { sessionId: SessionId; wo role="tooltip" // Popover chrome like every floating menu (theme shadow; the old // hard rgba shadow was a smear on light themes). - className="rounded-float pointer-events-none absolute bottom-[24px] left-2 z-40 flex w-[320px] flex-col gap-1 overflow-hidden border border-popover-border bg-popover-bg p-1.5 text-[11px] text-ink shadow-[0_8px_24px_var(--theme-shadow-color)]" + className={`rounded-float pointer-events-none absolute bottom-[24px] left-2 ${LAYERS.paneOverlay} flex w-[320px] flex-col gap-1 overflow-hidden border border-popover-border bg-popover-bg p-1.5 text-[11px] text-ink shadow-[0_8px_24px_var(--theme-shadow-color)]`} > {live.thumbnail ? : null} {details.map(line => ( diff --git a/src/renderer/src/features/command-palette/ui/CommandSortControl.tsx b/src/renderer/src/features/command-palette/ui/CommandSortControl.tsx index 6c458e702..eded9aea6 100644 --- a/src/renderer/src/features/command-palette/ui/CommandSortControl.tsx +++ b/src/renderer/src/features/command-palette/ui/CommandSortControl.tsx @@ -5,6 +5,7 @@ import { COMMAND_SORT_MODE_LABELS, } from '@renderer/features/command-palette/lib/sortCommands' import type { CommandSortMode } from '@renderer/features/command-palette/lib/sortCommands' +import { LAYERS } from '@renderer/ui/layers' // The palette's browse-order picker. // @@ -203,12 +204,12 @@ export function CommandSortControl({ role="menu" // Right-anchored: the control sits at the right edge of the header, // so a left-anchored menu would hang off the dialog. - className="rounded-float overflow-hidden - absolute right-0 top-[calc(100%+4px)] z-50 + className={`rounded-float overflow-hidden + absolute right-0 top-[calc(100%+4px)] ${LAYERS.inSurfacePopover} min-w-[168px] border border-popover-border bg-popover-bg shadow-[0_8px_24px_var(--theme-shadow-color)] - " + `} > {COMMAND_SORT_MODES.map((candidate, index) => ( - +
@@ -402,7 +405,7 @@ export function RenderingDebugInspector({ sessionId, provider, onSave, onClose }
)} - + ) } diff --git a/src/renderer/src/features/debug/ui/DebugPanel.tsx b/src/renderer/src/features/debug/ui/DebugPanel.tsx index 3570477dd..1d1b3b1bd 100644 --- a/src/renderer/src/features/debug/ui/DebugPanel.tsx +++ b/src/renderer/src/features/debug/ui/DebugPanel.tsx @@ -5,6 +5,8 @@ import type { SessionRuntime } from '@renderer/workspace/workspaceStore' import type { Entry } from '@shared/types/transcript' import { AgentInlineTerminal } from '@renderer/features/debug/ui/AgentInlineTerminal' import { useScreenLease } from '@renderer/features/debug/useScreenLease' +import { DebugPanelHeader } from './DebugPanelHeader' +import { SidePanel } from '@renderer/components/ui/side-panel' // DebugPanel — inline diagnostic overlay showing the raw state of the // focused pane. Toggled via "Toggle Debug Panel" in the command palette. @@ -66,30 +68,9 @@ export function DebugPanel({ }, [runtime.entries]) return ( -
+ {/* Header */} -
- debug — {kind} session - -
+
{/* State flags */} @@ -185,7 +166,7 @@ export function DebugPanel({
{runtime.projectDir ?? '(none)'}
-
+ ) } diff --git a/src/renderer/src/features/debug/ui/DebugPanelHeader.tsx b/src/renderer/src/features/debug/ui/DebugPanelHeader.tsx new file mode 100644 index 000000000..010a15222 --- /dev/null +++ b/src/renderer/src/features/debug/ui/DebugPanelHeader.tsx @@ -0,0 +1,47 @@ +import type { ReactNode } from 'react' + +/** + * The header shared by the debug-family side panels (#512): a red uppercase + * title, optional actions, and one close. + * + * WHY its own header and not PanelHeader: these are diagnostic tools, and the + * red title is the deliberate cue that the panel reads raw session state + * rather than being product UI (ProxyDebugPanel's note). What they must share + * with PanelHeader is a close with an accessible name: all five copies of + * this header drew a bare "×" that a screen reader announced as nothing. + */ +export function DebugPanelHeader({ + title, + closeLabel, + actions, + onClose, +}: { + title: ReactNode + /** Accessible name of the close button ("Close debug logs"). */ + closeLabel: string + actions?: ReactNode + onClose: () => void +}) { + return ( +
+ {title} +
+ {actions} + +
+
+ ) +} diff --git a/src/renderer/src/features/debug/ui/DevDebugPanel.tsx b/src/renderer/src/features/debug/ui/DevDebugPanel.tsx index b48a2be1d..bb167bf82 100644 --- a/src/renderer/src/features/debug/ui/DevDebugPanel.tsx +++ b/src/renderer/src/features/debug/ui/DevDebugPanel.tsx @@ -8,6 +8,8 @@ import type { } from '@renderer/features/debug/devModules/types' import type { Workspace } from '@renderer/workspace/workspaceStore' import type { SessionRuntime } from '@renderer/session-runtime/state' +import { DebugPanelHeader } from './DebugPanelHeader' +import { SidePanel } from '@renderer/components/ui/side-panel' const STORAGE_KEY = 'agent-code:dev-debug:enabled-modules' @@ -79,27 +81,8 @@ export function DevDebugPanel({ sessionId, runtime, kind, workspace, onClose }: } return ( -
-
- dev debug - -
+ +
@@ -198,7 +181,7 @@ export function DevDebugPanel({ sessionId, runtime, kind, workspace, onClose }: )) )}
-
+
) } diff --git a/src/renderer/src/features/debug/ui/FeedDebugPanel.tsx b/src/renderer/src/features/debug/ui/FeedDebugPanel.tsx index 7fca4e096..14a9cf72a 100644 --- a/src/renderer/src/features/debug/ui/FeedDebugPanel.tsx +++ b/src/renderer/src/features/debug/ui/FeedDebugPanel.tsx @@ -1,6 +1,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { FeedDebugEntry, FeedDebugLayer, SessionRuntime } from '@renderer/session-runtime/state' +import { DebugPanelHeader } from './DebugPanelHeader' +import { SidePanel } from '@renderer/components/ui/side-panel' type Props = { sessionId: string @@ -55,27 +57,8 @@ export function FeedDebugPanel({ sessionId, runtime, kind, onClose }: Props) { }, []) return ( -
-
- debug logs — {kind} pane - -
+ +
session @@ -154,7 +137,7 @@ export function FeedDebugPanel({ sessionId, runtime, kind, onClose }: Props) { }) )}
-
+ ) } diff --git a/src/renderer/src/features/debug/ui/HtmlDebugPanel.tsx b/src/renderer/src/features/debug/ui/HtmlDebugPanel.tsx index 497bda106..01872c5a4 100644 --- a/src/renderer/src/features/debug/ui/HtmlDebugPanel.tsx +++ b/src/renderer/src/features/debug/ui/HtmlDebugPanel.tsx @@ -1,6 +1,8 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { sanitizeHtml } from '@renderer/lib/sanitizeHtml' +import { DebugPanelHeader } from './DebugPanelHeader' +import { SidePanel } from '@renderer/components/ui/side-panel' // HtmlDebugPanel — grabs the focused pane's `outerHTML` and shows // it as copy-pasteable text. Fourth in the debug-panel family @@ -188,25 +190,17 @@ export function HtmlDebugPanel({ sessionId, kind, onClose }: Props) { : `no pane found for ${sessionId.slice(0, 8)}` return ( -
+ {/* Header — matches the existing debug panels: red uppercase title, row of action buttons on the right, × close. Width of 540px (not 380 like DebugPanel) because HTML wraps badly at narrower widths — FeedDebugPanel landed at 540 for the same reason. */} -
- debug — html ({kind}) -
+ - -
-
+ } + /> {/* Mode toggle row. Inline tab-style: two text buttons with the active one underlined/accented. Placed as its own @@ -298,7 +284,7 @@ export function HtmlDebugPanel({ sessionId, kind, onClose }: Props) {
)}
- + ) } diff --git a/src/renderer/src/features/debug/ui/ProxyDebugPanel.tsx b/src/renderer/src/features/debug/ui/ProxyDebugPanel.tsx index 405c0ec2f..ae6cbea4d 100644 --- a/src/renderer/src/features/debug/ui/ProxyDebugPanel.tsx +++ b/src/renderer/src/features/debug/ui/ProxyDebugPanel.tsx @@ -1,6 +1,8 @@ import { useMemo } from 'react' import { useAppStore } from '@renderer/app-state/hooks' import { emptySemanticRuntime } from '@renderer/session-runtime/state' +import { DebugPanelHeader } from './DebugPanelHeader' +import { SidePanel } from '@renderer/components/ui/side-panel' // ProxyDebugPanel — live inspector for the semantic stream and any // provider transport attribution that survives into the shared runtime. @@ -65,35 +67,12 @@ export function ProxyDebugPanel({ sessionId, kind, onClose }: Props) { }, [state.currentTurn]) return ( -
+ {/* Header — same chrome as DebugPanel. Kept red title for uniformity across debug-family panels (they are all diagnostic tools that read session state, not user-facing UI). */} -
- proxy debug — {kind} semantic stream -
- -
-
+
{/* Flow attribution */} @@ -251,7 +230,7 @@ export function ProxyDebugPanel({ sessionId, kind, onClose }: Props) {
-
+ ) } diff --git a/src/renderer/src/features/editor/ui/ExplorerPane.tsx b/src/renderer/src/features/editor/ui/ExplorerPane.tsx index 30756fec2..21d06ef84 100644 --- a/src/renderer/src/features/editor/ui/ExplorerPane.tsx +++ b/src/renderer/src/features/editor/ui/ExplorerPane.tsx @@ -6,6 +6,7 @@ import { FileIcon, FolderIcon } from '@renderer/features/editor/lib/fileIcon' // shape. See @shared/types/editorFs. import type { EditorFsEntry } from '@shared/types/editorFs' import { withVisibleControls } from '@shared/text/visibleControls' +import { LAYERS } from '@renderer/ui/layers' type TreeNode = { entry: EditorFsEntry @@ -779,7 +780,7 @@ export function ExplorerPane({ // pointer or the tree row (Shift+F10), which Radix DropdownMenu can // only fake with a virtual anchor, and its own roving focus, Escape // and Tab-closes behaviour are already keyboard-complete (M4). - className="fixed z-[1150] min-w-[160px] rounded-float overflow-hidden border border-popover-border bg-popover-bg py-1 shadow-[0_8px_24px_var(--theme-shadow-color)]" + className={`fixed ${LAYERS.menu} min-w-[160px] rounded-float overflow-hidden border border-popover-border bg-popover-bg py-1 shadow-[0_8px_24px_var(--theme-shadow-color)]`} style={{ left: menu.x, top: menu.y }} onMouseDown={event => event.stopPropagation()} onBlur={event => { diff --git a/src/renderer/src/features/git/ui/GitBar.tsx b/src/renderer/src/features/git/ui/GitBar.tsx index 3e98067fe..cbbf78b0c 100644 --- a/src/renderer/src/features/git/ui/GitBar.tsx +++ b/src/renderer/src/features/git/ui/GitBar.tsx @@ -11,6 +11,7 @@ import type { GitRecentCommit, GitSubmoduleStatus, } from '@shared/types/gitStatus' +import { SidePanel } from '@renderer/components/ui/side-panel' // GitBar — a narrow right-edge panel showing git state for the // focused pane's cwd: current branch, latest 5 commits, and the @@ -82,13 +83,7 @@ export function GitBar({ cwd, onClose }: Props) { const totalDel = data?.files.reduce((s, f) => s + f.deletions, 0) ?? 0 return ( -
+ {/* The shared side-panel header (UI pass, G-26). Its close had no accessible name and no focus style. */} @@ -195,7 +190,7 @@ export function GitBar({ cwd, onClose }: Props) { {!data && !error && (
loading…
)} -
+ ) } diff --git a/src/renderer/src/features/goal-loop/GoalLoopPane.tsx b/src/renderer/src/features/goal-loop/GoalLoopPane.tsx index 46c14b9f5..07b2f3d80 100644 --- a/src/renderer/src/features/goal-loop/GoalLoopPane.tsx +++ b/src/renderer/src/features/goal-loop/GoalLoopPane.tsx @@ -8,6 +8,7 @@ import type { GoalLoopControlAction, GoalLoopState } from '@shared/types/goalLoo import { dismissGoalLoop, useGoalLoopView } from './viewState' import { withVisibleControls } from '@shared/text/visibleControls' import { useSwapFocus } from '@renderer/lib/useSwapFocus' +import { LAYERS } from '@renderer/ui/layers' const PHASE_LABEL: Record = { active: 'active', paused: 'paused', ended: 'ended', @@ -79,7 +80,7 @@ function GoalLoopOverlay({ children, takeFocus }: { children: ReactNode; takeFoc data-goal-loop-active={takeFocus ? '' : undefined} role="dialog" aria-label="Agent goal loop" - className="absolute inset-0 z-50 bg-canvas text-ink" + className={`absolute inset-0 ${LAYERS.paneTakeover} bg-canvas text-ink`} onMouseDown={event => { event.preventDefault(); event.stopPropagation() }} onClick={event => event.stopPropagation()} onKeyDown={event => { @@ -204,7 +205,7 @@ export function GoalLoopPane({ sessionId, focused = true }: { sessionId: string; // also a genuine blocking surface, may do the same. const strip =
event.stopPropagation()} onClick={event => event.stopPropagation()} > diff --git a/src/renderer/src/features/path-picker/ui/PathInput.tsx b/src/renderer/src/features/path-picker/ui/PathInput.tsx index 44153a5aa..033da4848 100644 --- a/src/renderer/src/features/path-picker/ui/PathInput.tsx +++ b/src/renderer/src/features/path-picker/ui/PathInput.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, useId } from 'react' import type { CSSProperties } from 'react' import { containsInvisibleControls, withVisibleControls } from '@shared/text/visibleControls' +import { LAYERS } from '@renderer/ui/layers' // PathInput — the path picker's path-with-completion input. // @@ -284,7 +285,7 @@ export function PathInput({ id={listboxId} role="listbox" className={` - absolute left-0 right-0 top-full mt-1 z-50 + absolute left-0 right-0 top-full mt-1 ${LAYERS.inSurfacePopover} bg-popover-bg border border-popover-border rounded-float max-h-[280px] overflow-auto shadow-[0_8px_24px_var(--theme-shadow-color)] diff --git a/src/renderer/src/features/tldr/TldrOverlay.tsx b/src/renderer/src/features/tldr/TldrOverlay.tsx index dcde1b5cc..c530569eb 100644 --- a/src/renderer/src/features/tldr/TldrOverlay.tsx +++ b/src/renderer/src/features/tldr/TldrOverlay.tsx @@ -6,6 +6,7 @@ import { TldrFreshness } from './TldrFreshness' import { isPreviewVisible, useTldrView } from './viewState' import { useAgentTerminalOwnerVisible } from '@renderer/workspace/terminal/AgentTerminalOwnership' import type { PreviewKind } from './viewState' +import { LAYERS } from '@renderer/ui/layers' // What differs between the TLDR and Goal peeks is only where the text comes // from and what it is called. Sharing the overlay keeps the subscription race @@ -101,7 +102,7 @@ export function TldrOverlay({ kind = 'tldr', identity, enabled, runtime, provide // follows the active theme (light themes included) and reads in the same // canvas and ink as the rest of the UI. Opaque canvas keeps the pane's own // text from bleeding through behind the summary. - className="absolute inset-0 z-50 bg-canvas text-center text-ink" + className={`absolute inset-0 ${LAYERS.paneTakeover} bg-canvas text-center text-ink`} onMouseDown={event => { event.preventDefault(); event.stopPropagation() }} onClick={event => event.stopPropagation()} > diff --git a/src/renderer/src/features/voice-dictation/ui/VoiceDictationOverlay.tsx b/src/renderer/src/features/voice-dictation/ui/VoiceDictationOverlay.tsx index 1678a8611..ec1a5f95f 100644 --- a/src/renderer/src/features/voice-dictation/ui/VoiceDictationOverlay.tsx +++ b/src/renderer/src/features/voice-dictation/ui/VoiceDictationOverlay.tsx @@ -1,4 +1,5 @@ import { useDictationOverlayState } from '@renderer/features/voice-dictation/dictationStatusStore' +import { LAYERS } from '@renderer/ui/layers' // VoiceDictationOverlay — terminal-mode floating chip rendered at App root. // Rendered Agent mode keeps the old inline composer mic affordance; only @@ -53,7 +54,7 @@ export function VoiceDictationOverlay() { return (
{ diff --git a/src/renderer/src/features/worktrees/ui/WorktreesBar.tsx b/src/renderer/src/features/worktrees/ui/WorktreesBar.tsx index 459ab8c9e..c9aa817bd 100644 --- a/src/renderer/src/features/worktrees/ui/WorktreesBar.tsx +++ b/src/renderer/src/features/worktrees/ui/WorktreesBar.tsx @@ -11,6 +11,7 @@ import type { } from '@renderer/features/worktrees/lib/loadWorktreeDump' import { worktreeColorForIdentity } from '@renderer/workspace/tile-tree/TileLeaf/worktreeBadgeColor' import type { Workspace } from '@renderer/workspace/workspaceStore' +import { SidePanel } from '@renderer/components/ui/side-panel' type Props = { cwd: string | null @@ -231,7 +232,7 @@ export function WorktreesBar({ cwd, workspace, onClose }: Props) { }, [rows]) return ( -
+ {/* The shared side-panel header (UI pass, G-26): Title Case ghost actions instead of lowercase text links, and a named close (the × had no accessible name). */} @@ -295,7 +296,7 @@ export function WorktreesBar({ cwd, workspace, onClose }: Props) { ))}
)} -
+ ) } diff --git a/src/renderer/src/ui/GlobalToast.tsx b/src/renderer/src/ui/GlobalToast.tsx index cb9b671fc..86fb6dae8 100644 --- a/src/renderer/src/ui/GlobalToast.tsx +++ b/src/renderer/src/ui/GlobalToast.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { useAppStore } from '@renderer/app-state/hooks' import { managedSkillsUnavailableMessage } from '@shared/types/tldr' import { GlobalToastContext } from '@renderer/ui/GlobalToastContext' +import { LAYERS } from '@renderer/ui/layers' // GlobalToast — app-wide toast system rendered in the top-right corner. // @@ -146,7 +147,7 @@ export function GlobalToastProvider({ children }: { children: React.ReactNode }) The focus ring's offset gap is what makes it visible on the accent fill, because the built-in focus ring IS the accent (Claude review of #1221, reviewer C F1). */} -
+
{toast && (