Problem Situation
GPT-5.6 MultiAgentV2 must hide agent_type/model/reasoning_effort to match the server-reserved spawn schema. Users who opt into Codex model_catalog_json to make Sol/Terra use V1 can restore explicit child routing, but LazyCodex reads models_cache.json instead and removes the required V1 settings at install and SessionStart.
Reproduction Logs
With hide_spawn_agent_metadata=false on V2, codex exec fails with HTTP 400: Function collaboration.spawn_agent is reserved for use by this model and must match the configured schema. With a custom V1 catalog, the child correctly ran as explorer on gpt-5.4-mini/low, but current LazyCodex migration deleted enabled=false, hide_spawn_agent_metadata=false, and agents.max_threads because models_cache.json still said v2.
Root Cause
Both the installer resolver and SessionStart migration ignore the top-level model_catalog_json path. Codex documents that catalog as a complete replacement for the process, so LazyCodex and Codex resolve different multi-agent versions.
Verified Fix
Read model_catalog_json first in both resolution paths, falling back to models_cache.json. This preserves the existing GPT-5.6 V2 hotfix for users without an explicit replacement catalog.
diff --git a/packages/omo-codex/plugin/scripts/migrate-codex-config/multi-agent-v2-guard.mjs b/packages/omo-codex/plugin/scripts/migrate-codex-config/multi-agent-v2-guard.mjs
index 67ce8632..6042f87d 100644
--- a/packages/omo-codex/plugin/scripts/migrate-codex-config/multi-agent-v2-guard.mjs
+++ b/packages/omo-codex/plugin/scripts/migrate-codex-config/multi-agent-v2-guard.mjs
@@ -98,7 +98,8 @@ export function prefersMultiAgentV2(multiAgentVersion, sessionModel) {
export function resolveMultiAgentVersionFromConfig(config, options = {}) {
const model = normalizeModel(options.sessionModel) || readRootModel(config);
if (!model) return null;
- return resolveMultiAgentVersionForModel(model, options);
+ const modelsCachePath = options.modelsCachePath?.trim() || readRootModelCatalogPath(config) || undefined;
+ return resolveMultiAgentVersionForModel(model, { ...options, modelsCachePath });
}
/**
@@ -130,6 +131,13 @@ export function readRootModel(config) {
return single?.[1] ?? null;
}
+function readRootModelCatalogPath(config) {
+ const double = config.match(/^\s*model_catalog_json\s*=\s*"([^"]+)"/m);
+ if (double) return double[1];
+ const single = config.match(/^\s*model_catalog_json\s*=\s*'([^']+)'/m);
+ return single?.[1] ?? null;
+}
+
function normalizeModel(value) {
if (typeof value !== "string") return null;
const trimmed = value.trim();
diff --git a/packages/omo-codex/plugin/test/migrate-codex-config.test.mjs b/packages/omo-codex/plugin/test/migrate-codex-config.test.mjs
index dbe5d067..4d75477d 100644
--- a/packages/omo-codex/plugin/test/migrate-codex-config.test.mjs
+++ b/packages/omo-codex/plugin/test/migrate-codex-config.test.mjs
@@ -951,6 +951,51 @@ test("#given gpt-5.6-terra managed disable #when full migration sees models_cach
assert.match(content, /max_concurrent_threads_per_session = 1000/);
});
+test("#given custom catalog selects v1 #when models cache says v2 #then migration preserves v1 routing settings", async (t) => {
+ const root = await mkdtemp(join(tmpdir(), "lazycodex-custom-model-catalog-"));
+ t.after(() => rm(root, { recursive: true, force: true }));
+ const codexHome = join(root, "codex-home");
+ await mkdir(codexHome, { recursive: true });
+ const customCatalogPath = join(root, "models.json");
+ await writeFile(
+ customCatalogPath,
+ JSON.stringify({ models: [{ slug: "gpt-5.6-sol", multi_agent_version: "v1" }] }),
+ );
+ const configPath = join(codexHome, "config.toml");
+ await writeFile(
+ configPath,
+ [
+ 'model = "gpt-5.6-sol"',
+ `model_catalog_json = "${customCatalogPath}"`,
+ "",
+ "[agents]",
+ "max_threads = 1000",
+ "max_depth = 2",
+ "",
+ "[features.multi_agent_v2]",
+ "enabled = false",
+ "hide_spawn_agent_metadata = false",
+ "max_concurrent_threads_per_session = 1000",
+ "",
+ ].join("\n"),
+ );
+ await writeFile(
+ join(codexHome, "models_cache.json"),
+ JSON.stringify({ models: [{ slug: "gpt-5.6-sol", multi_agent_version: "v2" }] }),
+ );
+
+ await migrateCodexConfig({
+ env: { CODEX_HOME: codexHome, LAZYCODEX_MODEL_CATALOG_STATE_PATH: join(root, "model-state.json") },
+ cwd: root,
+ });
+
+ const content = await readFile(configPath, "utf8");
+ assert.match(content, /^enabled = false$/m);
+ assert.match(content, /^hide_spawn_agent_metadata = false$/m);
+ assert.match(content, /^max_threads = 1000$/m);
+ assert.match(content, /max_depth = 2/);
+});
+
test("#given config default gpt-5.5 #when SessionStart model is gpt-5.6-terra #then prefers session model and clears disable", () => {
const config = [
'model = "gpt-5.5"',
diff --git a/packages/omo-codex/src/install/codex-config-toml.test.ts b/packages/omo-codex/src/install/codex-config-toml.test.ts
index e0f0a43d..17b04018 100644
--- a/packages/omo-codex/src/install/codex-config-toml.test.ts
+++ b/packages/omo-codex/src/install/codex-config-toml.test.ts
@@ -487,6 +487,47 @@ describe("codex-config-toml", () => {
expect(content).toContain("max_concurrent_threads_per_session = 1000")
})
+ test("#given custom catalog selects v1 #when models cache says v2 #then installer keeps v1 routing", async () => {
+ // given
+ const root = await mkdtemp(join(tmpdir(), "omo-codex-config-custom-catalog-v1-"))
+ const customCatalogPath = join(root, "models.json")
+ await writeFile(
+ customCatalogPath,
+ JSON.stringify({ models: [{ slug: "gpt-5.6-sol", multi_agent_version: "v1" }] }),
+ )
+ const configPath = join(root, "config.toml")
+ await writeFile(
+ configPath,
+ [
+ 'model = "gpt-5.6-sol"',
+ `model_catalog_json = "${customCatalogPath}"`,
+ "",
+ "[features]",
+ "multi_agent_v2 = false",
+ "",
+ ].join("\n"),
+ )
+ await writeFile(
+ join(root, "models_cache.json"),
+ JSON.stringify({ models: [{ slug: "gpt-5.6-sol", multi_agent_version: "v2" }] }),
+ )
+
+ // when
+ await updateCodexConfig({
+ configPath,
+ repoRoot: "/repo/packages/omo-codex",
+ marketplaceName: "debug",
+ marketplaceSource: { sourceType: "local", source: "/repo/packages/omo-codex" },
+ pluginNames: ["omo"],
+ })
+
+ // then
+ const content = await readFile(configPath, "utf8")
+ expect(content).toContain("max_threads = 1000")
+ expect(content).toMatch(/^enabled = false$/m)
+ expect(content).toContain("max_concurrent_threads_per_session = 1000")
+ })
+
test("#given gpt-5.6 family model without models_cache #when updating config #then treats it as V2-preferred", async () => {
// given
const root = await mkdtemp(join(tmpdir(), "omo-codex-config-multi-agent-v2-nocache-"))
diff --git a/packages/omo-codex/src/install/codex-multi-agent-v2-config.ts b/packages/omo-codex/src/install/codex-multi-agent-v2-config.ts
index 4d4fd32f..6c5ea139 100644
--- a/packages/omo-codex/src/install/codex-multi-agent-v2-config.ts
+++ b/packages/omo-codex/src/install/codex-multi-agent-v2-config.ts
@@ -63,15 +63,16 @@ export function ensureCodexMultiAgentV2Config(
}
/**
- * Resolve the configured root model's multi-agent version from the Codex
- * model catalog cache (`models_cache.json` next to `config.toml`).
+ * Resolve the configured root model's multi-agent version from Codex's
+ * configured replacement catalog, falling back to `models_cache.json`.
* Mirrors `plugin/scripts/migrate-codex-config/multi-agent-v2-guard.mjs`:
* catalog wins; a GPT-5.6 family model with no catalog entry counts as V2.
*/
export function resolveCodexMultiAgentVersion(config: string, configPath: string): CodexMultiAgentVersion {
const model = readRootModel(config)
if (model === null) return null
- const catalogVersion = readCatalogMultiAgentVersion(model, join(dirname(configPath), "models_cache.json"))
+ const catalogPath = readRootString(config, "model_catalog_json") ?? join(dirname(configPath), "models_cache.json")
+ const catalogVersion = readCatalogMultiAgentVersion(model, catalogPath)
if (catalogVersion !== null) return catalogVersion
return /^gpt-5\.6\b/i.test(model) ? "v2" : null
}
@@ -101,9 +102,14 @@ function readCatalogMultiAgentVersion(model: string, cachePath: string): CodexMu
}
function readRootModel(config: string): string | null {
- const double = config.match(/^\s*model\s*=\s*"([^"]+)"/m)
+ return readRootString(config, "model")
+}
+
+function readRootString(config: string, key: string): string | null {
+ const escapedKey = escapeRegExp(key)
+ const double = config.match(new RegExp(`^\\s*${escapedKey}\\s*=\\s*"([^"]+)"`, "m"))
if (double !== null) return double[1] ?? null
- const single = config.match(/^\s*model\s*=\s*'([^']+)'/m)
+ const single = config.match(new RegExp(`^\\s*${escapedKey}\\s*=\\s*'([^']+)'`, "m"))
return single?.[1] ?? null
}
Verification
- RED: both new tests removed the V1 routing settings before the fix.
- GREEN: node migration suite 50/50; Bun installer suite 25/25; omo-codex typecheck passed.
- Manual QA: fresh gpt-5.6-sol parent resolved v1 and spawned agent_type=explorer on gpt-5.4-mini with reasoning_effort=low; parent and child returned the expected completion tokens.
- Isolated Codex QA: local install verification passed; app-server turn completed and sessionStart/userPromptSubmit/stop hooks fired; real ~/.codex/config.toml remained unchanged.
This fix was debugged, implemented, and verified with LazyCodex.
Tag: lazycodex-generated
Problem Situation
GPT-5.6 MultiAgentV2 must hide agent_type/model/reasoning_effort to match the server-reserved spawn schema. Users who opt into Codex model_catalog_json to make Sol/Terra use V1 can restore explicit child routing, but LazyCodex reads models_cache.json instead and removes the required V1 settings at install and SessionStart.
Reproduction Logs
With hide_spawn_agent_metadata=false on V2, codex exec fails with HTTP 400: Function collaboration.spawn_agent is reserved for use by this model and must match the configured schema. With a custom V1 catalog, the child correctly ran as explorer on gpt-5.4-mini/low, but current LazyCodex migration deleted enabled=false, hide_spawn_agent_metadata=false, and agents.max_threads because models_cache.json still said v2.
Root Cause
Both the installer resolver and SessionStart migration ignore the top-level model_catalog_json path. Codex documents that catalog as a complete replacement for the process, so LazyCodex and Codex resolve different multi-agent versions.
Verified Fix
Read model_catalog_json first in both resolution paths, falling back to models_cache.json. This preserves the existing GPT-5.6 V2 hotfix for users without an explicit replacement catalog.
Verification
This fix was debugged, implemented, and verified with LazyCodex.
Tag: lazycodex-generated