修复 DirectProject 回合上下文持久化
回合开始前幂等持久化规范化 user prompt 附件回合统一由 Rust 写入规范化历史,避免原始消息冲突 补充重启后多轮 user/assistant 历史恢复测试 同步 DirectProject replay 文档合同
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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` 或静默继续。
|
||||
|
||||
Reference in New Issue
Block a user