fix(codex): split the stack comp_hash by tool protocol

In the merged (stack) catalog every third-party row shared one comp_hash,
"cc-switch", so Codex never compacted when switching between them (#8485).
Third-party GPT rows mirror the official entry and carry
`tool_mode: "code_mode_only"`, where shell calls are recorded as the
custom tool `exec`; DeepSeek, Kimi and other rows are direct and only have
`exec_command`. After switching from a GPT row to a direct row without
compaction, the direct model copied the `exec` calls from history and
every turn failed with `unsupported call: exec`.

Group rows by tool protocol instead: a row with a tool_mode gets
"cc-switch-<tool_mode>", direct rows keep "cc-switch" so existing
sessions see no change. Switching within a group still skips compaction;
switching across groups compacts with the previous model first, which
folds the other protocol's calls into the summary. tool_mode and
supports_search_tool keep the template values, so third-party GPT rows
keep code mode. The cost is that a cross-group switch away from a
provider that is no longer usable fails to compact, as before #8485;
switching within the group or starting a new session avoids it.

Fixes #8676
This commit is contained in:
Jason committed 2026-10-23 11:15:35 +08:00
1 parent 79ba7982aa
commit 91fc2fcbf7
1 file changed
+82 -3
+82 -3
View File
@@ -2541,6 +2541,23 @@ pub(crate) enum CodexStackRoute<'a> {
/// 压缩语义也要保留。
const CODEX_STACK_COMP_HASH: &str = "cc-switch";
/// 让一行加入合并目录里共用历史的那组:`comp_hash` 按工具协议分组,同协议的行共用一个值。
///
/// 镜像官方条目的第三方 GPT 行带着 `tool_mode: "code_mode_only"`,shell 调用在历史里是
/// custom 工具 `exec`;其余第三方行是 direct,只有 `exec_command`。两者若共用一个值,切换时
/// 不压缩,direct 模型会照着历史调用不存在的 `exec`,每轮都报 `unsupported call: exec`
/// (#8676)。所以协议不同就分开:跨组切换先用上一家压缩,把对方协议的调用收进摘要;
/// 组内切换照旧不压缩(#8485)。代价是跨组切换时上一家若不可用,压缩会失败,#8485 在这条
/// 路径上回来;组内换一家或新开会话都能绕开。direct 行沿用 [`CODEX_STACK_COMP_HASH`],
/// 老会话记下的值不变。
fn join_stack_history(obj: &mut serde_json::Map<String, Value>) {
let comp_hash = match obj.get("tool_mode").and_then(Value::as_str) {
Some(mode) => format!("{CODEX_STACK_COMP_HASH}-{mode}"),
None => CODEX_STACK_COMP_HASH.to_string(),
};
obj.insert("comp_hash".to_string(), json!(comp_hash));
}
/// Stack 名单非空时的模型目录:路由那家的行在前,各 Stack 供应商的行按名单顺序在后,
/// `priority` 统一重新编号。
///
@@ -2558,7 +2575,7 @@ pub(crate) fn plan_codex_stack_catalog(
let mut rows = codex_stack_third_party_rows(&row)?;
for entry in &mut rows {
if let Some(obj) = entry.as_object_mut() {
obj.insert("comp_hash".to_string(), json!(CODEX_STACK_COMP_HASH));
join_stack_history(obj);
}
}
rows
@@ -2586,7 +2603,7 @@ pub(crate) fn plan_codex_stack_catalog(
let Some(obj) = entry.as_object_mut() else {
continue;
};
obj.insert("comp_hash".to_string(), json!(CODEX_STACK_COMP_HASH));
join_stack_history(obj);
let Some(model) = obj.get("slug").and_then(Value::as_str).map(str::to_string) else {
continue;
};
@@ -2647,7 +2664,8 @@ pub(crate) fn plan_codex_stack_catalog(
/// `config.toml` 里 `[features] multi_agent_v2 = true` 比目录优先,这时写了也不生效。
const CODEX_CLASSIC_SUBAGENTS: &str = "v1";
/// 一家第三方供应商在合并目录里的行(`comp_hash` 保持模板的值,由调用方统一改写)。
/// 一家第三方供应商在合并目录里的行(`comp_hash` 保持模板的值,由调用方经
/// [`join_stack_history`] 统一改写)。
pub(crate) fn codex_stack_third_party_rows(
row: &CodexCatalogRow<'_>,
) -> Result<Vec<Value>, AppError> {
@@ -5836,6 +5854,67 @@ wire_api = "responses"
}
}
/// #8676:共用 `comp_hash` 的行切换时不压缩,所以按工具协议分组。镜像官方条目的第三方
/// GPT 行(路由那家和 Stack 成员)保留 code mode,自成一组;direct 行仍是原来的值。
/// 除 `comp_hash` 外,GPT 行和直连目录里的同一行一致。
#[test]
fn stack_rows_share_history_only_within_one_tool_protocol() {
let gpt_settings = json!({ "modelCatalog": { "models": [{ "model": "gpt-6-sol" }] } });
let gpt_text = "model = \"gpt-6-sol\"\n";
let ds_settings = json!({ "modelCatalog": { "models": [{ "model": "deepseek-v4-pro" }] } });
let ds_text = "model = \"deepseek-v4-pro\"\n";
let profile = CodexCatalogToolProfile::NativeResponses;
let row = |settings, config_text| CodexCatalogRow {
settings,
config_text,
profile,
};
let (direct, stacked) = with_official_models(official_gpt_rows(), || {
let direct = codex_model_catalog_from_settings(&gpt_settings, gpt_text, profile)
.unwrap()
.unwrap();
let stacked = plan_codex_stack_catalog(
CodexStackRoute::ThirdParty(row(&gpt_settings, gpt_text)),
&[
CodexStackCatalogMember {
key: "relay",
provider_name: "Relay",
row: row(&gpt_settings, gpt_text),
},
CodexStackCatalogMember {
key: "ds",
provider_name: "DS",
row: row(&ds_settings, ds_text),
},
],
false,
)
.unwrap();
(direct, stacked)
});
let models = stacked["models"].as_array().unwrap();
let slugs: Vec<&str> = models.iter().map(|m| m["slug"].as_str().unwrap()).collect();
assert_eq!(
slugs,
vec!["gpt-6-sol", "ccs-relay/gpt-6-sol", "ccs-ds/deepseek-v4-pro"]
);
let mirrored = &direct["models"][0];
assert_eq!(mirrored["tool_mode"], "code_mode_only");
for gpt in &models[..2] {
assert_eq!(
gpt["comp_hash"], "cc-switch-code_mode_only",
"{}",
gpt["slug"]
);
for key in ["tool_mode", "supports_search_tool", "model_messages"] {
assert_eq!(gpt[key], mirrored[key], "{} {key}", gpt["slug"]);
}
}
assert!(models[2].get("tool_mode").is_none());
assert_eq!(models[2]["comp_hash"], "cc-switch");
}
#[test]
fn stacked_rows_keep_their_own_tool_profile_and_never_use_responses_lite() {
let route_settings = json!({ "modelCatalog": { "models": [{ "model": "route-model" }] } });