补齐 Response item 严格合同测试
拒绝资源引用额外身份字段与未知 content part 补充历史 item wire 投影边界测试 同步 DirectProject 历史文档为无 fallback 合同
This commit is contained in:
@@ -3,7 +3,7 @@ use ts_rs::TS;
|
||||
|
||||
/// DirectProject 本轮 user input 的唯一结构化入口。
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
|
||||
#[serde(tag = "type")]
|
||||
#[serde(tag = "type", deny_unknown_fields)]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))]
|
||||
pub(crate) enum DirectCodexUserItem {
|
||||
#[serde(rename = "message")]
|
||||
@@ -11,7 +11,7 @@ pub(crate) enum DirectCodexUserItem {
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))]
|
||||
pub(crate) struct DirectCodexUserMessageItem {
|
||||
pub(crate) role: DirectCodexUserRole,
|
||||
@@ -27,7 +27,7 @@ pub(crate) enum DirectCodexUserRole {
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
|
||||
#[serde(tag = "type")]
|
||||
#[serde(tag = "type", rename_all_fields = "camelCase", deny_unknown_fields)]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))]
|
||||
pub(crate) enum DirectCodexUserContentPart {
|
||||
#[serde(rename = "input_text")]
|
||||
@@ -39,7 +39,7 @@ pub(crate) enum DirectCodexUserContentPart {
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))]
|
||||
pub(crate) struct DirectCodexUserRuntimeRegionPart {
|
||||
pub(crate) label: String,
|
||||
@@ -62,8 +62,64 @@ pub(crate) struct DirectCodexUserRuntimeRegionPart {
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))]
|
||||
pub(crate) struct DirectCodexUserMessageEnvelope {
|
||||
pub(crate) item: DirectCodexUserItem,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn resource_reference_serializes_with_only_camel_case_resource_id() {
|
||||
let item = DirectCodexUserItem::Message(DirectCodexUserMessageItem {
|
||||
role: DirectCodexUserRole::User,
|
||||
content: vec![DirectCodexUserContentPart::AgcResourceReference {
|
||||
resource_id: "asset-hero".to_string(),
|
||||
}],
|
||||
id: "turn-1".to_string(),
|
||||
});
|
||||
assert_eq!(
|
||||
serde_json::to_value(item).expect("serialize user item"),
|
||||
json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "agc_resource_reference",
|
||||
"resourceId": "asset-hero"
|
||||
}],
|
||||
"id": "turn-1"
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resource_reference_rejects_extra_identity_fields() {
|
||||
let error = serde_json::from_value::<DirectCodexUserItem>(json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "agc_resource_reference",
|
||||
"resourceId": "asset-hero",
|
||||
"label": "主角"
|
||||
}],
|
||||
"id": "turn-1"
|
||||
}))
|
||||
.expect_err("label must not be accepted on resource reference");
|
||||
assert!(error.to_string().contains("unknown field"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_content_part_fails_closed() {
|
||||
serde_json::from_value::<DirectCodexUserItem>(json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{"type": "future_part", "value": "x"}],
|
||||
"id": "turn-1"
|
||||
}))
|
||||
.expect_err("unknown content part must fail closed");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,3 +110,34 @@ pub(crate) fn direct_codex_user_item_to_prompt(
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::direct_codex_user_item_to_response_item;
|
||||
use serde_json::json;
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
fn standard_response_item_passes_through_without_agc_private_parts() {
|
||||
let item = json!({
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "output_text", "text": "完成"}]
|
||||
});
|
||||
assert_eq!(
|
||||
direct_codex_user_item_to_response_item(Path::new("/unused"), &item)
|
||||
.expect("assistant response item should pass through"),
|
||||
item
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_item_without_type_fails_closed() {
|
||||
let error = direct_codex_user_item_to_response_item(
|
||||
Path::new("/unused"),
|
||||
&json!({"role": "assistant"}),
|
||||
)
|
||||
.expect_err("history item without type must fail");
|
||||
assert!(error.contains("缺少 type"), "{error}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,16 +16,16 @@ DirectProject 只使用 `.agent/conversations/project.jsonl` 作为对话历史
|
||||
{"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"你好"}]}}
|
||||
```
|
||||
|
||||
`payload` 必须是未经改写的 Responses item。Direct 回合不由浏览器预写用户 message;Codex 返回的 `rawResponseItem/completed.params.item` 原样追加。显式的本地 user/assistant 补写只能通过受权限保护的 `append_direct_project_conversation_message` 命令完成。native 工具、MCP 工具、reasoning、调用参数和调用结果都保留完整内容,不截断、不摘要、不保存 delta/started 事件。
|
||||
`payload` 必须是未经改写的 Responses item。AGC 前端 user input 先以 canonical user message item 形式写入;发送给 app-server 前由 Rust 投影为 Codex 可接受的 `message` item,AGC 私有 content part 不会穿透到 wire。Codex 返回的 `rawResponseItem/completed.params.item` 原样追加。native 工具、MCP 工具、reasoning、调用参数和调用结果都保留完整内容,不截断、不摘要、不保存 delta/started 事件。
|
||||
|
||||
DirectProject 自己的写侧只写新格式:格式切换(#282)时仍会写旧行的路径已收口——显式 Codex 返回只落在自己的 journal `.agent/conversations/codex-responses.jsonl`,不再投影进 `project.jsonl`。
|
||||
|
||||
读侧白名单兼容旧 `{role,content}` 行:格式切换前,DirectProject 主对话由通用对话写入器落到同一份 `project.jsonl`,存量用户项目的历史文件整份都是这种行。读取时把**明确枚举的那一种**旧行形状(`schemaVersion=game-creator-conversation.v1`、无 `type`、role 在 legacy 写入器自己的角色集合 `user`/`assistant`/`tool` 内、content 为非空字符串)认下来:`user`/`assistant` 投影成与 `direct_project_local_message_item` 同形状的 message item,`role` 与 `content` 逐字节保留;`tool` 行已识别但不进 Codex 上下文(它不是 Responses item,无法还原成真正的工具 item,聊天投影本来也只展示 user/assistant),与 developer/system item 同样过滤。角色集合取的是 `project/conversation.rs` 里那条 `matches!(role, "user" | "assistant" | "tool")` 校验,所以「legacy 写入器能写出的行」被完整覆盖;白名单之外的角色、换了 `schemaVersion`、带 `type`、content 非字符串或为空、缺 `payload`、坏 JSON 仍按损坏失败关闭。这条兼容是只读的,不迁移、不改写历史文件。
|
||||
读取只接受 `response_item` envelope;旧 `game-creator-conversation.v1` 行不提供迁移或 fallback,直接失败关闭。未知或无法投影的 canonical item 在发送前失败,不能产生本轮新增历史。
|
||||
|
||||
## 正常回合
|
||||
|
||||
1. 启动 `ephemeral: true` 线程,并启用 `experimentalRawEvents: true`。
|
||||
2. 新线程先把历史 item 数组一次注入;注入成功后执行新的 `turn/start`。本轮用户 item 只接受 Codex 回传的 `rawResponseItem/completed`,不由 AGC 预写。
|
||||
2. 新线程先把历史 item 数组逐项投影为 Codex 可接受 item 后一次注入;注入成功后执行新的 `turn/start`。本轮 canonical user item 在发送前完成同样的投影校验,再写入项目历史。
|
||||
3. 收到 `rawResponseItem/completed` 后立即追加其 `params.item` 并 flush。
|
||||
4. 正常 `turn/completed: completed` 不生成额外记录。
|
||||
|
||||
@@ -55,12 +55,10 @@ Codex 启动时注入的 `host_skills.instructions`、`permissions.instructions`
|
||||
|
||||
DirectProject 的浏览器层只负责显示和乐观状态,不再调用通用对话写入器。历史读写与回合累计分别位于 `agent/direct_project_history.rs` 和 `agent/direct_project_turn_history.rs`。
|
||||
|
||||
`project.jsonl` 不是 DirectProject 独享的写入方:通用对话链(`project/conversation.rs`)把「项目主对话」(`agent_id=None`)映射到同一份文件,非 DirectProject 模式(`codex_cli`/`provider`)的项目对话、以及 Agent Runtime 的项目级公开状态消息(`agent/runtime_state.rs` 的 public status 写入点)都由它追加 `game-creator-conversation.v1` 行。两条链的行形状不同但**互读兼容**:DirectProject 侧投影旧行(见上),通用对话侧跳过 `type=response_item` 且带 `payload` 的行(不把它二次投影成自己的记录,DirectProject 侧已经拥有那份投影),其余坏行两侧都失败关闭。因此同一份文件里出现两种行不会让任何一侧失败。
|
||||
|
||||
这里**故意不给通用对话写入器加「文件已属于 DirectProject 就拒绝追加」的硬报错**:这些写入点不是尽力而为的旁路——`agent/runtime_driver/task_start.rs` 在 `ensure_game_creator_agent_runtime_accepted_public_status_at` 返回 `Err` 时会直接中止本次后台任务(「后台任务启动确认落盘失败,任务未执行」),`agent/runtime_protocol/steering.rs` 的三处调用也用 `?` 上抛。加硬报错会把「旧行噪声」换成「任务起不来」,比它要解决的问题更糟;而毒化本身已经不可能发生——legacy 写入器的行形状与角色集合都被 `conversation.rs` 的校验穷举,全部落在 DirectProject 的读侧白名单内。
|
||||
`project.jsonl` 的 DirectProject 现行合同只允许 `response_item` envelope。其它模式产生的旧 conversation 行不属于本合同,不得注入 DirectProject。
|
||||
|
||||
## 写入与损坏边界
|
||||
|
||||
写入使用 `write_all + flush`。读取时允许丢弃文件末尾一条不完整 JSON 行;白名单化的旧行投影成 message item;其余中间坏行直接失败。兼容只发生在读取侧,不对旧格式做数据迁移或改写。
|
||||
写入使用 `write_all + flush`。读取时允许丢弃文件末尾一条不完整 JSON 行;非 `response_item` 行和无法投影的 item 直接失败,不做数据迁移或 fallback。
|
||||
|
||||
该失败有专门恢复提示,并按不可重试处理:同一份历史文件每次读都会得到同一结论,重试不会改变结果,因此不会向用户显示「可直接重试」。
|
||||
|
||||
Reference in New Issue
Block a user