DirectProject 引用解析文本随条目持久化,prompt 投影拆成 freeze 与纯折叠

- AgcResourceReference 增加 resolved_text:入队冻结时写入素材摘要 + UI 设计文档代码上下文,此后随条目持久化;缺省合法(旧历史照样解析),序列化时不写 None
- wire.rs 新增 freeze_direct_codex_user_item(校验 + 算片段 + 写盘 + 冻结进 part + 判空),direct_codex_user_item_to_prompt 改为只读条目自身事实的纯折叠(零 IO、零校验、无失败出口)
- wire 投影与 turn input 优先读 resolved_text,缺省才退回按当前 manifest 现算摘要;历史回读依旧不产生写副作用
- user_input.rs 入队侧改用 freeze,prompt 由冻结条目折叠得出
- 生成绑定 DirectCodexUserContentPart 增加可选 resolvedText
This commit is contained in:
2026-09-30 16:07:46 +08:00
parent 2b98b022c2
commit 15a495dc29
6 changed files with 251 additions and 100 deletions
@@ -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,
};
@@ -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<String>,
},
#[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!({
@@ -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,
},
]));
}
@@ -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<DirectCodexUserItem, String> {
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<String, String> {
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::<String>();
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<Option<String>, String> {
let referenced_ids = message
.content
.iter()
.filter_map(|part| match part {
DirectCodexUserContentPart::AgcResourceReference { resource_id } => {
Some(resource_id.trim())
}
_ => None,
})
.collect::<Vec<_>>();
if referenced_ids.is_empty() {
return Ok(None);
manifest: &GameCreationAppManifest,
resource_id: &str,
) -> Result<String, String> {
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<String> {
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!(
@@ -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);
// 容量是**最便宜**的一条入队前置条件,先判:满了就直接说满,别让一条注定收不下的消息先去做工程
@@ -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';