From 557df95dc0ec663fe2ab8f4c5b4947efac536958 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Sat, 12 Sep 2026 15:17:40 +0900 Subject: [PATCH 1/2] test(codex): align native restore safety fixtures --- .../codex-inject-integration.test.ts | 18 +++++++++++------- tests/codex-integration/codex-journal.test.ts | 13 ++++++++----- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/tests/codex-integration/codex-inject-integration.test.ts b/tests/codex-integration/codex-inject-integration.test.ts index e471961311..5ff8615abf 100644 --- a/tests/codex-integration/codex-inject-integration.test.ts +++ b/tests/codex-integration/codex-inject-integration.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test, beforeEach, afterEach, setDefaultTimeout } from "bun:test"; -import { existsSync, mkdirSync, mkdtempSync, writeFileSync, readFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, realpathSync, writeFileSync, readFileSync } from "node:fs"; import { spawnSync } from "node:child_process"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; @@ -59,7 +59,8 @@ describe("injectCodexConfig integration (Design B)", () => { let ocxHome: string; beforeEach(() => { - codexHome = mkdtempSync(join(tmpdir(), "ocx-inject-codex-")); + // Match paths.ts so Windows short TEMP aliases cannot change manifest identity or spy targets. + codexHome = realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-inject-codex-"))); ocxHome = mkdtempSync(join(tmpdir(), "ocx-inject-home-")); }); @@ -116,16 +117,19 @@ describe("injectCodexConfig integration (Design B)", () => { const target = join(process.env.CODEX_HOME,"opencodex.config.toml"); const configPath = join(process.env.CODEX_HOME,"config.toml"); fs.writeFileSync(configPath,'model="test"'); + const realRead = fs.readFileSync; + let denyProfileRead = false; + // Install before import so the injector's bound fs read sees the failure too. + const readSpy = spyOn(fs,"readFileSync").mockImplementation((path,...args)=>{ + if (denyProfileRead && String(path)===target) throw Object.assign(new Error("fixture denied"),{code:"EACCES"}); + return realRead(path,...args); + }); const {injectCodexConfig,restoreNativeCodex,restoreNativeCodexAsync}=require("./src/codex/inject"); const initial=await injectCodexConfig(10100,{}); if(!initial.success) throw new Error("fixture injection failed"); const watched=[configPath,target,join(process.env.CODEX_HOME,"opencodex-journal.json")]; const original=watched.map(path=>fs.readFileSync(path,"utf8")); - const realRead = fs.readFileSync; - const readSpy = spyOn(fs,"readFileSync").mockImplementation((path,...args)=>{ - if (String(path)===target) throw Object.assign(new Error("fixture denied"),{code:"EACCES"}); - return realRead(path,...args); - }); + denyProfileRead = true; const {captureCodexPreImages,restoreCodexPreImages}=require("./src/codex/inject-coordination"); let captureCode; try { captureCodexPreImages(); } catch(error) { captureCode=error.code; } diff --git a/tests/codex-integration/codex-journal.test.ts b/tests/codex-integration/codex-journal.test.ts index 05d6d14e37..c568bbb661 100644 --- a/tests/codex-integration/codex-journal.test.ts +++ b/tests/codex-integration/codex-journal.test.ts @@ -167,7 +167,9 @@ describe("codex-journal", () => { expect(r.status).toBe(0); const out = JSON.parse(r.stdout); expect(out.result.success).toBe(false); - expect(out.result.artifacts.config.state).toBe("failed"); + for (const artifact of Object.values(out.result.artifacts)) { + expect(artifact).toMatchObject({state:"skipped",changed:false}); + } expect(out.config).toBe(edited); expect(out.journalPreserved).toBe(true); }); @@ -511,11 +513,12 @@ describe("codex-journal", () => { }, { catalogPath: null }); const marker = ${JSON.stringify(MANAGED_SUBAGENT_DEFAULT_MARKER)}; const injected = fs.readFileSync(configPath, "utf8"); - fs.writeFileSync(configPath, injected.replace( + const damaged = injected.replace( marker + '\\ndefault_subagent_model', marker + '\\n\\ndefault_subagent_model', - ), "utf8"); - console.log(JSON.stringify(restoreNativeCodex())); + ); + fs.writeFileSync(configPath, damaged, "utf8"); + console.log(JSON.stringify({ ...restoreNativeCodex(), before: damaged })); })(); `); @@ -525,7 +528,7 @@ describe("codex-journal", () => { expect(result.message).toContain("could not be safely removed"); expect(result.message).toContain("orphaned managed subagent default marker"); const after = readFileSync(join(testDir, "config.toml"), "utf8"); - expect(after).not.toContain("openai_base_url"); + expect(after).toBe(result.before); expect(after).toContain("# Managed by opencodex: native subagent default"); expect(after).toContain('default_subagent_model = "gpt-5.6-sol"'); expect(existsSync(join(testDir, "opencodex-journal.json"))).toBe(true); From b06b520ce6b309be6c657f85294fb393dce9ad27 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Sat, 12 Sep 2026 15:17:59 +0900 Subject: [PATCH 2/2] fix(codex): compensate restore-time history migration --- .../content/docs/guides/codex-integration.md | 2 + src/codex/inject.ts | 6 + structure/catalog.md | 2 +- structure/codex-home.md | 2 +- structure/config.md | 2 +- structure/gui-and-management-api.md | 2 +- structure/ops/docs-and-release.md | 2 +- structure/providers/openai-tiers.md | 2 +- structure/runtime.md | 2 +- structure/subagents.md | 2 +- .../codex-inject-integration.test.ts | 116 ++++++++++++++++++ 11 files changed, 132 insertions(+), 8 deletions(-) diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index 333671101d..8b04cdfb4a 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -786,4 +786,6 @@ When a routed preferred model may receive V2 work from a native ChatGPT parent, When an affected history store supports paginated records, a provider transition may return `history_paginated_requires_native_writer`. OpenCodex preserves the current configuration, profile, catalog, rollout and restore provenance instead of assigning ordinals outside Codex. This includes legacy rows in a migration-capable store. No-transition exits, such as preserving an external provider, remain available. +Native restore checks again after restoring the journal or removing owned configuration. If migration is detected during that write interval, it puts back the prior configuration, profile and journal, skips catalog/history restoration, and rolls back any coordinated remove transition. This compensation does not lock out Codex's own writer or exclude changes after the final check. + Do not delete a provider definition still referenced by a conversation, repeatedly run `ocx sync` or legacy recovery, or rewrite an active rollout to work around this refusal. Keep the current files, close the affected conversation before any recovery, and report the exact error and versions without uploading private history. Use a verified fix with native-writer coordination; a backup or a successful script alone does not prove the conversation is visible again. Check the restored conversation in Codex after reopening. diff --git a/src/codex/inject.ts b/src/codex/inject.ts index 65661963fb..3df34ec505 100644 --- a/src/codex/inject.ts +++ b/src/codex/inject.ts @@ -1937,6 +1937,12 @@ function restoreCodexConfigInlineImpl(kind: string): CodexRestoreConfigResult { const restored = journal.configRestored ? { success: true, message: "Codex config restored from opencodex journal." } : removeCodexConfig({ preserveProfile: journal.profileRestored || journal.profileChanged }); + if (restored.success) { + // A successful journal/fallback write can race native history migration too. + // Refuse here while preimage compensation and the remove transaction can roll back. + const finalHistoryError = preflightCodexHistoryInjection(false, false); + if (finalHistoryError) return { state: "failed", changed: false, action: "failed", message: `Codex configuration and journal preserved: ${finalHistoryError}.` }; + } return restored.success ? { state: "ok", diff --git a/structure/catalog.md b/structure/catalog.md index 0ba4acca3e..22e156bd40 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -274,7 +274,7 @@ see [Combo editor routing quota](gui-and-management-api.md#combo-editor-routing- ## Paginated history writer boundary -`src/codex/history-provider.ts` refuses external writes to paginated or migration-capable history. `src/codex/inject.ts` checks affected rows and manifest-owned restore targets before artifact changes and compensates detected migration. Failed config restore stops later catalog/history work. See the [history writer contract](codex-home.md#paginated-history-writer-boundary) for guarantees and concurrent-writer limits. +`src/codex/history-provider.ts` refuses external writes to paginated or migration-capable history. `src/codex/inject.ts` checks affected rows and manifest-owned restore targets before and after config/profile/journal changes, including successful journal and fallback restores, and compensates detected migration. Failed config restore stops later catalog/history work and rolls back a coordinated remove transition. See the [history writer contract](codex-home.md#paginated-history-writer-boundary) for guarantees and concurrent-writer limits. Claude replay carries [Go conversation affinity](data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) privately to final dispatch; preliminary route selection does not inject Go-only headers. diff --git a/structure/codex-home.md b/structure/codex-home.md index b11ddd3f1a..693894646a 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -231,7 +231,7 @@ Codex display-cache expiry, retained main-policy evidence, and reset history fol `src/codex/history-provider.ts` rejects provider-history changes with `history_paginated_requires_native_writer` when a target begins with an ordinal-bearing record or declares `history_mode=paginated`. Apply, manifest-backed restore, and explicit legacy recovery preflight all selected targets before changing database rows or manifests. The append boundary checks again. Codex owns ordinal allocation and the live projection cursor; reading the last ordinal and appending N+1 is not safe concurrent coordination. Legacy unnumbered rollouts retain their existing behavior. This guard prevents the observed stable-format corruption; it does not implement native-writer integration or guarantee a concurrent legacy-to-paginated conversion is excluded. -Injection preflights affected history using the normalized config candidate before writing config/profile/journal, then checks again after the complete artifact write. Detected migration restores all three preimages before returning a structured refusal, including on legacy-uncoordinated homes. A failed config restore stops catalog/history work; coordinated restore rolls back its published remove transition. Legacy first-line provider patches are bound to the validated file identity before and after writing. These compensating checks do not provide a native-writer lock or authorize external ordinal allocation. +Injection preflights affected history using the normalized config candidate before writing config/profile/journal, then checks again after the complete artifact write. Native restore also rechecks after successful journal restoration or fallback removal, while exact config/profile/journal preimages and any coordinated remove transaction remain available for compensation. Detected migration restores all three preimages before returning a structured refusal, including on legacy-uncoordinated homes. A failed config restore stops catalog/history work; coordinated restore rolls back its published remove transition. Legacy first-line provider patches are bound to the validated file identity before and after writing. These compensating checks do not provide a native-writer lock or authorize external ordinal allocation. The legacy external writer is now refused for affected rows in any store whose schema includes history_mode, even while their row mode is still legacy. This deliberately sacrifices automatic relabeling on migration-capable stores rather than racing native conversion. Synchronous/asynchronous restore, inline journal restore, and direct config removal preserve all artifacts on the same refusal. diff --git a/structure/config.md b/structure/config.md index 80bb62bc73..7958111af9 100644 --- a/structure/config.md +++ b/structure/config.md @@ -198,7 +198,7 @@ Codex display-cache expiry, retained main-policy evidence, and reset history fol ## Paginated history writer boundary -`src/codex/history-provider.ts` refuses external writes to paginated or migration-capable history. `src/codex/inject.ts` checks affected rows and manifest-owned restore targets before artifact changes and compensates detected migration. Failed config restore stops later catalog/history work. See the [history writer contract](codex-home.md#paginated-history-writer-boundary) for guarantees and concurrent-writer limits. +`src/codex/history-provider.ts` refuses external writes to paginated or migration-capable history. `src/codex/inject.ts` checks affected rows and manifest-owned restore targets before and after config/profile/journal changes, including successful journal and fallback restores, and compensates detected migration. Failed config restore stops later catalog/history work and rolls back a coordinated remove transition. See the [history writer contract](codex-home.md#paginated-history-writer-boundary) for guarantees and concurrent-writer limits. The Cline client keeps connection settings and models in a separate native file pair; client path overrides and reversible writes follow [Cline paired files](clients/integrations.md#cline-paired-files). diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 73090d646e..ae883463ad 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -533,7 +533,7 @@ advances the observation clock, so a retained older row cannot defer evaluation ## Paginated history writer boundary -`src/codex/history-provider.ts` refuses external writes to paginated or migration-capable history. `src/codex/inject.ts` checks affected rows and manifest-owned restore targets before artifact changes and compensates detected migration. Failed config restore stops later catalog/history work. See the [history writer contract](codex-home.md#paginated-history-writer-boundary) for guarantees and concurrent-writer limits. +`src/codex/history-provider.ts` refuses external writes to paginated or migration-capable history. `src/codex/inject.ts` checks affected rows and manifest-owned restore targets before and after config/profile/journal changes, including successful journal and fallback restores, and compensates detected migration. Failed config restore stops later catalog/history work and rolls back a coordinated remove transition. See the [history writer contract](codex-home.md#paginated-history-writer-boundary) for guarantees and concurrent-writer limits. Claude replay carries [Go conversation affinity](data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) privately to final dispatch; preliminary route selection does not inject Go-only headers. diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index 827540194a..b73edf3b99 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -311,6 +311,6 @@ see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routi ## Paginated history writer boundary -`src/codex/history-provider.ts` refuses external writes to paginated or migration-capable history. `src/codex/inject.ts` checks affected rows and manifest-owned restore targets before artifact changes and compensates detected migration. Failed config restore stops later catalog/history work. See the [history writer contract](../codex-home.md#paginated-history-writer-boundary) for guarantees and concurrent-writer limits. +`src/codex/history-provider.ts` refuses external writes to paginated or migration-capable history. `src/codex/inject.ts` checks affected rows and manifest-owned restore targets before and after config/profile/journal changes, including successful journal and fallback restores, and compensates detected migration. Failed config restore stops later catalog/history work and rolls back a coordinated remove transition. See the [history writer contract](../codex-home.md#paginated-history-writer-boundary) for guarantees and concurrent-writer limits. The integrations guide documents Cline CLI as a two-file, loopback-only integration. Hosted CI validates its source-backed fixtures; the packaged dashboard exposes it through the existing client list. diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index a44edff557..f81fb336bb 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -401,4 +401,4 @@ successful main usage refresh clears the runtime mark. ## Paginated history writer boundary -`src/codex/history-provider.ts` refuses external writes to paginated or migration-capable history. `src/codex/inject.ts` checks affected rows and manifest-owned restore targets before artifact changes and compensates detected migration. Failed config restore stops later catalog/history work. See the [history writer contract](../codex-home.md#paginated-history-writer-boundary) for guarantees and concurrent-writer limits. +`src/codex/history-provider.ts` refuses external writes to paginated or migration-capable history. `src/codex/inject.ts` checks affected rows and manifest-owned restore targets before and after config/profile/journal changes, including successful journal and fallback restores, and compensates detected migration. Failed config restore stops later catalog/history work and rolls back a coordinated remove transition. See the [history writer contract](../codex-home.md#paginated-history-writer-boundary) for guarantees and concurrent-writer limits. diff --git a/structure/runtime.md b/structure/runtime.md index 2f5ce70e21..ef28a0e816 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -221,7 +221,7 @@ see [Combo editor routing quota](gui-and-management-api.md#combo-editor-routing- ## Paginated history writer boundary -`src/codex/history-provider.ts` refuses external writes to paginated or migration-capable history. `src/codex/inject.ts` checks affected rows and manifest-owned restore targets before artifact changes and compensates detected migration. Failed config restore stops later catalog/history work. See the [history writer contract](codex-home.md#paginated-history-writer-boundary) for guarantees and concurrent-writer limits. +`src/codex/history-provider.ts` refuses external writes to paginated or migration-capable history. `src/codex/inject.ts` checks affected rows and manifest-owned restore targets before and after config/profile/journal changes, including successful journal and fallback restores, and compensates detected migration. Failed config restore stops later catalog/history work and rolls back a coordinated remove transition. See the [history writer contract](codex-home.md#paginated-history-writer-boundary) for guarantees and concurrent-writer limits. Claude replay carries [Go conversation affinity](data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) privately to final dispatch; preliminary route selection does not inject Go-only headers. diff --git a/structure/subagents.md b/structure/subagents.md index f190aab084..34fc2aa012 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -210,7 +210,7 @@ see [Combo editor routing quota](gui-and-management-api.md#combo-editor-routing- ## Paginated history writer boundary -`src/codex/history-provider.ts` refuses external writes to paginated or migration-capable history. `src/codex/inject.ts` checks affected rows and manifest-owned restore targets before artifact changes and compensates detected migration. Failed config restore stops later catalog/history work. See the [history writer contract](codex-home.md#paginated-history-writer-boundary) for guarantees and concurrent-writer limits. +`src/codex/history-provider.ts` refuses external writes to paginated or migration-capable history. `src/codex/inject.ts` checks affected rows and manifest-owned restore targets before and after config/profile/journal changes, including successful journal and fallback restores, and compensates detected migration. Failed config restore stops later catalog/history work and rolls back a coordinated remove transition. See the [history writer contract](codex-home.md#paginated-history-writer-boundary) for guarantees and concurrent-writer limits. Claude replay carries [Go conversation affinity](data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch) privately to final dispatch; preliminary route selection does not inject Go-only headers. diff --git a/tests/codex-integration/codex-inject-integration.test.ts b/tests/codex-integration/codex-inject-integration.test.ts index 5ff8615abf..d0184e860b 100644 --- a/tests/codex-integration/codex-inject-integration.test.ts +++ b/tests/codex-integration/codex-inject-integration.test.ts @@ -234,6 +234,122 @@ describe("injectCodexConfig integration (Design B)", () => { else expect(existsSync(join(codexHome,"opencodex.config.toml"))).toBe(false); }); + for (const path of ["journal", "fallback"]) { + test.each([ + ["sync", "schema"], ["legacy-uncoordinated", "schema"], ["coordinated", "schema"], + ["sync", "ordinal"], ["coordinated", "none"], + ])(`history format at successful ${path} restore commit (%s, %s)`, (kind, migration) => { + const script = ` + const fs = require("node:fs"); + const { join } = require("node:path"); + const { Database } = require("bun:sqlite"); + const { spyOn } = require("bun:test"); + let onUnlink; + const realUnlink = fs.unlinkSync; + spyOn(fs, "unlinkSync").mockImplementation((p, ...args) => { + const result = realUnlink(p, ...args); + onUnlink?.(p); + return result; + }); + const { restoreNativeCodex, restoreNativeCodexAsync, setBeforeRestoreConfigForTests } = require("./src/codex/inject"); + const { writeJournal, markJournalInjectedState } = require("./src/codex/journal"); + const { syncCodexHistoryProvider, historyBackupPathFor } = require("./src/codex/history-provider"); + // Use the same canonical home as the writer (Windows TEMP may use an 8.3 alias). + const home = require("./src/codex/paths").CODEX_HOME; + const configPath = join(home, "config.toml"); + const profilePath = join(home, "opencodex.config.toml"); + const journalPath = join(home, "opencodex-journal.json"); + fs.writeFileSync(configPath, 'model="test"\\n'); + const readState = ${kind === "coordinated" ? 'require("./src/codex/transition-state").readCodexTransitionState' : "() => null"}; + const beforeState = readState(); + if (${JSON.stringify(path)} === "journal") writeJournal(); + const routed = 'model_provider="opencodex"\\n[model_providers.opencodex]\\nname="OpenCodex"\\nbase_url="http://127.0.0.1:10100/v1"\\nwire_api="responses"\\n'; + const profile = ${JSON.stringify(kind === "legacy-uncoordinated" ? "[invalid profile\n" : "# generated profile\n")}; + fs.writeFileSync(configPath, routed); + fs.writeFileSync(profilePath, profile); + if (${JSON.stringify(path)} === "journal") markJournalInjectedState(routed, profile, { + injectedOpenaiBaseUrl: null, injectedRealtimeWsBaseUrl: null, injectedCatalogPath: null, + }); + const dbPath = join(home, "state_5.sqlite"); + const rollout = join(home, "restore-migration.jsonl"); + fs.writeFileSync(rollout, JSON.stringify({type:"session_meta",payload:{id:"fixture",model_provider:"openai",source:"cli"}})+"\\n"); + const db = new Database(dbPath); + db.run("CREATE TABLE threads (id TEXT PRIMARY KEY, rollout_path TEXT, model_provider TEXT, source TEXT, first_user_message TEXT, has_user_event INTEGER)"); + db.run("INSERT INTO threads VALUES ('fixture', ?, 'openai', 'cli', 'hello', 1)", rollout); + db.close(); + const routedHistory = syncCodexHistoryProvider("opencodex", dbPath); + if (routedHistory.failed || routedHistory.rows !== 1) throw new Error("fixture history route failed"); + fs.writeFileSync(join(home, "models_cache.json"), '{"models":[],"sentinel":"preserve"}\\n'); + fs.writeFileSync(join(home, "config.toml.bak"), "legacy backup sentinel\\n"); + const read = p => fs.existsSync(p) ? fs.readFileSync(p, "utf8") : null; + const watched = [configPath, profilePath, journalPath, join(home,"models_cache.json"), join(home,"config.toml.bak"), historyBackupPathFor(dbPath), rollout]; + const before = watched.map(read); + const target = ${JSON.stringify(path)} === "journal" ? journalPath : profilePath; + let observed; + let migrations = 0; + let reachedSuccessfulWrite = false; + setBeforeRestoreConfigForTests(value => { + observed = value; + onUnlink = p => { + if (String(p) === target && migrations++ === 0) { + reachedSuccessfulWrite = !fs.existsSync(target) && read(configPath) !== before[0]; + if (${JSON.stringify(migration)} === "schema") { + const migrated = new Database(dbPath); + migrated.run("ALTER TABLE threads ADD COLUMN history_mode TEXT DEFAULT 'legacy'"); + migrated.close(); + } else if (${JSON.stringify(migration)} === "ordinal") { + const lines = read(rollout).split("\\n"); + lines[0] = JSON.stringify({ ...JSON.parse(lines[0]), ordinal: 0 }); + fs.writeFileSync(rollout, lines.join("\\n")); + // The simulated native writer owns these new rollout bytes. + before[before.length - 1] = read(rollout); + } + } + }; + }); + const result = ${kind === "sync" ? "restoreNativeCodex()" : "await restoreNativeCodexAsync()"}; + const after = watched.map(read); + const afterState = readState(); + const historyDb = new Database(dbPath, {readonly:true}); + const provider = historyDb.query("SELECT model_provider FROM threads WHERE id='fixture'").get().model_provider; + historyDb.close(); + console.log(JSON.stringify({observed, migrations, reachedSuccessfulWrite, result, before, after, beforeState, afterState, provider})); + `; + const child = spawnSync(process.execPath, ["--eval", script], { + cwd: repoRoot, env: { ...process.env, CODEX_HOME: codexHome, OPENCODEX_HOME: ocxHome }, + encoding: "utf8", timeout: SPAWN_BUDGET_MS - 5_000, + }); + expect(child.status, child.stderr).toBe(0); + const value = JSON.parse(child.stdout); + expect(value.observed).toBe(kind); + expect(value.migrations, JSON.stringify(value.result)).toBe(1); + expect(value.reachedSuccessfulWrite).toBe(true); + if (migration === "none") { + expect(value.result.success).toBe(true); + expect(value.result.artifacts.config.action).toBe(path === "journal" ? "journal-restored" : "owned-fields-stripped"); + expect(value.result.artifacts.history).toMatchObject({state:"ok",rows:1}); + expect(value.provider).toBe("openai"); + expect(value.after[1]).toBeNull(); + expect(value.after[2]).toBeNull(); + expect(value.after[3]).toBe(value.before[3]); + expect(value.after[4]).toBe(value.before[4]); + expect(value.afterState.state).toMatchObject({nativeGeneration:1,history:{status:"converged"},historySchedule:{direction:"remove"}}); + return; + } + expect(value.after).toEqual(value.before); + expect(value.provider).toBe("opencodex"); + if (kind === "coordinated") { + expect(value.beforeState).toMatchObject({kind:"ready",state:{nativeGeneration:0,currentTxId:null}}); + } + expect(value.afterState).toEqual(value.beforeState); + expect(value.result.success).toBe(false); + expect(value.result.message).toContain("history_paginated_requires_native_writer"); + for (const artifact of Object.values(value.result.artifacts)) { + expect(artifact).toMatchObject({ state: "skipped", changed: false }); + } + }); + } + test.each([false, true])("paginated history preserves config and profile before provider transition (authless=%s)", (authless) => { const original = 'model_provider = "opencodex"\n[model_providers.opencodex]\nname="OpenCodex"\nbase_url="http://127.0.0.1:10100/v1"\nwire_api="responses"\n'; const configPath = join(codexHome, "config.toml");