DirectProject 不再把 Codex 回显的用户消息下发给聊天

- 运行态条目投影新增回显过滤 direct_thread_visible_item,item/started 与 rawResponseItem/completed 两条路径共用
- is_direct_project_codex_user_item 提升为 pub(crate),落盘侧与运行态侧共用同一判据
- 新增 E2E direct_project_turn_does_not_forward_codex_user_echo_as_chat_items:修复前事件流会多出两条同文本用户条目(已做变异验证)
- 同步 ADR 与 shared-memory decision-log:回显过滤必须同时覆盖落盘与运行态两侧
This commit is contained in:
2026-09-17 14:40:36 +08:00
parent 0caf998227
commit 63c495dab0
4 changed files with 178 additions and 3 deletions
@@ -765,6 +765,25 @@ fn direct_thread_event_item(
direct_thread_item_from_value(root, item, direct_tool_call_now_ms())
}
/// 运行态条目投影:Codex 回显的用户消息整条跳过。
///
/// AGC 是 DirectProject 用户消息的唯一来源——回合入口在 `turn/start` 之前就把
/// `direct-codex:{clientTurnId}:user` 落盘,并下发同一条运行态条目。app-server 之后
/// 回显的 `userMessage``item/started`)和原始 `role=user` item
/// `rawResponseItem/completed`)只用于观察和关联:落盘侧已由
/// `append_direct_project_history_item_at` 过滤,运行态必须用同一口径过滤,否则前端会多
/// 渲染出两条没有历史对应的孤儿用户气泡,各自开出一个耗时 0 秒的假回合,直到重进页面才
/// 恢复(那时读的是同一份已过滤的 `project.jsonl`)。
fn direct_thread_visible_item(
root: &std::path::Path,
item: &serde_json::Value,
) -> Option<DirectThreadItem> {
if is_direct_project_codex_user_item(item) {
return None;
}
direct_thread_event_item(root, item)
}
fn direct_codex_command_is_game_verification(command: &str) -> bool {
let command = command.to_ascii_lowercase();
command.contains("game.static_smoke")
@@ -3125,7 +3144,7 @@ impl CodexAppServerConnection {
"rawResponseItem/completed 缺少 item".to_string(),
));
}
let entry_item = direct_thread_event_item(history_root, &item);
let entry_item = direct_thread_visible_item(history_root, &item);
let history_root = history_root.to_path_buf();
let history_item = item.clone();
tokio::task::spawn_blocking(move || {
@@ -3269,7 +3288,7 @@ impl CodexAppServerConnection {
== CodexAppServerWorkspaceMode::DirectProject
{
if let Some(entry_item) =
direct_thread_event_item(history_root, item)
direct_thread_visible_item(history_root, item)
{
append_direct_thread_event(
&direct_thread_id,
@@ -6503,6 +6522,155 @@ while IFS= read -r line; do :; done
);
}
#[cfg(unix)]
#[tokio::test]
async fn direct_project_turn_does_not_forward_codex_user_echo_as_chat_items() {
use std::os::unix::fs::PermissionsExt;
let temp = tempfile::tempdir().expect("temp dir");
let project = temp.path().join("direct-user-echo-project");
crate::init_local_game_project_at(&project, "direct-user-echo", "回显过滤")
.expect("init project");
let executable = temp.path().join("fake-codex-app-server-direct-user-echo");
std::fs::write(
&executable,
r#"#!/bin/sh
while IFS= read -r line; do
id=$(printf '%s' "$line" | sed -n 's/.*"id":\([0-9][0-9]*\).*/\1/p')
case "$line" in
*'"method":"initialize"'*) printf '{"id":%s,"result":{"codexHome":"/tmp","platformFamily":"unix","platformOs":"linux","userAgent":"fixture"}}\n' "$id" ;;
*'"method":"skills/extraRoots/set"'*) printf '{"id":%s,"result":{}}\n' "$id" ;;
*'"method":"skills/list"'*) printf '{"id":%s,"result":{"data":[{"skills":[{"name":"agc-browser-playtest"},{"name":"agc-client-projection"},{"name":"agc-game-production-workflow"},{"name":"agc-project-structure"},{"name":"agc-web-game-development"},{"name":"taonier-art-assets"}],"errors":[]}]}}\n' "$id" ;;
*'"method":"thread/start"'*) printf '{"id":%s,"result":{"thread":{"id":"thread-echo"}}}\n' "$id" ;;
*'"method":"thread/inject_items"'*) printf '{"id":%s,"result":{}}\n' "$id" ;;
*'"method":"turn/start"'*)
printf '{"id":%s,"result":{"turn":{"id":"turn-echo","items":[],"status":"inProgress"}}}\n' "$id"
printf '%s\n' '{"method":"turn/started","params":{"threadId":"thread-echo","turn":{"id":"turn-echo","items":[],"status":"inProgress"}}}'
printf '%s\n' '{"method":"item/started","params":{"threadId":"thread-echo","turnId":"turn-echo","item":{"id":"codex-user-echo-1","type":"userMessage","clientId":"turn-0001","content":[{"type":"text","text":"请创建菜单"}]}}}'
printf '%s\n' '{"method":"rawResponseItem/completed","params":{"threadId":"thread-echo","turnId":"turn-echo","item":{"id":"codex-raw-user-echo-1","type":"message","role":"user","content":[{"type":"input_text","text":"请创建菜单"}]}}}'
printf '%s\n' '{"method":"item/agentMessage/delta","params":{"threadId":"thread-echo","turnId":"turn-echo","itemId":"item-echo-1","delta":"好的"}}'
printf '%s\n' '{"method":"item/completed","params":{"threadId":"thread-echo","turnId":"turn-echo","item":{"id":"item-echo-1","type":"agentMessage","text":"好的"}}}'
printf '%s\n' '{"method":"rawResponseItem/completed","params":{"threadId":"thread-echo","turnId":"turn-echo","item":{"id":"item-echo-1","type":"message","role":"assistant","content":[{"type":"output_text","text":"好的"}]}}}'
printf '%s\n' '{"method":"turn/completed","params":{"threadId":"thread-echo","turn":{"id":"turn-echo","items":[],"status":"completed"}}}'
;;
esac
done
"#,
)
.expect("write fake app-server");
let mut permissions = std::fs::metadata(&executable)
.expect("fake metadata")
.permissions();
permissions.set_mode(0o700);
std::fs::set_permissions(&executable, permissions).expect("chmod fake app-server");
let llm = test_llm();
let credential = CodexAppServerCredential::AppDataKey {
fingerprint: "fixture-credential".to_string(),
};
let connection =
CodexAppServerConnection::spawn_with_executable_and_credential_at_workspace(
&llm,
&credential,
executable.as_os_str(),
Some(&project),
CodexAppServerWorkspaceMode::DirectProject,
)
.await
.expect("spawn direct-project app-server");
let user_item = serde_json::json!({
"type": "message",
"role": "user",
"id": "direct-codex:turn-0001:user",
"content": [{"type": "input_text", "text": "请创建菜单"}]
});
let thread_id = direct_thread_id_for_project(&project);
let bootstrap = crate::agent::subscribe_direct_thread(&thread_id);
assert!(
bootstrap.events.is_empty(),
"订阅发生在回合之前,bootstrap 必须为空"
);
// 生产入口(`chat_with_game_creator_direct_codex`)在发起回合前登记本客户端的
// 付费生成身份;工具桥在这一轮里按它绑定付费调用,这里补上同一步。
let _active_invocation =
crate::agent::DirectTaonierActiveInvocationGuard::enter(&project, "turn-0001")
.expect("enter direct invocation");
let mut observer = |_observation| {};
connection
.run_turn_with_direct_observer_and_history(
&test_snapshot(),
&llm,
LlmRunRequest::single_turn("系统", "请创建菜单"),
Some(&project),
Some("turn-0001"),
Some(&user_item),
None,
Some(&mut observer),
None,
)
.await
.expect("run direct-project turn");
drop(observer);
let consumed = crate::agent::consume_direct_thread(&bootstrap.subscription_id)
.expect("consume events");
let mut user_items = Vec::new();
let mut assistant_items = Vec::new();
for event in &consumed.events {
let item = match event {
DirectThreadEvent::ItemStarted { item }
| DirectThreadEvent::ItemCompleted { item } => item,
_ => continue,
};
match item {
DirectThreadItem::Message {
item_id,
role,
text,
..
} if role.as_str() == "user" => {
user_items.push((item_id.clone(), text.clone()));
}
DirectThreadItem::Message { text, .. } if !text.trim().is_empty() => {
assistant_items.push(text.clone());
}
_ => {}
}
}
assert_eq!(
user_items,
vec![(
"direct-codex:turn-0001:user".to_string(),
"请创建菜单".to_string()
)],
"Codex 回显的用户消息不得再作为聊天条目下发,否则前端会渲染出多余的孤儿用户气泡"
);
assert_eq!(
assistant_items,
vec!["好的".to_string()],
"assistant 正文仍必须按同一 itemId 下发一次"
);
let history = read_direct_project_history_items_at(&project).expect("read history");
let history_user_items = history
.iter()
.filter(|item| item.get("role").and_then(serde_json::Value::as_str) == Some("user"))
.cloned()
.collect::<Vec<_>>();
assert!(
history
.iter()
.any(|item| item.get("id").and_then(serde_json::Value::as_str)
== Some("item-echo-1")),
"assistant 与工具条目仍必须落盘:{history:?}"
);
assert_eq!(
history_user_items,
vec![user_item],
"回显的用户消息也不得落盘,历史里只能有 AGC 自己那条"
);
}
#[cfg(unix)]
#[tokio::test]
async fn direct_home_app_server_uses_read_only_protocol_and_rejects_file_change_items() {
@@ -492,7 +492,12 @@ fn append_direct_project_history_item_once(root: &Path, item: &Value) -> Result<
append_jsonl_line_unlocked(&path, &line, "DirectProject 历史")
}
fn is_direct_project_codex_user_item(item: &Value) -> bool {
/// Codex app-server 回显的用户条目(`userMessage` / 非 AGC 的 `role=user` message)。
///
/// AGC 自己预写的用户条目 id 固定是 `direct-codex:{clientTurnId}:user`,因此这里必须
/// 把两者区分开:落盘侧(本模块)与运行态事件侧(`codex_app_server`)共用同一口径,
/// 任何一侧漏判都会让同一条用户消息出现第二个身份。
pub(crate) fn is_direct_project_codex_user_item(item: &Value) -> bool {
if item.get("type").and_then(Value::as_str) == Some("userMessage") {
return true;
}
@@ -17,6 +17,7 @@ AGC 项目开发聊天框当前同时从三处取数据:Direct 回合事件(
- 可见性判断留在前端聊天投影:后端历史分页只按原始条目切片,前端自己跳过不可显示条目并推进锚点。
- 线上模型是 **ts-rs 导出的 tagged enum**`agent/direct_thread_wire.rs`),不是"一个大结构体加一堆可空字段":`DirectThreadItem``itemType` 区分条目,`DirectThreadEvent``type` 区分事件,前端直接消费生成的 TS 类型(改完 Rust 模型跑 `cargo test export_bindings`)。条目上的毫秒时间戳标 `#[ts(as = "f64")]`,因为 ts-rs 默认把 `u64` 映射成 `bigint`,而 Tauri 的 JSON 通道传的是 `number`
- 运行态事件与历史切片使用同形条目,Rust 在两侧套同一套安全过滤(脱敏、截断、路径归一),前端只有一个「原始条目 → 视图」投影函数。
- 两侧的过滤口径必须完全一致,包含「哪些条目根本不是本项目的聊天条目」:Codex app-server 回显的用户消息(`userMessage` / 非 AGC 的 `role=user`)在落盘侧被过滤,在运行态事件侧也必须被过滤(`direct_thread_visible_item`)。少一侧就会出现「实时比历史多出两条同文本用户条目、各自开出一个耗时 0 秒的假回合,重进页面又正常」这类只有其中一侧的事实源缺陷。
- 搬运层不生成展示形状:Thread Manager 只下发脱敏原始条目(`itemType` 原样透传),工具卡片的 `kind`、标题、折叠摘要都由前端生成。
- 条目身份只有一套:进队列前归一成一个 `itemId`。工具条目在 `project.jsonl` 里带两个 id(调用 id 与 response item id,调用与输出共用前者),归一只在 Rust 边界做一次,Thread Manager 与前端都不暴露第二个 id 概念。
- 事件不带回合身份:DirectProject 同一时刻只有一个回合在跑,`turn.started` 无载荷、`turn.completed` 只带 `status`;前端 state 里只有一个 `turnRunning` 布尔,没有 `turnId``subscribe` 返回的条目、增量、请求与队列锚点都不带 turn id。
@@ -8251,6 +8251,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
- `.agent/conversations/project.jsonl` 中的 DirectProject 用户消息由 AGC 在 `turn/start` 前以 `direct-codex:{clientTurnId}:user` 幂等追加;写入失败时禁止发起 Codex turn,失败或中断也保留该 user item。
- Codex app-server 回显的 `userMessage` / `role=user` item 不是第二个历史来源。AGC 只处理其观察和关联,不再把该 echo 追加到项目历史;Codex 的 assistant、tool 和其它有效 response item 仍按现有 append-only 规则落盘。
- 2026-09-17 追加:回显过滤必须同时覆盖**运行态事件侧**(`direct_thread_visible_item``item/started``rawResponseItem/completed` 两条路径),不能只过滤落盘。只过滤落盘时实时聊天会多出两条没有历史对应的孤儿用户气泡,各自开出一个「耗时 0 秒」的假回合,重进页面读同一份已过滤的 `project.jsonl` 又恢复正常;判据是 `direct_project_turn_does_not_forward_codex_user_echo_as_chat_items`(回合事件里只能有一条 `direct-codex:{clientTurnId}:user` 用户条目)。
- 本地 AGC user-item 写入必须使用允许 user item 的内部入口,Codex raw item 写入使用过滤入口,避免“过滤回显”反过来阻断预写。相同 `clientTurnId` 只能复用相同规范化 prompt,内容冲突必须失败关闭。
## 2026-08-31 AGC 错误报告与诊断上传