Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions docs-site/src/content/docs/guides/sub-agent-surface.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,23 @@ routed one arrives encrypted and fails. The dashboard asks before either, and li
[Why v1 is the default](/guides/subagent-v1-default/).
:::

## Model switches and side-chat compaction

Codex can compact inherited history before the first turn of a side chat or after a model switch.
One trigger compares the previous and destination models' `comp_hash` compatibility markers:
when both are present and differ, Codex compacts even if the history fits the destination's context.

OpenCodex represents unknown compatibility for routed models as `comp_hash: null`, instead of
inventing an `"opencodex"` marker or copying one from a native template. Catalog rebuilds also clear
those old markers on OpenCodex rows retained during a provider discovery outage. Native models and
explicit Codex-forward aliases keep their upstream markers, so genuine native incompatibility
checks still apply. This behavior is independent of the v1/v2 sub-agent surface and the model
configured to perform compaction.

Normal context and token-limit compaction still applies. This change removes the synthetic
hash mismatch; it does not select which visible messages a side chat inherits or restore original
messages from an already compacted context. Provider content support remains a separate constraint.

## External task input

Codex can deliver a task's initial input or follow-up in a result-shaped envelope
Expand Down
11 changes: 6 additions & 5 deletions src/codex/catalog/build-entries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -369,9 +369,8 @@ export function orderForModelPicker(
* Every generated routed row — current full-slug form, the June–July 2026
* provider-name form, and legacy combo aliases — carries the stable
* description prefix `Routed via opencodex → `; foreign rows from Cursor or
* user tooling do not. `owned_by` cannot serve as the signal (upstream
* ownership), and `comp_hash` defaults to "opencodex" for every normalized
* row.
* user tooling do not. `owned_by` describes upstream ownership, and `comp_hash`
* describes history compatibility; neither is an authorship signal.
*/
function isOcxAuthoredRoutedEntry(entry: RawEntry): boolean {
if (isNativeAliasCatalogEntry(entry)) return true;
Expand Down Expand Up @@ -782,11 +781,13 @@ export function mergeCatalogEntriesFromObservedState({
delete entry[SPAWN_PRIORITY_FIELD];
}
const slug = String(entry.slug);
if (!isOcxAuthoredRoutedEntry(entry) || isNativeAliasCatalogEntry(entry)) continue;
if (!isOcxAuthoredRoutedEntry(entry)) continue;
// The builder no longer copies a template's comp_hash onto routed rows (#5796), but a row
// kept from disk may still carry one. Custom rows, Codex-forward aliases included, never
// reach this loop: they are rebuilt from config.
entry.comp_hash = "opencodex";
// Clear the former synthetic "opencodex" marker as well: it is not upstream evidence.
entry.comp_hash = null;
if (isNativeAliasCatalogEntry(entry)) continue;
const featuredRank = featuredRankOf(slug);
entry.priority = featuredRank !== undefined
? featuredRank * priorityStride
Expand Down
7 changes: 3 additions & 4 deletions src/codex/catalog/derive-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,10 +144,9 @@ export function deriveEntry(
delete e.context_window;
delete e.max_context_window;
delete e.auto_compact_token_limit;
// Nor its comp_hash (#5796). Codex compacts a thread whenever the recorded value
// changes, and the template is whichever native row a rebuild found first, so an
// inherited value moves with rebuild order. Left unset, normalization gives every
// routed row the same "opencodex" marker.
// Nor its comp_hash (#5796): template selection is not evidence of history
// compatibility. Normalization represents the unknown value as null, avoiding
// both rebuild-dependent hashes and a synthetic native/routed mismatch.
delete e.comp_hash;
}
if (typeof e.base_instructions === "string") {
Expand Down
5 changes: 4 additions & 1 deletion src/codex/catalog/parsing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -633,7 +633,10 @@ export function ensureStrictCatalogFields(
entry.max_context_window = contextWindow;
}
if (typeof entry.effective_context_window_percent !== "number") entry.effective_context_window_percent = 95;
if (typeof entry.comp_hash !== "string") entry.comp_hash = "opencodex";
// Unknown compatibility is not an incompatibility marker. Codex compares hashes only
// when both turns supply one; a synthetic value forces native/routed switches to compact
// even below the token limit. Keep authoritative strings and represent unknown as null.
if (typeof entry.comp_hash !== "string") entry.comp_hash = null;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// Routed rows must not carry NATIVE eligibility metadata. `deriveEntry` deep-clones a
// native template and deletes a fixed denylist, so these eligibility fields survive onto rows backed
// by unrelated provider credentials — advertising ChatGPT plan eligibility for a model
Expand Down
10 changes: 5 additions & 5 deletions structure/catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,11 @@ provider-wide fallback. Exact model output limits precede the provider default o
explicitly configured canonical `openai/gpt-daybreak-blue-latest` Codex-forward row from the
pinned Sol capability metadata while preserving its selector and Daybreak wire identity;
this never expands the bare/API-key model lists or rewrites the wire model to `gpt-5.6-sol`;
- clones a native template for routed `provider/model` entries without its `comp_hash`, and resets
that value on opencodex rows kept from disk while their provider's discovery is degraded, so these
rows carry the fixed `"opencodex"` marker instead of whichever native row a rebuild found first;
Codex compacts a thread whenever that value changes (#5796). Codex-forward aliases keep their
native value and rows written by other tools keep theirs;
- clones a native template for routed `provider/model` entries without its `comp_hash`, and clears
copied or synthetic hashes on opencodex rows kept during degraded discovery (#5796). Unknown
compatibility is `null`: Codex's hash-change trigger requires two non-null, unequal hashes, so
native/routed switches do not compact merely because of a synthetic marker. Native rows and
Codex-forward aliases keep upstream hashes; foreign rows keep valid hashes. Token limits are unchanged;
- forces strict Codex catalog fields required by the current parser;
- hides `disabledModels` without blocking direct routing (routed provider ids are excluded;
account-qualified native ids hide only that selector row; BARE native slugs hide the bare row
Expand Down
170 changes: 124 additions & 46 deletions tests/codex-integration/catalog-routed-comp-hash.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,67 +3,145 @@ import { buildCatalogEntries } from "../../src/codex/catalog";
import {
buildCatalogEntriesFromObservedState,
CANONICAL_NATIVE_CATALOG_CONTENT_POLICY,
deriveEntry,
mergeCatalogEntriesFromObservedState,
mergeCatalogEntriesForSync,
} from "../../src/codex/catalog/sync";
import { ensureStrictCatalogFields } from "../../src/codex/catalog/parsing";
import type { RawEntry } from "../../src/codex/catalog/parsing";

/**
* A routed row is cloned from whichever native row a rebuild picks as the template, and it
* used to keep that row's `comp_hash` (#5796). Codex compacts a thread when consecutive turns
* record different values, so a rebuild that picked another template compacted every active
* routed thread.
*/
describe("catalog routed comp_hash (#5796)", () => {
const routedHash = (compHash: string | undefined) => buildCatalogEntries(
{
slug: "gpt-5.5",
display_name: "gpt-5.5",
description: "Native GPT model",
priority: 1,
visibility: "list",
base_instructions: "You are Codex, a coding agent based on GPT-5.",
...(compHash === undefined ? {} : { comp_hash: compHash }),
},
[],
[{ provider: "local", id: "qwen3-coder" }],
).find(e => e.slug === "local/qwen3-coder")?.comp_hash;
const template = (compHash: string | null | undefined): RawEntry => ({
slug: "gpt-5.5",
display_name: "gpt-5.5",
description: "Native GPT model",
priority: 1,
visibility: "list",
base_instructions: "You are Codex, a coding agent based on GPT-5.",
...(compHash === undefined ? {} : { comp_hash: compHash }),
});

const routedEntry = (entries: RawEntry[], slug = "local/qwen3-coder") =>
entries.find(entry => entry.slug === slug)!;

const mergeOutage = (catalogModels: readonly RawEntry[], routedEntries: readonly RawEntry[]) =>
mergeCatalogEntriesFromObservedState({
catalogModels,
routedEntries,
baselineCatalogModels: [],
baseline: new Map(),
featured: [],
wsEnabled: false,
template: null,
disabledModels: new Set(),
selectedModelsByProvider: new Map(),
gatheredProviderNames: new Set(["local"]),
degradedProviderNames: new Set(["local"]),
legacyCustomModelSlugs: new Set(),
multiAgentMode: "default",
multiAgentV2Enabled: false,
exactComboSlugs: new Set(),
hasPhysicalComboProvider: false,
includeNativeOpenAi: true,
accountBoundEntries: [],
policy: { ...CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, warningPolicy: "suppress" },
});

test("generic routed rows have unknown hashes for every native template shape", () => {
for (const nativeHash of ["3000", "2911", null, undefined] as const) {
const row = routedEntry(buildCatalogEntries(
template(nativeHash), [], [{ provider: "local", id: "qwen3-coder" }],
));
expect(row.comp_hash).toBeNull();
expect(JSON.parse(JSON.stringify(row)).comp_hash).toBeNull();
}
});

test("routed rows keep one value whichever native row is the template", () => {
expect(routedHash("3000")).toBe("opencodex");
expect(routedHash("2911")).toBe("opencodex");
expect(routedHash(undefined)).toBe("opencodex");
test("derivation strips native hashes while native and canonical Codex-forward rows keep theirs", () => {
const nativeTemplate = template("native-template-hash");
const generic = deriveEntry(nativeTemplate, "local/model", "Routed model", 5, {
provider: "local", id: "model", contextWindow: 256_000,
maxInputTokens: 180_000, autoCompactTokenLimit: 150_000,
});
expect(generic).toMatchObject({
comp_hash: null,
context_window: 256_000,
max_context_window: 256_000,
auto_compact_token_limit: 150_000,
});

expect(ensureStrictCatalogFields({ slug: "gpt-5.5", comp_hash: "native-authoritative" }).comp_hash)
.toBe("native-authoritative");
expect(deriveEntry(template("native-template-hash"), "openai/gpt-6-sol", "Forward", 5, {
provider: "openai", id: "gpt-6-sol", codexForwardNativeCapabilityAlias: true,
}).comp_hash).toBe("3000");
});

test("a row kept from disk during a provider outage drops a copied value", () => {
const merge = (catalogModels: readonly RawEntry[], routedEntries: readonly RawEntry[], degraded: boolean) =>
mergeCatalogEntriesFromObservedState({
catalogModels, routedEntries, baselineCatalogModels: [], baseline: new Map(), featured: [],
wsEnabled: false, template: null, disabledModels: new Set(), selectedModelsByProvider: new Map(),
gatheredProviderNames: new Set(["local"]),
degradedProviderNames: new Set(degraded ? ["local"] : []),
legacyCustomModelSlugs: new Set(), multiAgentMode: "default", multiAgentV2Enabled: false,
exactComboSlugs: new Set(), hasPhysicalComboProvider: false, includeNativeOpenAi: true,
accountBoundEntries: [],
policy: { ...CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, warningPolicy: "suppress" },
});
test("strict normalization makes absent or invalid hashes explicit null and preserves known values", () => {
for (const [value, expected] of [
[undefined, null], [null, null], [42, null], ["", ""], ["upstream-hash", "upstream-hash"],
] as const) {
const row: RawEntry = { slug: "provider/model", ...(value === undefined ? {} : { comp_hash: value }) };
expect(ensureStrictCatalogFields(row, { isRouted: true }).comp_hash).toBe(expected);
expect(Object.hasOwn(row, "comp_hash")).toBe(true);
}
});

test("rebuild and JSON roundtrip keep the normalized routed row stable", () => {
const fresh = buildCatalogEntries(template("native-template-hash"), [], [
{ provider: "local", id: "qwen3-coder" },
]);
const first = mergeCatalogEntriesForSync([], fresh, new Map(), [], false);
const persisted = JSON.parse(JSON.stringify(first)) as RawEntry[];
const second = mergeCatalogEntriesForSync(persisted, fresh, new Map(), [], false);
expect(routedEntry(first)).toEqual(routedEntry(second));
expect(routedEntry(second).comp_hash).toBeNull();
});

test.each(["3000", "opencodex"] as const)(
"outage migration clears the old %s routed hash without mutating input or foreign hashes",
oldHash => {
const fresh = buildCatalogEntriesFromObservedState({
template: null, gptSlugs: [], goModels: [{ provider: "local", id: "qwen3-coder" }],
featured: [], modelPickerOrder: [], wsEnabled: false, multiAgentMode: "default",
exactComboSlugs: new Set(), accountSelectors: [], suppressedBareNativeSlugs: new Set(),
disabledNativeAccountSlugs: new Set(), multiAgentV2Enabled: false,
});
// A catalog written before the fix, where the routed row still carries a template's value,
// plus a row another tool wrote under the same provider. That one is not ours to change.
const saved = merge([], fresh, false).map(entry =>
entry.slug === "local/qwen3-coder" ? { ...entry, comp_hash: "3000" } : entry);
const saved = fresh.map(entry => entry.slug === "local/qwen3-coder"
? { ...entry, comp_hash: oldHash } : entry);
const foreign = {
...saved.find(entry => entry.slug === "local/qwen3-coder")!,
slug: "local/imported",
description: "Imported model",
...routedEntry(saved), slug: "local/imported", description: "Imported model", comp_hash: "foreign-valid-hash",
};
const kept = merge([...saved, foreign], [], true);
const hash = (slug: string) => kept.find(entry => entry.slug === slug)?.comp_hash;
expect(hash("local/qwen3-coder")).toBe("opencodex");
expect(hash("local/imported")).toBe("3000");
const input = structuredClone([...saved, foreign]);
const kept = mergeOutage(input, []);
expect(input).toEqual(structuredClone([...saved, foreign]));
expect(routedEntry(kept).comp_hash).toBeNull();
expect(routedEntry(kept, "local/imported").comp_hash).toBe("foreign-valid-hash");
});

test.each(["3000", "opencodex"] as const)(
"fresh and retained public native aliases normalize old %s hashes to null", oldHash => {
const alias = "gpt-5.6-sol";
const built = buildCatalogEntriesFromObservedState({
template: template("3000"), gptSlugs: [alias],
goModels: [{ provider: "combo", id: "nova-sol", alias, nativeAlias: true, owned_by: "combo" }],
featured: [], modelPickerOrder: [], wsEnabled: false, multiAgentMode: "default",
exactComboSlugs: new Set([alias]), accountSelectors: [], suppressedBareNativeSlugs: new Set(),
disabledNativeAccountSlugs: new Set(), multiAgentV2Enabled: false,
});
const freshAlias = built.find(entry => entry.slug === alias)!;
expect(freshAlias.comp_hash).toBeNull();

const staleAlias = { ...freshAlias, comp_hash: oldHash };
const kept = mergeCatalogEntriesFromObservedState({
catalogModels: [staleAlias], routedEntries: [], baselineCatalogModels: [], baseline: new Map(),
featured: [], wsEnabled: false, template: null, disabledModels: new Set(),
selectedModelsByProvider: new Map(), gatheredProviderNames: new Set(), degradedProviderNames: new Set(),
legacyCustomModelSlugs: new Set(), multiAgentMode: "default", multiAgentV2Enabled: false,
exactComboSlugs: new Set([alias]), hasPhysicalComboProvider: false, includeNativeOpenAi: true,
suppressedBareNativeSlugs: new Set([alias]), accountBoundEntries: [],
policy: { ...CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, warningPolicy: "suppress" },
}).find(entry => entry.slug === alias)!;
expect(kept.comp_hash).toBeNull();
});
});
Loading