diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs index 66ef283b4..092a8ae4c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs @@ -8,5 +8,5 @@ pub(crate) use model::DirectCodexUserItem; pub(crate) use validation::validate_direct_codex_user_item; pub(crate) use wire::{ direct_codex_user_item_to_codex_turn_input, direct_codex_user_item_to_prompt, - direct_codex_user_item_to_response_item, + direct_codex_user_item_to_response_item, freeze_direct_codex_user_item, }; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs index 21f0f7d7f..0fbd38640 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs @@ -33,7 +33,14 @@ pub(crate) enum DirectCodexUserContentPart { #[serde(rename = "input_text")] InputText { text: String }, #[serde(rename = "agc_resource_reference")] - AgcResourceReference { resource_id: String }, + AgcResourceReference { + resource_id: String, + /// 这个引用被宿主解析出来的文本:素材摘要,加上引用的 UI 设计文档展开的代码上下文。 + /// 入队冻结时写入一次,此后随条目持久化,prompt / 历史回读 / turn input 共用这一份。 + /// 缺省合法(旧历史没有它照样解析),缺省时才退回按当前 manifest 现算。 + #[serde(default, skip_serializing_if = "Option::is_none")] + resolved_text: Option, + }, #[serde(rename = "agc_skill_reference")] AgcSkillReference { name: String }, #[serde(rename = "agc_runtime_region_reference")] @@ -89,6 +96,7 @@ mod tests { role: DirectCodexUserRole::User, content: vec![DirectCodexUserContentPart::AgcResourceReference { resource_id: "asset-hero".to_string(), + resolved_text: None, }], id: "turn-1".to_string(), }); @@ -122,6 +130,32 @@ mod tests { assert!(error.to_string().contains("unknown field"), "{error}"); } + /// 旧历史里的资源引用没有 `resolvedText`:缺省必须合法,照样解析成 `None` 的引用。 + #[test] + fn resource_reference_without_resolved_text_still_parses() { + let item: DirectCodexUserItem = serde_json::from_value(json!({ + "type": "message", + "role": "user", + "content": [{ + "type": "agc_resource_reference", + "resourceId": "asset-hero" + }], + "id": "turn-1" + })) + .expect("legacy resource reference must keep parsing"); + let DirectCodexUserItem::Message(message) = item; + match &message.content[0] { + DirectCodexUserContentPart::AgcResourceReference { + resource_id, + resolved_text, + } => { + assert_eq!(resource_id, "asset-hero"); + assert_eq!(resolved_text, &None); + } + other => panic!("expected a resource reference, got {other:?}"), + } + } + #[test] fn skill_reference_serializes_with_only_the_stable_name() { let item: DirectCodexUserItem = serde_json::from_value(json!({ diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs index f63a13da1..a14d93b63 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs @@ -37,7 +37,7 @@ pub(crate) fn validate_direct_codex_user_item( for part in &message.content { match part { DirectCodexUserContentPart::InputText { .. } => {} - DirectCodexUserContentPart::AgcResourceReference { resource_id } => { + DirectCodexUserContentPart::AgcResourceReference { resource_id, .. } => { reference_count = reference_count.saturating_add(1); validate_resource_id_and_manifest(&manifest, resource_id)?; } @@ -202,6 +202,7 @@ mod tests { assert!(content_has_meaningful_input(&[ DirectCodexUserContentPart::AgcResourceReference { resource_id: "asset-hero".to_string(), + resolved_text: None, }, ])); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs index 7fb52396d..ca0837eee 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs @@ -4,8 +4,8 @@ use super::model::{ }; use super::validation::validate_direct_codex_user_item; use crate::agent::{ - read_manifest_for_project, sanitize_attachment_local_path, sanitize_attachment_media_type, - sanitize_attachment_name, GameCreationAppManifest, + sanitize_attachment_local_path, sanitize_attachment_media_type, sanitize_attachment_name, + GameCreationAppManifest, }; use crate::ui_editor::persistence::{ generate_ui_design_code_at, GenerateUiDesignCodeInput, UI_DESIGN_DOC_ASSET_KIND, @@ -14,6 +14,53 @@ use crate::ui_editor::persistence::{ use serde_json::Value; use std::path::Path; +/// 入队检查产出的**冻结条目**:校验 → 把每个引用 part 的解析文本写进它自己的 +/// [`DirectCodexUserContentPart::AgcResourceReference::resolved_text`] → 判空。 +/// +/// 这是这条消息**唯一**会算片段、会写盘(UI 设计文档代码导出)的地方,也是唯一的失败出口: +/// 之后的 prompt 折叠([`direct_codex_user_item_to_prompt`])与放行都不再重算、不再读 manifest, +/// 因此也没有失败可言。冻结结果随条目一路走到历史、事件与 turn input——它们读的都是同一份文本。 +pub(crate) fn freeze_direct_codex_user_item( + root: &Path, + item: &DirectCodexUserItem, +) -> Result { + let manifest = validate_direct_codex_user_item(root, item)?; + let DirectCodexUserItem::Message(message) = item; + let mut content = Vec::with_capacity(message.content.len()); + for part in &message.content { + match part { + DirectCodexUserContentPart::AgcResourceReference { + resource_id, + resolved_text, + } => { + // 客户端不产出这个字段;它只可能来自"已冻结的条目"(旧历史缺省)。缺省才现算。 + let resolved_text = match resolved_text { + Some(text) => Some(text.clone()), + None => Some(resolved_resource_reference_text( + root, + &manifest, + resource_id, + )?), + }; + content.push(DirectCodexUserContentPart::AgcResourceReference { + resource_id: resource_id.clone(), + resolved_text, + }); + } + other => content.push(other.clone()), + } + } + let frozen = DirectCodexUserItem::Message(DirectCodexUserMessageItem { + role: message.role.clone(), + content, + id: message.id.clone(), + }); + if direct_codex_user_item_to_prompt(&frozen).trim().is_empty() { + return Err("DirectProject user item 不能转换为空 prompt".to_string()); + } + Ok(frozen) +} + /// 将历史中的 canonical user item 投影为 Codex `response_item` message。 /// 非 user message 的标准 Response item 原样返回;未知形状直接失败。 pub(crate) fn direct_codex_user_item_to_response_item( @@ -134,9 +181,15 @@ pub(crate) fn direct_codex_user_item_to_wire_input( for part in &message.content { let text = match part { DirectCodexUserContentPart::InputText { text } => text.clone(), - DirectCodexUserContentPart::AgcResourceReference { resource_id } => { - resource_reference_summary(&manifest, resource_id)? - } + DirectCodexUserContentPart::AgcResourceReference { + resource_id, + resolved_text, + } => match resolved_text { + // 冻结过的条目读自己那一份:历史回放因此就是当初发出去的那条消息。 + Some(text) => text.clone(), + // 旧历史没有这份文本,退回按当前 manifest 现算(只有摘要,不产生写副作用)。 + None => resource_reference_summary(&manifest, resource_id)?, + }, DirectCodexUserContentPart::AgcSkillReference { name } => { format!("${}", name.trim()) } @@ -165,10 +218,16 @@ pub(crate) fn direct_codex_user_item_to_codex_turn_input( DirectCodexUserContentPart::InputText { text } => { input.push(serde_json::json!({ "type": "text", "text": text })); } - DirectCodexUserContentPart::AgcResourceReference { resource_id } => { + DirectCodexUserContentPart::AgcResourceReference { + resource_id, + resolved_text, + } => { input.push(serde_json::json!({ "type": "text", - "text": resource_reference_summary(&manifest, resource_id)?, + "text": match resolved_text { + Some(text) => text.clone(), + None => resource_reference_summary(&manifest, resource_id)?, + }, })); } DirectCodexUserContentPart::AgcSkillReference { name } => { @@ -201,82 +260,88 @@ pub(crate) fn direct_codex_user_item_to_codex_turn_input( Ok(Value::Array(input)) } -pub(crate) fn direct_codex_user_item_to_prompt( - root: &Path, - item: &DirectCodexUserItem, -) -> Result { - let wire = direct_codex_user_item_to_wire_input(root, item)?; +/// canonical 条目到 prompt 的**纯折叠**:零 IO、零校验、无失败出口。 +/// +/// 它只读条目自己的事实——文本 part 的正文与引用 part 的 `resolved_text`(入队时 +/// [`freeze_direct_codex_user_item`] 冻结,随条目持久化)。条目里没有引用之外的东西要算, +/// 所以这里也不再需要 root。调用方要保证条目是冻结过的;缺省 `resolved_text` 的旧条目 +/// 走不了这一条(历史回读用 [`direct_codex_user_item_to_response_item`])。 +pub(crate) fn direct_codex_user_item_to_prompt(item: &DirectCodexUserItem) -> String { let DirectCodexUserItem::Message(message) = item; - let mut prompt = wire - .as_array() - .ok_or_else(|| "DirectProject user item wire input 不是数组".to_string())? - .iter() - .filter_map(|part| part.get("text").and_then(Value::as_str)) - .collect::(); - if let Some(code_context) = render_ui_design_code_context(root, message)? { - prompt.push('\n'); - prompt.push_str(&code_context); + let mut prompt = String::new(); + for part in &message.content { + match part { + DirectCodexUserContentPart::InputText { text } => prompt.push_str(text), + DirectCodexUserContentPart::AgcResourceReference { resolved_text, .. } => { + if let Some(text) = resolved_text { + prompt.push_str(text); + } + } + DirectCodexUserContentPart::AgcSkillReference { name } => { + prompt.push_str(&format!("${}", name.trim())); + } + DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => { + prompt.push_str(&runtime_region_summary(reference)); + } + DirectCodexUserContentPart::AgcAttachmentReference(reference) => { + prompt.push_str(&attachment_reference_summary(reference)); + } + } } - if prompt.trim().is_empty() { - return Err("DirectProject user item 不能转换为空 prompt".to_string()); - } - Ok(prompt) + prompt } -/// 本轮 prompt 的 UI 设计文档引用上下文:复用 UI Editor 代码导出,把带文档注释的 -/// JS 片段路径交给模型;生成失败只追加原始错误,不阻断本轮引用,其它引用继续处理。 +/// 一个资源引用 part 的解析文本:素材摘要,加上引用的 UI 设计文档展开的代码上下文。 /// -/// 只在生成本轮 prompt 时展开:历史 item 回读走 `direct_codex_user_item_to_response_item` -/// 的纯投影,不得在这里产生项目写副作用。 -fn render_ui_design_code_context( +/// 只在 [`freeze_direct_codex_user_item`] 里现算一次;算出来的就是这一 part 此后唯一的文本。 +fn resolved_resource_reference_text( root: &Path, - message: &DirectCodexUserMessageItem, -) -> Result, String> { - let referenced_ids = message - .content - .iter() - .filter_map(|part| match part { - DirectCodexUserContentPart::AgcResourceReference { resource_id } => { - Some(resource_id.trim()) - } - _ => None, - }) - .collect::>(); - if referenced_ids.is_empty() { - return Ok(None); + manifest: &GameCreationAppManifest, + resource_id: &str, +) -> Result { + let mut text = resource_reference_summary(manifest, resource_id)?; + if let Some(code_context) = ui_design_code_context_for(root, manifest, resource_id) { + text.push('\n'); + text.push_str(&code_context); } - let manifest = read_manifest_for_project(root)?; - let mut lines = Vec::new(); - for resource_id in referenced_ids { - let is_ui_design_doc = manifest.assets.iter().any(|asset| { - asset.id == resource_id - && asset.kind == UI_DESIGN_DOC_ASSET_KIND - && asset.media_type == UI_DESIGN_DOC_MEDIA_TYPE - }); - if !is_ui_design_doc { - continue; - } - lines.push( - match generate_ui_design_code_at(GenerateUiDesignCodeInput { - project_path: root.to_string_lossy().into_owned(), - expected_project_id: manifest.project_id.clone(), - asset_id: resource_id.to_string(), - }) { - Ok(result) => format!( - prompt_text!("projectContext.uiDesign.codeContext"), - result.relative_path - ), - Err(error) => format!( - prompt_text!("projectContext.uiDesign.generationErrorContext"), - error = error - ), - }, - ); + Ok(text) +} + +/// 引用的 UI 设计文档上下文:复用 UI Editor 代码导出,把带文档注释的 JS 片段路径交给模型; +/// 生成失败只带回原始错误,不阻断本轮引用。非 UI 设计文档返回 `None`。 +/// +/// 这是整条链上唯一会写盘(生成 `ui/generated-*.js`)的投影处,因此只允许在入队冻结时调用。 +/// 历史回读走 `direct_codex_user_item_to_response_item` 的纯投影,不得在这里产生项目写副作用。 +fn ui_design_code_context_for( + root: &Path, + manifest: &GameCreationAppManifest, + resource_id: &str, +) -> Option { + let resource_id = resource_id.trim(); + let is_ui_design_doc = manifest.assets.iter().any(|asset| { + asset.id == resource_id + && asset.kind == UI_DESIGN_DOC_ASSET_KIND + && asset.media_type == UI_DESIGN_DOC_MEDIA_TYPE + }); + if !is_ui_design_doc { + return None; } - if lines.is_empty() { - return Ok(None); - } - Ok(Some(lines.join("\n"))) + Some( + match generate_ui_design_code_at(GenerateUiDesignCodeInput { + project_path: root.to_string_lossy().into_owned(), + expected_project_id: manifest.project_id.clone(), + asset_id: resource_id.to_string(), + }) { + Ok(result) => format!( + prompt_text!("projectContext.uiDesign.codeContext"), + result.relative_path + ), + Err(error) => format!( + prompt_text!("projectContext.uiDesign.generationErrorContext"), + error = error + ), + }, + ) } #[cfg(test)] @@ -284,7 +349,7 @@ mod tests { use super::{ direct_codex_user_item_to_codex_turn_input, direct_codex_user_item_to_prompt, direct_codex_user_item_to_response_item, direct_codex_user_item_to_wire_input, - validate_direct_codex_user_item, + freeze_direct_codex_user_item, validate_direct_codex_user_item, }; use crate::agent::direct_codex_user_item::model::DirectCodexUserItem; use crate::ui_editor::persistence::UI_DESIGN_DOC_MEDIA_TYPE; @@ -375,8 +440,8 @@ mod tests { let item: super::DirectCodexUserItem = serde_json::from_value(user_item_with_resource_reference(&asset_id)) .expect("canonical user item"); - let prompt = - direct_codex_user_item_to_prompt(project.path(), &item).expect("prompt projection"); + let frozen = freeze_direct_codex_user_item(project.path(), &item).expect("freeze"); + let prompt = direct_codex_user_item_to_prompt(&frozen); assert!( prompt.contains(&format!( "[素材引用 resourceId={asset_id};项目路径=ui/design.json]" @@ -387,6 +452,14 @@ mod tests { prompt.contains("请先阅读生成的带有文档的代码片段: ui/generated-"), "{prompt}" ); + // 解析文本随条目持久化:历史回放读的就是这一份,不再重算、不再写盘。 + let super::DirectCodexUserItem::Message(message) = &frozen; + match &message.content[0] { + super::DirectCodexUserContentPart::AgcResourceReference { resolved_text, .. } => { + assert_eq!(resolved_text.as_deref(), Some(prompt.as_str())); + } + other => panic!("expected a frozen resource reference, got {other:?}"), + } } #[test] @@ -418,8 +491,8 @@ mod tests { let item: super::DirectCodexUserItem = serde_json::from_value(user_item_with_resource_reference(&asset_id)) .expect("canonical user item"); - let prompt = - direct_codex_user_item_to_prompt(project.path(), &item).expect("prompt projection"); + let frozen = freeze_direct_codex_user_item(project.path(), &item).expect("freeze"); + let prompt = direct_codex_user_item_to_prompt(&frozen); assert!(prompt.contains("素材引用 resourceId="), "{prompt}"); assert!(prompt.contains("生成代码遇到错误"), "{prompt}"); } @@ -444,8 +517,8 @@ mod tests { ], })) .expect("canonical user item"); - let prompt = - direct_codex_user_item_to_prompt(project.path(), &item).expect("prompt projection"); + let frozen = freeze_direct_codex_user_item(project.path(), &item).expect("freeze"); + let prompt = direct_codex_user_item_to_prompt(&frozen); let ui_doc_at = prompt .find(&failing_ui_doc_id) @@ -477,8 +550,8 @@ mod tests { let item: super::DirectCodexUserItem = serde_json::from_value(user_item_with_resource_reference(&asset_id)) .expect("canonical user item"); - let prompt = - direct_codex_user_item_to_prompt(project.path(), &item).expect("prompt projection"); + let frozen = freeze_direct_codex_user_item(project.path(), &item).expect("freeze"); + let prompt = direct_codex_user_item_to_prompt(&frozen); assert!(prompt.contains("素材引用 resourceId="), "{prompt}"); assert!( !prompt.contains("请先阅读生成的带有文档的代码片段"), @@ -518,6 +591,42 @@ mod tests { assert_ne!(projected["content"][0]["type"], "text"); } + /// 历史回读读条目自己的 `resolvedText`:引用被冻结过就是当初那条消息,没有就退回当前 manifest 的摘要。 + #[test] + fn history_projection_reads_the_persisted_resolved_text() { + let root = prompt_context_project(); + let asset_id = register_fixture_asset( + root.path(), + "assets/hero.png", + GameCreationAppAssetKind::Character, + "image/png", + ); + for resolved_text in [None, Some("冻结时的解析文本".to_string())] { + let mut reference = json!({ + "type": "agc_resource_reference", + "resourceId": asset_id, + }); + if let Some(text) = &resolved_text { + reference["resolvedText"] = json!(text); + } + let item = json!({ + "type": "message", + "role": "user", + "id": "turn-1:user", + "content": [reference], + }); + let projected = direct_codex_user_item_to_response_item(root.path(), &item) + .expect("history projection"); + let text = projected["content"][0]["text"].as_str().expect("text"); + assert_eq!( + text, + resolved_text.unwrap_or_else(|| format!( + "[素材引用 resourceId={asset_id};项目路径=assets/hero.png]" + )) + ); + } + } + #[test] fn text_projection_preserves_empty_parts_line_breaks_and_trailing_whitespace() { let root = tempfile::tempdir().expect("temp project"); @@ -541,7 +650,7 @@ mod tests { assert_eq!(projected["content"], item["content"]); let canonical = serde_json::from_value(item).expect("canonical user item"); assert_eq!( - direct_codex_user_item_to_prompt(root.path(), &canonical).expect("multiline prompt"), + direct_codex_user_item_to_prompt(&canonical), "你好\n第二段\n " ); } @@ -603,11 +712,9 @@ mod tests { "type": "message", "role": "user", "id": "turn-1:user", "content": content }); let canonical = serde_json::from_value(item.clone()).expect("canonical user item"); - assert_eq!( - direct_codex_user_item_to_prompt(root.path(), &canonical) - .expect("reference prompt"), - expected_prompt - ); + let frozen = freeze_direct_codex_user_item(root.path(), &canonical) + .expect("reference freeze"); + assert_eq!(direct_codex_user_item_to_prompt(&frozen), expected_prompt); let projected = direct_codex_user_item_to_response_item(root.path(), &item) .expect("reference history projection"); assert_eq!( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs index b2f2354ce..94dc8e4fc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs @@ -30,8 +30,8 @@ pub(crate) fn normalize_direct_client_turn_id( /// DirectProject 聊天命令:**只入队**,不 await、也不起回合。 /// -/// 入队侧就是"这条消息能不能收下":`clientTurnId` 校验 → 工作流恢复 → 用户条目校验 → prompt -/// 投影 → 前置条件 → 容量预判 → 工程准备,任何一步失败都是**入队失败**(typed 载荷)。全部通过 +/// 入队侧就是"这条消息能不能收下":`clientTurnId` 校验 → 工作流恢复 → 用户条目校验与冻结 +/// (引用解析文本写进条目)→ 前置条件 → 容量预判 → 工程准备,任何一步失败都是**入队失败**(typed 载荷)。全部通过 /// 之后只做一件事——把待发消息排进队列:不登记占用、不落盘、不发回合事件、不起 codex。 /// /// 于是"这一轮跑成什么"仍然只有订阅事件一个来源:命令返回 `Ok` 只说明**入队成立**。真正的回合边界 @@ -65,7 +65,7 @@ pub(crate) async fn enqueue_direct_codex_turn( } /// 入队侧:全程 typed,顺序固定,**每一步失败都还是入队失败**: -/// `clientTurnId` 校验 → 工作流恢复 → 用户条目校验 → prompt 投影 → 前置条件 → 容量预判 → 工程准备 → 入队。 +/// `clientTurnId` 校验 → 工作流恢复 → 用户条目校验与冻结 → 前置条件 → 容量预判 → 工程准备 → 入队。 /// /// 这个顺序不是风格问题:工程准备(可能建工程、铺脚手架)必须在"这条消息真的被收下"之前判定; /// 没通过检查的东西不进队列。容量放在工程准备之前是"别为注定收不下的消息先付分钟级的代价",它 @@ -88,10 +88,10 @@ async fn enqueue_direct_codex_turn_typed( ), } })?; - validate_direct_codex_user_item(root, &user_item) - .map_err(|detail| DirectTurnError::InputRejected { detail })?; - let user_prompt = direct_codex_user_item_to_prompt(root, &user_item) + // 冻结是这条消息唯一的算片段 / 写盘时机:引用解析文本就此写进条目自身,放行只重投影。 + let user_item = freeze_direct_codex_user_item(root, &user_item) .map_err(|detail| DirectTurnError::InputRejected { detail })?; + let user_prompt = direct_codex_user_item_to_prompt(&user_item); check_direct_turn_preconditions(root, &user_prompt, creation_type.as_deref())?; let thread_id = thread_id_for_project(root); // 容量是**最便宜**的一条入队前置条件,先判:满了就直接说满,别让一条注定收不下的消息先去做工程 diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexUserContentPart.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexUserContentPart.ts index e6551d847..d92fdb7fa 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexUserContentPart.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexUserContentPart.ts @@ -4,7 +4,16 @@ import type { DirectCodexUserRuntimeRegionPart } from './DirectCodexUserRuntimeR export type DirectCodexUserContentPart = | { type: 'input_text'; text: string } - | { type: 'agc_resource_reference'; resourceId: string } + | { + type: 'agc_resource_reference'; + resourceId: string; + /** + * 这个引用被宿主解析出来的文本:素材摘要,加上引用的 UI 设计文档展开的代码上下文。 + * 入队冻结时写入一次,此后随条目持久化,prompt / 历史回读 / turn input 共用这一份。 + * 缺省合法(旧历史没有它照样解析),缺省时才退回按当前 manifest 现算。 + */ + resolvedText?: string | null; + } | { type: 'agc_skill_reference'; name: string } | ({ type: 'agc_runtime_region_reference';