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:
@@ -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 错误报告与诊断上传
|
||||
|
||||
Reference in New Issue
Block a user