修复 DirectProject 回合上下文持久化
Project CI / Repository checks (pull_request) Failing after 13s
Project CI / Backend tests (pull_request) Failing after 9s
Project CI / Frontend tests (pull_request) Successful in 3m19s
Project CI / Native shell tests (pull_request) Has been cancelled

回合开始前幂等持久化规范化 user prompt
附件回合统一由 Rust 写入规范化历史,避免原始消息冲突
补充重启后多轮 user/assistant 历史恢复测试
同步 DirectProject replay 文档合同
This commit is contained in:
2026-09-03 13:05:20 +08:00
parent ff1f77f88a
commit da1759322c
3 changed files with 143 additions and 16 deletions
@@ -4192,6 +4192,27 @@ fn persist_direct_codex_assistant_reply_at(
.map(|_| ())
}
fn persist_direct_codex_user_prompt_at(
root: &Path,
client_turn_id: &str,
prompt: &str,
) -> Result<(), String> {
enforce_project_permission_policy(root, "conversation.write")?;
let _lock = acquire_project_write_lock(root, "conversation.write")?;
append_local_conversation_message_for_session_idempotent_at(
root,
None,
None,
LocalConversationMessage {
role: "user".to_string(),
content: prompt.trim().to_string(),
agent_id: None,
},
&format!("direct-codex:{client_turn_id}:user"),
)
.map(|_| ())
}
#[tauri::command]
pub(crate) async fn chat_with_game_creator_direct_codex(
project_path: String,
@@ -4223,6 +4244,15 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
return Err(error);
}
};
if let Err(error) = persist_direct_codex_user_prompt_at(root, &turn_id, &user_prompt) {
audit.finish(false);
turn_emitter.emit("failed", Some("none"), None);
return Err(redact_agent_runtime_error(
root,
&format!("Direct 用户消息持久化失败,已拒绝发起回合:{error}"),
500,
));
}
let reply = match run_direct_game_creator_turn_at_with_creation_type_and_emitter(
root,
&user_prompt,
@@ -4264,6 +4294,24 @@ pub(crate) async fn chat_with_game_creator_home_direct_codex(
mod tests {
use super::*;
fn direct_test_llm() -> GameCreatorLlmConfig {
GameCreatorLlmConfig {
api_key: "fixture-secret".to_string(),
base_url: "https://example.invalid/v1".to_string(),
model: "fixture-model".to_string(),
api_kind: "openai_responses".to_string(),
reasoning_effort: "high".to_string(),
stream: false,
web_search_enabled: false,
context_window_tokens: 128_000,
auto_compact_token_limit: 64_000,
tool_output_token_limit: 12_000,
request_timeout_ms: 10_000,
max_retries: 0,
retry_backoff_ms: 100,
}
}
#[test]
fn client_turn_id_is_strictly_normalized_and_bounded() {
assert_eq!(
@@ -4329,6 +4377,83 @@ mod tests {
);
}
#[test]
fn direct_user_prompt_is_persisted_idempotently_before_reply_and_survives_retry() {
let root = tempfile::tempdir().expect("temp dir");
init_local_game_project_at(root.path(), "direct-user-project", "用户消息落盘")
.expect("init project");
let turn_id = "client-turn-user-0001";
let normalized_prompt = "请先检查附件,再继续上一轮的游戏设计。\n\n项目附件:\n- 原文件名:需求.md;项目路径:需求.md;类型:text/markdown;大小:12 字节";
persist_direct_codex_user_prompt_at(root.path(), turn_id, normalized_prompt)
.expect("persist user prompt before turn");
persist_direct_codex_user_prompt_at(root.path(), turn_id, normalized_prompt)
.expect("retry reuses the same user message identity");
let conversation = read_local_conversation_for_session_at(root.path(), None, None)
.expect("read project conversation");
let message_id = format!("direct-codex:{turn_id}:user");
let persisted = conversation
.messages
.iter()
.filter(|message| message.message_id.as_deref() == Some(message_id.as_str()))
.collect::<Vec<_>>();
assert_eq!(persisted.len(), 1);
assert_eq!(persisted[0].role, "user");
assert_eq!(persisted[0].content, normalized_prompt);
assert!(
persist_direct_codex_user_prompt_at(root.path(), turn_id, "不同的重试请求")
.expect_err("same turn identity cannot be rebound")
.contains("messageId 冲突")
);
}
#[test]
fn direct_history_recovers_multiple_user_assistant_turns_after_thread_restart() {
let root = tempfile::tempdir().expect("temp dir");
init_local_game_project_at(root.path(), "direct-restart-project", "重启恢复")
.expect("init project");
let request = LlmRunRequest::single_turn("system", "继续实现第二轮需求")
.with_model("fixture-model")
.with_max_output_tokens(16_000);
persist_direct_codex_user_prompt_at(root.path(), "client-turn-0001", "先做一个主菜单")
.expect("persist first user turn");
persist_direct_codex_assistant_reply_at(
root.path(),
"client-turn-0001",
"主菜单已完成,下一步可以继续扩展关卡。",
)
.expect("persist first assistant turn");
persist_direct_codex_user_prompt_at(
root.path(),
"client-turn-0002",
"请在主菜单基础上增加关卡选择",
)
.expect("persist second user turn");
persist_direct_codex_assistant_reply_at(
root.path(),
"client-turn-0002",
"关卡选择页面已加入,并保留主菜单入口。",
)
.expect("persist second assistant turn");
// 模拟 app-server 重启后没有内存 thread:从项目 JSONL 重建时,
// 两轮完整的 user/assistant 上下文都必须保留。
let prompt = crate::agent::codex_app_server::build_direct_codex_history_prompt(
root.path(),
"client-turn-0003",
"继续实现第二轮需求",
&request,
&direct_test_llm(),
)
.expect("rebuild history after restart");
assert_eq!(
prompt,
"user: 先做一个主菜单\nassistant: 主菜单已完成,下一步可以继续扩展关卡。\nuser: 请在主菜单基础上增加关卡选择\nassistant: 关卡选择页面已加入,并保留主菜单入口。\nuser: 继续实现第二轮需求"
);
}
#[test]
fn direct_turn_update_payload_is_the_exact_camel_case_contract() {
let value = serde_json::to_value(GameCreatorDirectTurnUpdateEvent {
+17 -15
View File
@@ -5447,23 +5447,25 @@ export function App({
);
directCodexInterruptedPartialRef.current = null;
}
// Persist the original user intent and stable turn identity before
// Codex can start any billable or externally visible work. The
// regular conversation writer may race this call, but messageId
// idempotency makes both writers converge on the same record.
await directInvoke<LocalConversationResult>(
'append_local_conversation_message',
{
projectPath: directProjectPath,
agentId: null,
messageId: directUserMessageId,
message: {
role: 'user',
content: prompt,
// Rust owns the normalized user record for attachment turns so the
// durable message includes the same bounded project mapping that is
// sent to Codex. Plain turns keep the optimistic browser write; the
// Rust writer then converges on it through messageId idempotency.
if (!attachments?.length) {
await directInvoke<LocalConversationResult>(
'append_local_conversation_message',
{
projectPath: directProjectPath,
agentId: null,
messageId: directUserMessageId,
message: {
role: 'user',
content: prompt,
agentId: null,
},
},
},
);
);
}
const directTurnInput: {
projectPath: string;
prompt: string;
@@ -219,7 +219,7 @@ Supervisor 认领该回执后,由父 run 自己为每个原 delivery 逐一创
- 模式升级:`agentMode` 扩为 `codex_app_server / codex_cli / provider`,新默认为 `codex_app_server`;V1.51 的一次性 `codex exec` 保留为显式兼容模式,HTTP Provider 保留为非 Responses 配置及故障回退模式。
- 进程与节点:External Runner 按“有效 Agent LLM 凭据/Responses 路由 + `projectId/agentId/sessionId/runId`”隔离长期 `codex app-server --stdio`,即每个权威节点 run 直接持有自己的 Codex CLI 子进程与 ephemeral thread,每次完整权威请求映射 turn。同一节点 turn 串行,节点之间进程级隔离;单节点连接失败不得使其它节点同时失去终态。Codex thread 不写 durable recovery;节点完成、重启、retry、handoff 和 finalization 仍只认 AGC 账本。
- DirectProject replay:Codex thread 仍保持 `ephemeral=true`。`.agent/conversations/project.jsonl` 是聊天唯一、append-only 事实源;仅当 app-server 连接没有可用的项目 thread(通常是进程重启或 thread 被淘汰)时,AGC 才读取历史,按原顺序渲染为简单 `user:` / `assistant:` / `tool:` 行,再追加本次新 user request,发送给新建 thread;已有 thread 的普通消息仍只发送新 user。为避免持久增长的历史超过模型上下文,replay builder 使用全局 `contextWindowTokens`、`autoCompactTokenLimit`、本次 `maxOutputTokens` 和 4096 安全余量计算输入预算,从最新记录向前选择连续、完整的消息;超预算的旧前缀只在本次 prompt 中省略,不改写 JSONL、不写 summary/sidecar、不拆分单条记录。发生省略时在 prompt 开头加入普通 `system: Earlier conversation history was omitted due to context budget.` 行;当前 user request 始终保留,若其自身超过硬上下文预算则直接失败。这里的简单 role 前缀和普通 assistant partial(末尾 `unexpected interrupt happened here`)仍是产品合同:保持 prompt 形状稳定、避免 envelope breaking change,并让模型明确知道上次输出在断开处结束。app-server 意外中断时,已收到的 partial 文本按普通 `assistant` 消息追加;断开处理和下一次发送都可尝试写入,依赖普通 `messageId` 幂等。项目打开只读取历史,不因 user-only 记录自动重发;Direct 不提供 retry 入口。Runtime Agent 继续使用独立的 runtime/context 恢复链路,不读取 DirectProject 对话作为原生 thread history。
- DirectProject replay:Codex thread 仍保持 `ephemeral=true`。`.agent/conversations/project.jsonl` 是聊天唯一、append-only 事实源;每个 GUI turn 在发起 app-server turn 前,先把渲染后的规范化 user prompt 以 `direct-codex:{clientTurnId}:user` 幂等追加,持久化失败则不发起 turn 并公开 failed;LLM 失败 / 中断时保留该 user 记录,重试同一 `clientTurnId` 只复用它。仅当 app-server 连接没有可用的项目 thread(通常是进程重启或 thread 被淘汰)时,AGC 才读取历史,按原顺序渲染为简单 `user:` / `assistant:` / `tool:` 行,再追加本次新 user request,发送给新建 thread;已有 thread 的普通消息仍只发送新 user。为避免持久增长的历史超过模型上下文,replay builder 使用全局 `contextWindowTokens`、`autoCompactTokenLimit`、本次 `maxOutputTokens` 和 4096 安全余量计算输入预算,从最新记录向前选择连续、完整的消息;超预算的旧前缀只在本次 prompt 中省略,不改写 JSONL、不写 summary/sidecar、不拆分单条记录。发生省略时在 prompt 开头加入普通 `system: Earlier conversation history was omitted due to context budget.` 行;当前 user request 始终保留,若其自身超过硬上下文预算则直接失败。这里的简单 role 前缀和普通 assistant partial(末尾 `unexpected interrupt happened here`)仍是产品合同:保持 prompt 形状稳定、避免 envelope breaking change,并让模型明确知道上次输出在断开处结束。app-server 意外中断时,已收到的 partial 文本按普通 `assistant` 消息追加;断开处理和下一次发送都可尝试写入,依赖普通 `messageId` 幂等。项目打开只读取历史,不因 user-only 记录自动重发;Direct 不提供 retry 入口。Runtime Agent 继续使用独立的 runtime/context 恢复链路,不读取 DirectProject 对话作为原生 thread history。
- LLM 配置:`apiKind` 始终只接受 `openai_responses`;非空 Key 转换为 app-server model provider,base URL 生效,Key 仅走专用环境变量;空 Key 只桥接用户 Codex `auth.json`,不继承环境 `CODEX_API_KEY`。设置面板在 app-server 模式继续显示并保存 model、effort、stream、全局/逐 Agent Key 与路由配置;`openai_chat / anthropic` 明确提示切 `provider`,不得悄悄忽略。`stream=true` 接入 app-server 文本 delta;`webSearchEnabled=true` 只允许 DirectProject 经客户端审核的 `agc_web_search` 使用,不得启用 Codex 原生 webSearch 或任意网络。
- 安全与取消:临时 cwd、隔离 `CODEX_HOME` 与 OS HOME、read-only、network off、never approval,并在启动前关闭 web/multi-agent/shell/browser/plugin/image 等原生能力;取消从 turn-start pending 阶段就跟踪且只 interrupt 当前 turn。已发送 turn 后连接断开或终态丢失进入 reconciliation,只关闭当前节点进程且不重放同一 request slot;明确 failed/interrupted 不按 transport 重试。
- remote-control 认证边界:没有 ChatGPT `auth.json` 的 API Key / provider-proxy app-server 在启动时设置 Codex 内部环境变量 `CODEX_INTERNAL_APP_SERVER_REMOTE_CONTROL_DISABLED=1`,让 remote-control 以 `desired_state=Disabled` 启动,避免上游进入 1Hz 认证重试;不再依赖需要 ChatGPT 登录态的 `remoteControl/disable` RPC。只有实际桥接 ChatGPT 登录态的 AuthBridge 保持 remote-control 可用。API Key 子进程同时使用 `RUST_LOG=warn` 收敛剩余预期噪音,不伪造 `auth.json` 或静默继续。