feat/AGC codex的工具调用 持久化处理 #282
@@ -107,11 +107,13 @@ const rustSharedContractSource = fs.readFileSync(
|
||||
'utf8',
|
||||
);
|
||||
const allowedUncalledTauriCommands = [
|
||||
'append_direct_project_conversation_message',
|
||||
'chat_with_game_creator_agent',
|
||||
'check_ui_editor_font_glyph_coverage',
|
||||
'create_ui_design_resource',
|
||||
'open_game_creator_launcher_window',
|
||||
'open_game_creator_workspace_window',
|
||||
'read_direct_project_conversation',
|
||||
'stop_local_game_preview_if_matches',
|
||||
'start_game_creator_external_mcp',
|
||||
'stop_game_creator_external_mcp',
|
||||
|
||||
@@ -14,6 +14,8 @@ mod codex_cli;
|
||||
mod codex_provider_proxy;
|
||||
mod direct_codex_attachments;
|
||||
mod direct_codex_audit;
|
||||
mod direct_project_history;
|
||||
mod direct_project_turn_history;
|
||||
mod direct_runtime;
|
||||
mod direct_tool_bridge;
|
||||
mod direct_tools_mcp;
|
||||
@@ -38,6 +40,8 @@ pub(crate) use codex_cli::{
|
||||
pub(crate) use codex_provider_proxy::*;
|
||||
pub(crate) use direct_codex_attachments::*;
|
||||
pub(crate) use direct_codex_audit::*;
|
||||
pub(crate) use direct_project_history::*;
|
||||
pub(crate) use direct_project_turn_history::*;
|
||||
pub(crate) use direct_runtime::*;
|
||||
pub(crate) use direct_tool_bridge::*;
|
||||
pub(crate) use direct_tools_mcp::*;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,467 @@
|
||||
use crate::config::prepare_game_creator_private_path_for_read;
|
||||
use crate::project::{
|
||||
append_jsonl_line_unlocked, enforce_project_permission_policy, project_append_lock_for,
|
||||
};
|
||||
use crate::{LocalConversationMessageRecord, LocalConversationResult};
|
||||
use serde_json::Value;
|
||||
use std::fs::File;
|
||||
use std::io::{BufRead, BufReader, Read, Seek, SeekFrom};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const DIRECT_PROJECT_HISTORY_RECORD_TYPE: &str = "response_item";
|
||||
const DIRECT_PROJECT_INTERNAL_CONTEXT_KINDS: &[&str] = &[
|
||||
"host_skills.instructions",
|
||||
"permissions.instructions",
|
||||
"environments.environment_context",
|
||||
];
|
||||
|
||||
const DIRECT_PROJECT_CONTEXTUAL_USER_TEXT_MARKERS: &[(&str, &str)] = &[
|
||||
("# AGENTS.md instructions", "</INSTRUCTIONS>"),
|
||||
("<environment_context>", "</environment_context>"),
|
||||
("<skill>", "</skill>"),
|
||||
("<user_shell_command>", "</user_shell_command>"),
|
||||
("<turn_aborted>", "</turn_aborted>"),
|
||||
("<subagent_notification>", "</subagent_notification>"),
|
||||
("<recommended_plugins>", "</recommended_plugins>"),
|
||||
("<goal_context>", "</goal_context>"),
|
||||
];
|
||||
|
||||
fn is_known_contextual_user_text(text: &str) -> bool {
|
||||
let text = text.trim();
|
||||
if DIRECT_PROJECT_CONTEXTUAL_USER_TEXT_MARKERS
|
||||
.iter()
|
||||
.any(|(start, end)| text.starts_with(start) && text.ends_with(end))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if text.starts_with("<codex_internal_context") && text.ends_with("</codex_internal_context>") {
|
||||
return true;
|
||||
}
|
||||
text.starts_with("<external_")
|
||||
&& text
|
||||
.split_once('>')
|
||||
.and_then(|(start, _)| start.strip_prefix("<external_"))
|
||||
.is_some_and(|key| text.ends_with(&format!("</external_{key}>")))
|
||||
}
|
||||
|
||||
pub(crate) fn is_direct_project_internal_context_item(item: &Value) -> bool {
|
||||
if matches!(
|
||||
item.get("role").and_then(Value::as_str),
|
||||
Some("developer" | "system")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if item
|
||||
.pointer("/internal_chat_message_metadata_passthrough/content_item_kinds")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|kinds| {
|
||||
kinds
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.any(|kind| DIRECT_PROJECT_INTERNAL_CONTEXT_KINDS.contains(&kind))
|
||||
})
|
||||
{
|
||||
return true;
|
||||
}
|
||||
item.get("content")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|parts| {
|
||||
parts.iter().any(|part| {
|
||||
let Some(text) = part.get("text").and_then(Value::as_str) else {
|
||||
return false;
|
||||
};
|
||||
is_known_contextual_user_text(text)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn history_path(root: &Path) -> PathBuf {
|
||||
root.join(".agent/conversations/project.jsonl")
|
||||
}
|
||||
|
||||
fn record(item: &Value) -> Result<String, String> {
|
||||
serde_json::to_string(&serde_json::json!({
|
||||
"type": DIRECT_PROJECT_HISTORY_RECORD_TYPE,
|
||||
"payload": item,
|
||||
}))
|
||||
.map_err(|error| format!("序列化 DirectProject 历史失败:{error}"))
|
||||
}
|
||||
|
||||
const DIRECT_PROJECT_HISTORY_REVERSE_SCAN_CHUNK_BYTES: usize = 16 * 1024;
|
||||
|
||||
fn find_direct_project_history_item_by_id_at(
|
||||
path: &Path,
|
||||
item_id: &str,
|
||||
) -> Result<Option<Value>, String> {
|
||||
let mut file = File::open(path)
|
||||
.map_err(|error| format!("打开 DirectProject 历史失败:{}: {error}", path.display()))?;
|
||||
let mut position = file
|
||||
.metadata()
|
||||
.map_err(|error| {
|
||||
format!(
|
||||
"读取 DirectProject 历史元数据失败:{}: {error}",
|
||||
path.display()
|
||||
)
|
||||
})?
|
||||
.len();
|
||||
let mut pending = Vec::new();
|
||||
let mut chunk = vec![0u8; DIRECT_PROJECT_HISTORY_REVERSE_SCAN_CHUNK_BYTES];
|
||||
|
||||
loop {
|
||||
if position == 0 {
|
||||
break;
|
||||
}
|
||||
let read_len = usize::try_from(position)
|
||||
.unwrap_or(usize::MAX)
|
||||
.min(chunk.len());
|
||||
position -= read_len as u64;
|
||||
file.seek(SeekFrom::Start(position))
|
||||
.map_err(|error| format!("定位 DirectProject 历史失败:{}: {error}", path.display()))?;
|
||||
file.read_exact(&mut chunk[..read_len])
|
||||
.map_err(|error| format!("读取 DirectProject 历史失败:{}: {error}", path.display()))?;
|
||||
|
||||
let mut combined = Vec::with_capacity(read_len + pending.len());
|
||||
combined.extend_from_slice(&chunk[..read_len]);
|
||||
combined.extend_from_slice(&pending);
|
||||
let mut line_end = combined.len();
|
||||
while let Some(newline) = combined[..line_end].iter().rposition(|byte| *byte == b'\n') {
|
||||
|
k88936 marked this conversation as resolved
|
||||
let line = &combined[newline + 1..line_end];
|
||||
if !line.is_empty() {
|
||||
|
kdletters
commented
[P1] Repairable unterminated final JSONL records must be ignored during reverse id scan; with a prior complete line this currently returns EOF before append repair. [P1] Repairable unterminated final JSONL records must be ignored during reverse id scan; with a prior complete line this currently returns EOF before append repair.
|
||||
if let Some(item) = direct_project_history_item_from_line(path, line)? {
|
||||
if item.get("id").and_then(Value::as_str) == Some(item_id) {
|
||||
return Ok(Some(item));
|
||||
}
|
||||
}
|
||||
}
|
||||
line_end = newline;
|
||||
}
|
||||
pending = combined[..line_end].to_vec();
|
||||
}
|
||||
|
||||
if !pending.is_empty() {
|
||||
// The append path repairs an unterminated final JSONL record before
|
||||
// writing. A duplicate scan must not reject that repairable tail.
|
||||
if let Ok(Some(item)) = direct_project_history_item_from_line(path, &pending) {
|
||||
if item.get("id").and_then(Value::as_str) == Some(item_id) {
|
||||
return Ok(Some(item));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
k88936 marked this conversation as resolved
Outdated
kdletters
commented
P1:这里在调用 P1:这里在调用 `append_jsonl_line_unlocked` 之前先扫描已有记录;如果文件末尾是无换行的半截 JSON 或损坏行,`direct_project_history_item_from_line` 会直接返回解析错误,后续 append 根本不会执行到底层已有的尾行修复逻辑。因此一次中断写入会永久阻塞之后所有带 id 的 raw item。请先执行尾行修复,或让重复检查明确跳过最后一条不完整尾行,并补测试。
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn direct_project_history_item_from_line(
|
||||
path: &Path,
|
||||
line: &[u8],
|
||||
) -> Result<Option<Value>, String> {
|
||||
if line.iter().all(|byte| byte.is_ascii_whitespace()) {
|
||||
return Ok(None);
|
||||
}
|
||||
let parsed: Value = serde_json::from_slice(line)
|
||||
.map_err(|error| format!("解析 DirectProject 历史失败:{}: {error}", path.display()))?;
|
||||
if parsed.get("type").and_then(Value::as_str) != Some(DIRECT_PROJECT_HISTORY_RECORD_TYPE) {
|
||||
return Err(format!(
|
||||
"DirectProject 历史记录类型无效:{}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
let item = parsed
|
||||
.get("payload")
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("DirectProject 历史记录缺少 payload:{}", path.display()))?;
|
||||
if is_direct_project_internal_context_item(&item) {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(item))
|
||||
}
|
||||
|
||||
pub(crate) fn append_direct_project_history_item_at(
|
||||
root: &Path,
|
||||
item: &Value,
|
||||
) -> Result<(), String> {
|
||||
append_direct_project_history_item_at_with_user_policy(root, item, false)
|
||||
}
|
||||
|
||||
/// Appends a user message authored by AGC itself. Codex response items use
|
||||
/// the default path above, which deliberately ignores echoed user messages;
|
||||
/// this explicit entry point keeps the two sources distinct.
|
||||
pub(crate) fn append_direct_project_user_message_at(
|
||||
root: &Path,
|
||||
item: &Value,
|
||||
) -> Result<(), String> {
|
||||
append_direct_project_history_item_at_with_user_policy(root, item, true)
|
||||
}
|
||||
|
||||
fn append_direct_project_history_item_at_with_user_policy(
|
||||
root: &Path,
|
||||
item: &Value,
|
||||
allow_user_item: bool,
|
||||
) -> Result<(), String> {
|
||||
enforce_project_permission_policy(root, "conversation.write")?;
|
||||
if is_direct_project_internal_context_item(item) {
|
||||
return Ok(());
|
||||
}
|
||||
if !allow_user_item && is_direct_project_codex_user_item(item) {
|
||||
return Ok(());
|
||||
}
|
||||
let _project_lock = crate::project::acquire_project_write_lock(root, "conversation.write")?;
|
||||
let path = history_path(root);
|
||||
let history_exists =
|
||||
prepare_game_creator_private_path_for_read(&path, false, "DirectProject 历史")?;
|
||||
let lock = project_append_lock_for(&path)?;
|
||||
let _append_guard = lock.lock("DirectProject 历史追加写")?;
|
||||
if history_exists {
|
||||
if let Some(item_id) = item.get("id").and_then(Value::as_str) {
|
||||
if let Some(existing) = find_direct_project_history_item_by_id_at(&path, item_id)? {
|
||||
if &existing == item {
|
||||
return Ok(());
|
||||
}
|
||||
return Err(format!("DirectProject 历史 item id 冲突:{item_id}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
let line = record(item)?;
|
||||
append_jsonl_line_unlocked(&path, &line, "DirectProject 历史")
|
||||
}
|
||||
|
||||
fn is_direct_project_codex_user_item(item: &Value) -> bool {
|
||||
if item.get("type").and_then(Value::as_str) == Some("userMessage") {
|
||||
return true;
|
||||
}
|
||||
if item.get("role").and_then(Value::as_str) != Some("user") {
|
||||
return false;
|
||||
}
|
||||
!item
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|id| id.starts_with("direct-codex:") && id.ends_with(":user"))
|
||||
}
|
||||
|
||||
pub(crate) fn direct_project_local_message_item(
|
||||
role: &str,
|
||||
content: &str,
|
||||
message_id: Option<&str>,
|
||||
) -> Result<Value, String> {
|
||||
let role = role.trim();
|
||||
let content = content.trim();
|
||||
if content.is_empty() || !matches!(role, "user" | "assistant") {
|
||||
return Err("DirectProject 只接受非空 user/assistant 历史消息".to_string());
|
||||
}
|
||||
let mut item = serde_json::json!({
|
||||
"type": "message",
|
||||
"role": role,
|
||||
"content": [{
|
||||
"type": if role == "user" { "input_text" } else { "output_text" },
|
||||
"text": content,
|
||||
}],
|
||||
});
|
||||
if let Some(message_id) = message_id.map(str::trim).filter(|value| !value.is_empty()) {
|
||||
item["id"] = Value::String(message_id.to_string());
|
||||
}
|
||||
Ok(item)
|
||||
}
|
||||
|
||||
pub(crate) fn read_direct_project_history_items_at(root: &Path) -> Result<Vec<Value>, String> {
|
||||
let path = history_path(root);
|
||||
if !prepare_game_creator_private_path_for_read(&path, false, "DirectProject 历史")? {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let file = File::open(&path)
|
||||
.map_err(|error| format!("打开 DirectProject 历史失败:{}: {error}", path.display()))?;
|
||||
let mut items = Vec::new();
|
||||
let mut reader = BufReader::new(file);
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
let bytes = reader
|
||||
.read_line(&mut line)
|
||||
.map_err(|error| format!("读取 DirectProject 历史失败:{}: {error}", path.display()))?;
|
||||
if bytes == 0 {
|
||||
break;
|
||||
}
|
||||
let had_newline = line.ends_with('\n');
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let parsed: Value = match serde_json::from_str(trimmed) {
|
||||
Ok(value) => value,
|
||||
Err(_error) if !had_newline => break,
|
||||
Err(error) => {
|
||||
return Err(format!(
|
||||
"解析 DirectProject 历史失败:{}: {error}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
};
|
||||
if parsed.get("type").and_then(Value::as_str) != Some(DIRECT_PROJECT_HISTORY_RECORD_TYPE) {
|
||||
return Err(format!(
|
||||
"DirectProject 历史记录类型无效:{}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
let item = parsed
|
||||
.get("payload")
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("DirectProject 历史记录缺少 payload:{}", path.display()))?;
|
||||
if is_direct_project_internal_context_item(&item) {
|
||||
continue;
|
||||
}
|
||||
items.push(item);
|
||||
}
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
pub(crate) fn read_direct_project_chat_history_at(
|
||||
root: &Path,
|
||||
) -> Result<LocalConversationResult, String> {
|
||||
let path = history_path(root);
|
||||
let items = read_direct_project_history_items_at(root)?;
|
||||
let messages = items
|
||||
.into_iter()
|
||||
.filter_map(|item| {
|
||||
let role = item.get("role").and_then(Value::as_str)?;
|
||||
if !matches!(role, "user" | "assistant") {
|
||||
return None;
|
||||
}
|
||||
let content = item
|
||||
.get("content")
|
||||
.and_then(Value::as_array)
|
||||
.map(|parts| {
|
||||
parts
|
||||
.iter()
|
||||
.filter_map(|part| part.get("text").and_then(Value::as_str))
|
||||
.collect::<Vec<_>>()
|
||||
.join("")
|
||||
})
|
||||
.unwrap_or_default();
|
||||
(!content.is_empty()).then(|| LocalConversationMessageRecord {
|
||||
schema_version: "agc-direct-project-context.v1".to_string(),
|
||||
role: role.to_string(),
|
||||
content,
|
||||
agent_id: None,
|
||||
message_id: item.get("id").and_then(Value::as_str).map(str::to_string),
|
||||
updated_at: 0,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(LocalConversationResult {
|
||||
path: path.to_string_lossy().into_owned(),
|
||||
agent_id: None,
|
||||
session_id: None,
|
||||
messages,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
append_direct_project_history_item_at, append_direct_project_user_message_at, history_path,
|
||||
is_direct_project_internal_context_item, read_direct_project_history_items_at,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn filters_host_context_but_keeps_real_user_items() {
|
||||
assert!(is_direct_project_internal_context_item(&json!({
|
||||
"type": "message",
|
||||
"role": "developer",
|
||||
"content": [{"type": "input_text", "text": "<skills_instructions>"}]
|
||||
})));
|
||||
assert!(is_direct_project_internal_context_item(&json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": "<environment_context>"}],
|
||||
"internal_chat_message_metadata_passthrough": {
|
||||
"content_item_kinds": ["environments.environment_context"]
|
||||
}
|
||||
})));
|
||||
assert!(!is_direct_project_internal_context_item(&json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": "ls ui"}],
|
||||
"internal_chat_message_metadata_passthrough": {
|
||||
"content_item_kinds": ["user.text"]
|
||||
}
|
||||
})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filters_codex_contextual_user_item_without_passthrough_metadata() {
|
||||
assert!(is_direct_project_internal_context_item(&json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "input_text",
|
||||
"text": "<environment_context>\n<cwd>/tmp/project</cwd>\n</environment_context>"
|
||||
}]
|
||||
})));
|
||||
assert!(!is_direct_project_internal_context_item(&json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "input_text",
|
||||
"text": "请读取 <environment_context> 中的说明"
|
||||
}]
|
||||
})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_repairs_truncated_tail_before_idempotency_scan() {
|
||||
let root = tempfile::tempdir().expect("temp project");
|
||||
crate::init_local_game_project_at(root.path(), "tail-repair", "尾行修复")
|
||||
.expect("init project");
|
||||
let path = history_path(root.path());
|
||||
std::fs::create_dir_all(path.parent().expect("history parent")).expect("history dir");
|
||||
std::fs::write(
|
||||
&path,
|
||||
br#"{"type":"response_item","payload":{"type":"message"}"#,
|
||||
)
|
||||
.expect("write truncated history tail");
|
||||
let item = serde_json::json!({
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"id": "tail-repair-item",
|
||||
"content": [{"type": "output_text", "text": "已修复"}]
|
||||
});
|
||||
append_direct_project_history_item_at(root.path(), &item).expect("repair and append");
|
||||
let items =
|
||||
read_direct_project_history_items_at(root.path()).expect("read repaired history");
|
||||
assert_eq!(items, vec![item]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_user_echo_is_filtered_but_agc_user_message_is_persisted() {
|
||||
let root = tempfile::tempdir().expect("temp project");
|
||||
crate::init_local_game_project_at(root.path(), "user-echo", "用户回显过滤")
|
||||
.expect("init project");
|
||||
let user = serde_json::json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"id": "direct-codex:turn-0001:user",
|
||||
"content": [{"type": "input_text", "text": "请创建菜单"}]
|
||||
});
|
||||
append_direct_project_user_message_at(root.path(), &user).expect("persist AGC user");
|
||||
append_direct_project_history_item_at(
|
||||
root.path(),
|
||||
&serde_json::json!({
|
||||
"type": "userMessage",
|
||||
"id": "codex-user-item-1",
|
||||
"clientId": "turn-0001",
|
||||
"content": [{"type": "text", "text": "请创建菜单"}]
|
||||
}),
|
||||
)
|
||||
.expect("ignore Codex echo");
|
||||
append_direct_project_history_item_at(
|
||||
root.path(),
|
||||
&serde_json::json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"id": "codex-raw-user-item-1",
|
||||
"content": [{"type": "input_text", "text": "请创建菜单"}]
|
||||
}),
|
||||
)
|
||||
.expect("ignore raw Codex user echo");
|
||||
let items = read_direct_project_history_items_at(root.path()).expect("read history");
|
||||
assert_eq!(items, vec![user]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
use super::direct_project_history::append_direct_project_history_item_at;
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct DirectProjectHistoryAccumulator {
|
||||
text_by_item_id: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl DirectProjectHistoryAccumulator {
|
||||
pub(crate) fn observe_delta(&mut self, item_id: &str, delta: &str) {
|
||||
self.text_by_item_id
|
||||
.entry(item_id.to_string())
|
||||
.or_default()
|
||||
.push_str(delta);
|
||||
}
|
||||
|
||||
pub(crate) fn complete_item(&mut self, item: &Value) {
|
||||
if let Some(item_id) = item.get("id").and_then(Value::as_str) {
|
||||
self.text_by_item_id.remove(item_id);
|
||||
}
|
||||
}
|
||||
|
||||
fn take_partial_items(&mut self) -> impl Iterator<Item = Value> + '_ {
|
||||
std::mem::take(&mut self.text_by_item_id)
|
||||
.into_iter()
|
||||
.filter(|(_, text)| !text.is_empty())
|
||||
.map(|(item_id, text)| {
|
||||
serde_json::json!({
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"id": item_id,
|
||||
"content": [{"type": "output_text", "text": text}],
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn persist_direct_project_partial_items_at(
|
||||
root: &Path,
|
||||
accumulator: &mut DirectProjectHistoryAccumulator,
|
||||
) -> Result<(), String> {
|
||||
let items = accumulator.take_partial_items().collect::<Vec<_>>();
|
||||
let mut first_error = None;
|
||||
for item in items {
|
||||
if let Err(error) = append_direct_project_history_item_at(root, &item) {
|
||||
if first_error.is_none() {
|
||||
first_error = Some(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
first_error.map_or(Ok(()), Err)
|
||||
}
|
||||
@@ -4407,48 +4407,6 @@ fn normalize_direct_client_turn_id(client_turn_id: Option<&str>) -> Result<Strin
|
||||
Ok(client_turn_id.to_string())
|
||||
}
|
||||
|
||||
fn persist_direct_codex_assistant_reply_at(
|
||||
root: &Path,
|
||||
client_turn_id: &str,
|
||||
reply: &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: "assistant".to_string(),
|
||||
content: reply.to_string(),
|
||||
agent_id: None,
|
||||
},
|
||||
&format!("direct-codex:{client_turn_id}:assistant"),
|
||||
)
|
||||
.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,
|
||||
@@ -4480,15 +4438,6 @@ 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,
|
||||
@@ -4504,15 +4453,6 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
if let Err(error) = persist_direct_codex_assistant_reply_at(root, &turn_id, &reply) {
|
||||
audit.finish(false);
|
||||
turn_emitter.emit("failed", Some("none"), None);
|
||||
return Err(redact_agent_runtime_error(
|
||||
root,
|
||||
&format!("Direct 成功回复持久化失败,已拒绝以未落盘状态返回:{error}"),
|
||||
500,
|
||||
));
|
||||
}
|
||||
audit.finish(true);
|
||||
turn_emitter.emit("completed", Some("none"), Some(reply.clone()));
|
||||
Ok(reply)
|
||||
@@ -4526,6 +4466,34 @@ pub(crate) async fn chat_with_game_creator_home_direct_codex(
|
||||
run_direct_game_creator_home_turn(&prompt, attachments.as_deref().unwrap_or_default()).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn persist_direct_codex_user_prompt_at(
|
||||
root: &Path,
|
||||
client_turn_id: &str,
|
||||
prompt: &str,
|
||||
) -> Result<(), String> {
|
||||
let item = direct_project_local_message_item(
|
||||
"user",
|
||||
prompt,
|
||||
Some(&format!("direct-codex:{client_turn_id}:user")),
|
||||
)?;
|
||||
append_direct_project_user_message_at(root, &item)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn persist_direct_codex_assistant_reply_at(
|
||||
root: &Path,
|
||||
client_turn_id: &str,
|
||||
reply: &str,
|
||||
) -> Result<(), String> {
|
||||
let item = direct_project_local_message_item(
|
||||
"assistant",
|
||||
reply,
|
||||
Some(&format!("direct-codex:{client_turn_id}:assistant")),
|
||||
)?;
|
||||
append_direct_project_history_item_at(root, &item)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -4610,8 +4578,8 @@ mod tests {
|
||||
persist_direct_codex_assistant_reply_at(root.path(), turn_id, reply)
|
||||
.expect("the App's same-id write converges idempotently");
|
||||
|
||||
let conversation = read_local_conversation_for_session_at(root.path(), None, None)
|
||||
.expect("read project conversation");
|
||||
let conversation =
|
||||
read_direct_project_chat_history_at(root.path()).expect("read project conversation");
|
||||
let message_id = format!("direct-codex:{turn_id}:assistant");
|
||||
let persisted = conversation
|
||||
.messages
|
||||
@@ -4624,7 +4592,7 @@ mod tests {
|
||||
assert!(
|
||||
persist_direct_codex_assistant_reply_at(root.path(), turn_id, "不同回复")
|
||||
.expect_err("same message identity cannot be rebound")
|
||||
.contains("messageId 冲突")
|
||||
.contains("item id 冲突")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4641,8 +4609,8 @@ mod tests {
|
||||
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 conversation =
|
||||
read_direct_project_chat_history_at(root.path()).expect("read project conversation");
|
||||
let message_id = format!("direct-codex:{turn_id}:user");
|
||||
let persisted = conversation
|
||||
.messages
|
||||
@@ -4655,7 +4623,7 @@ mod tests {
|
||||
assert!(
|
||||
persist_direct_codex_user_prompt_at(root.path(), turn_id, "不同的重试请求")
|
||||
.expect_err("same turn identity cannot be rebound")
|
||||
.contains("messageId 冲突")
|
||||
.contains("item id 冲突")
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::*;
|
||||
use crate::agent::read_direct_project_chat_history_at;
|
||||
use crate::ui_editor::resource::font::FontAsset;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
@@ -4748,6 +4749,15 @@ pub(crate) fn read_local_conversation(
|
||||
read_local_conversation_for_session_at(root, agent_id.as_deref(), session_id.as_deref())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn read_direct_project_conversation(
|
||||
project_path: String,
|
||||
) -> Result<LocalConversationResult, String> {
|
||||
let root = Path::new(project_path.trim());
|
||||
enforce_project_permission_policy(root, "conversation.read")?;
|
||||
read_direct_project_chat_history_at(root)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn append_local_conversation_message(
|
||||
project_path: String,
|
||||
@@ -4776,6 +4786,20 @@ pub(crate) fn append_local_conversation_message(
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn append_direct_project_conversation_message(
|
||||
project_path: String,
|
||||
message: LocalConversationMessage,
|
||||
message_id: Option<String>,
|
||||
) -> Result<LocalConversationResult, String> {
|
||||
let root = Path::new(project_path.trim());
|
||||
enforce_project_permission_policy(root, "conversation.write")?;
|
||||
let item =
|
||||
direct_project_local_message_item(&message.role, &message.content, message_id.as_deref())?;
|
||||
append_direct_project_history_item_at(root, &item)?;
|
||||
read_direct_project_chat_history_at(root)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn build_local_project_index(
|
||||
project_path: String,
|
||||
|
||||
@@ -2591,7 +2591,9 @@ fn main() {
|
||||
set_active_game_creator_agent_session,
|
||||
archive_game_creator_agent_session,
|
||||
read_local_conversation,
|
||||
read_direct_project_conversation,
|
||||
append_local_conversation_message,
|
||||
append_direct_project_conversation_message,
|
||||
build_local_project_index,
|
||||
create_local_project_checkpoint,
|
||||
export_local_project_package,
|
||||
|
||||
@@ -4318,12 +4318,12 @@ fn project_append_locks() -> &'static Mutex<BTreeMap<PathBuf, Arc<Mutex<()>>>> {
|
||||
PROJECT_APPEND_LOCKS.get_or_init(|| Mutex::new(BTreeMap::new()))
|
||||
}
|
||||
|
||||
pub(super) struct ProjectAppendLock {
|
||||
pub(crate) struct ProjectAppendLock {
|
||||
process_lock: Arc<Mutex<()>>,
|
||||
os_lock_path: PathBuf,
|
||||
}
|
||||
|
||||
pub(super) struct ProjectAppendGuard<'a> {
|
||||
pub(crate) struct ProjectAppendGuard<'a> {
|
||||
_process_guard: std::sync::MutexGuard<'a, ()>,
|
||||
_os_lock: File,
|
||||
}
|
||||
@@ -4335,7 +4335,7 @@ impl ProjectAppendLock {
|
||||
.map_err(|_| format!("获取{error_label}进程内锁失败:锁已损坏"))
|
||||
}
|
||||
|
||||
pub(super) fn lock(&self, error_label: &str) -> Result<ProjectAppendGuard<'_>, String> {
|
||||
pub(crate) fn lock(&self, error_label: &str) -> Result<ProjectAppendGuard<'_>, String> {
|
||||
let process_guard = self
|
||||
.process_lock
|
||||
.lock()
|
||||
@@ -4348,7 +4348,7 @@ impl ProjectAppendLock {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn project_append_lock_for(path: &Path) -> Result<ProjectAppendLock, String> {
|
||||
pub(crate) fn project_append_lock_for(path: &Path) -> Result<ProjectAppendLock, String> {
|
||||
let mut locks = project_append_locks()
|
||||
.lock()
|
||||
.map_err(|_| "获取本地追加写锁失败:锁已损坏".to_string())?;
|
||||
@@ -4539,7 +4539,7 @@ fn try_open_project_append_os_lock(path: &Path, error_label: &str) -> Result<Opt
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) fn append_jsonl_line_unlocked(
|
||||
pub(crate) fn append_jsonl_line_unlocked(
|
||||
path: &Path,
|
||||
line: &str,
|
||||
error_label: &str,
|
||||
|
||||
@@ -489,6 +489,7 @@ type ExecuteChatAgentReplyInput = {
|
||||
clientTurnId?: string;
|
||||
creationType?: HomeCreationType | null;
|
||||
attachments?: DirectCodexTurnAttachment[];
|
||||
directPolicyChecked?: boolean;
|
||||
};
|
||||
|
||||
export function App({
|
||||
@@ -601,11 +602,6 @@ export function App({
|
||||
const [directCodexTransientReply, setDirectCodexTransientReply] =
|
||||
useState('');
|
||||
const directCodexTransientReplyRef = useRef('');
|
||||
const directCodexInterruptedPartialRef = useRef<{
|
||||
projectPath: string;
|
||||
text: string;
|
||||
messageId: string;
|
||||
} | null>(null);
|
||||
const [
|
||||
directCodexTransientReplyUpdatedAt,
|
||||
setDirectCodexTransientReplyUpdatedAt,
|
||||
@@ -617,10 +613,6 @@ export function App({
|
||||
receivedDirectUpdate: boolean;
|
||||
} | null>(null);
|
||||
const lastDirectCodexActivityRef = useRef<string | null>(null);
|
||||
const recoveredDirectCodexTurnClaimsRef = useRef(new Set<string>());
|
||||
const directCodexClaimReleaseOnConversationWriteFailureRef = useRef(
|
||||
new Map<string, string>(),
|
||||
);
|
||||
const directCodexConversationTurnSequenceRef = useRef(0);
|
||||
const [projectSupervisorSessionId, setProjectSupervisorSessionId] = useState<
|
||||
string | null
|
||||
@@ -1801,6 +1793,16 @@ export function App({
|
||||
projectConversationWriteConfirmedRef.current = null;
|
||||
projectConversationWriteCancelledRef.current = null;
|
||||
}
|
||||
// DirectProject history is written by Rust from raw app-server items.
|
||||
// The browser only renders that projection and must not append chat rows.
|
||||
if (projectSupervisorOnly && directCodexProductRuntime) {
|
||||
// `messages` is only an optimistic UI projection in Direct mode; it is
|
||||
// intentionally not proof of durability. Rust owns the raw response
|
||||
// history, so this effect must not route these rows through the generic
|
||||
// browser conversation writer.
|
||||
savedConversationCountRef.current = messages.length;
|
||||
return;
|
||||
}
|
||||
const start = savedConversationCountRef.current;
|
||||
const pendingMessages = messages.slice(start);
|
||||
if (pendingMessages.length === 0) {
|
||||
@@ -1871,7 +1873,6 @@ export function App({
|
||||
return;
|
||||
}
|
||||
conversationWriteInFlightRef.current = true;
|
||||
let failedDirectTerminalMessageCount: number | null = null;
|
||||
void (async () => {
|
||||
let wroteMessage = false;
|
||||
for (const [index, message] of pendingMessages.entries()) {
|
||||
@@ -1886,56 +1887,22 @@ export function App({
|
||||
savedConversationCountRef.current = start + index + 1;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await invoke<LocalConversationResult>(
|
||||
'append_local_conversation_message',
|
||||
{
|
||||
projectPath: nextProjectPath,
|
||||
await invoke<LocalConversationResult>(
|
||||
'append_local_conversation_message',
|
||||
{
|
||||
projectPath: nextProjectPath,
|
||||
agentId: null,
|
||||
...(message.messageId ? { messageId: message.messageId } : {}),
|
||||
message: {
|
||||
role: message.role,
|
||||
content: message.text,
|
||||
agentId: null,
|
||||
...(message.messageId ? { messageId: message.messageId } : {}),
|
||||
message: {
|
||||
role: message.role,
|
||||
content: message.text,
|
||||
agentId: null,
|
||||
...(typeof message.updatedAt === 'number'
|
||||
? { updatedAt: message.updatedAt }
|
||||
: {}),
|
||||
},
|
||||
...(typeof message.updatedAt === 'number'
|
||||
? { updatedAt: message.updatedAt }
|
||||
: {}),
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
const claimKey = message.messageId
|
||||
? directCodexClaimReleaseOnConversationWriteFailureRef.current.get(
|
||||
message.messageId,
|
||||
)
|
||||
: undefined;
|
||||
if (claimKey) {
|
||||
directCodexClaimReleaseOnConversationWriteFailureRef.current.delete(
|
||||
message.messageId!,
|
||||
);
|
||||
recoveredDirectCodexTurnClaimsRef.current.delete(claimKey);
|
||||
failedDirectTerminalMessageCount = start + index + 1;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (message.messageId) {
|
||||
const claimKey =
|
||||
directCodexClaimReleaseOnConversationWriteFailureRef.current.get(
|
||||
message.messageId,
|
||||
);
|
||||
if (claimKey) {
|
||||
directCodexClaimReleaseOnConversationWriteFailureRef.current.delete(
|
||||
message.messageId,
|
||||
);
|
||||
if (localProjectPathRef.current === nextProjectPath) {
|
||||
// Any history read started before this terminal append may hold
|
||||
// A stale history snapshot may still be missing this terminal
|
||||
// append. Invalidate it before releasing the in-memory claim.
|
||||
projectSupervisorHistoryLoadVersionRef.current += 1;
|
||||
}
|
||||
recoveredDirectCodexTurnClaimsRef.current.delete(claimKey);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
wroteMessage = true;
|
||||
savedConversationCountRef.current = start + index + 1;
|
||||
}
|
||||
@@ -1950,12 +1917,10 @@ export function App({
|
||||
}
|
||||
})()
|
||||
.catch((error) => {
|
||||
savedConversationCountRef.current = failedDirectTerminalMessageCount
|
||||
? Math.max(
|
||||
savedConversationCountRef.current,
|
||||
failedDirectTerminalMessageCount,
|
||||
)
|
||||
: Math.min(savedConversationCountRef.current, start);
|
||||
savedConversationCountRef.current = Math.min(
|
||||
savedConversationCountRef.current,
|
||||
start,
|
||||
);
|
||||
setWorkspaceStatus(
|
||||
`项目对话保存失败:${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
@@ -1985,6 +1950,7 @@ export function App({
|
||||
conversationWriteVersion,
|
||||
pendingUiConfirmation,
|
||||
projectSupervisorOnly,
|
||||
directCodexProductRuntime,
|
||||
]);
|
||||
|
||||
function appendLocalPermissionLog(
|
||||
@@ -2741,11 +2707,12 @@ export function App({
|
||||
: await readProjectSupervisorActiveSession(invoke, nextProjectPath);
|
||||
let runtimeError = '';
|
||||
const projectConversation = await invoke<LocalConversationResult>(
|
||||
'read_local_conversation',
|
||||
{
|
||||
projectPath: nextProjectPath,
|
||||
agentId: null,
|
||||
},
|
||||
directCodexProductRuntime
|
||||
? 'read_direct_project_conversation'
|
||||
: 'read_local_conversation',
|
||||
directCodexProductRuntime
|
||||
? { projectPath: nextProjectPath }
|
||||
: { projectPath: nextProjectPath, agentId: null },
|
||||
);
|
||||
let supervisorConversation: LocalConversationResult | null = null;
|
||||
let runtime: AgentRuntimeState | null = null;
|
||||
@@ -5450,6 +5417,7 @@ export function App({
|
||||
clientTurnId: directConversationTurnId,
|
||||
creationType,
|
||||
attachments,
|
||||
directPolicyChecked = false,
|
||||
}: ExecuteChatAgentReplyInput) {
|
||||
// Product default: send the conversation directly to Codex app-server.
|
||||
// The legacy Supervisor/harness path remains below for rollback and tests.
|
||||
@@ -5459,6 +5427,44 @@ export function App({
|
||||
if (directProjectPath && directInvoke) {
|
||||
const clientTurnId =
|
||||
directConversationTurnId ?? createDirectCodexConversationTurnId();
|
||||
if (
|
||||
!directPolicyChecked &&
|
||||
projectConversationWriteConfirmedRef.current !== directProjectPath
|
||||
) {
|
||||
try {
|
||||
const policyPaused = await queueProjectPolicyConfirmationIfNeeded(
|
||||
|
kdletters
commented
[P2] Claim busy/active-turn ownership before this await; a second submit during policy I/O can clear the first turn state in its finally block. [P2] Claim busy/active-turn ownership before this await; a second submit during policy I/O can clear the first turn state in its finally block.
|
||||
directInvoke,
|
||||
'conversation.write',
|
||||
directProjectPath,
|
||||
'写入 DirectProject 对话历史',
|
||||
'DirectProject 对话写入需要确认。',
|
||||
() => {
|
||||
projectConversationWriteConfirmedRef.current =
|
||||
directProjectPath;
|
||||
void executeChatAgentReply({
|
||||
prompt,
|
||||
clientTurnId,
|
||||
creationType,
|
||||
attachments,
|
||||
directPolicyChecked: true,
|
||||
});
|
||||
},
|
||||
);
|
||||
if (policyPaused) {
|
||||
return;
|
||||
}
|
||||
projectConversationWriteConfirmedRef.current = directProjectPath;
|
||||
} catch (error) {
|
||||
if (localProjectPathRef.current === directProjectPath) {
|
||||
setProjectSupervisorRuntimeError(
|
||||
`DirectProject 对话权限检查失败:${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
const directUserMessageId = directCodexConversationMessageId(
|
||||
clientTurnId,
|
||||
'user',
|
||||
@@ -5470,20 +5476,45 @@ export function App({
|
||||
const appendDirectUserMessageIfMissing = (
|
||||
current: ChatMessage[],
|
||||
): ChatMessage[] => {
|
||||
return current.some(
|
||||
(message) => message.messageId === directUserMessageId,
|
||||
)
|
||||
? current
|
||||
: [
|
||||
...current,
|
||||
{
|
||||
role: 'user' as const,
|
||||
text: prompt,
|
||||
runtimeOwned: true,
|
||||
messageId: directUserMessageId,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
];
|
||||
if (
|
||||
current.some((message) => message.messageId === directUserMessageId)
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
let optimisticIndex = -1;
|
||||
for (let index = current.length - 1; index >= 0; index -= 1) {
|
||||
const message = current[index];
|
||||
if (
|
||||
message?.role === 'user' &&
|
||||
message.text === prompt &&
|
||||
!message.messageId
|
||||
) {
|
||||
optimisticIndex = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (optimisticIndex >= 0) {
|
||||
return current.map((message, index) =>
|
||||
index === optimisticIndex
|
||||
? {
|
||||
...message,
|
||||
runtimeOwned: true,
|
||||
messageId: directUserMessageId,
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
: message,
|
||||
);
|
||||
}
|
||||
return [
|
||||
...current,
|
||||
{
|
||||
role: 'user' as const,
|
||||
text: prompt,
|
||||
runtimeOwned: true,
|
||||
messageId: directUserMessageId,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
];
|
||||
};
|
||||
const appendDirectAssistantMessage = (
|
||||
current: ChatMessage[],
|
||||
@@ -5507,41 +5538,6 @@ export function App({
|
||||
index === existingIndex ? nextMessage : message,
|
||||
);
|
||||
};
|
||||
const persistDirectAssistantMessage = (text: string) =>
|
||||
directInvoke<LocalConversationResult>(
|
||||
'append_local_conversation_message',
|
||||
{
|
||||
projectPath: directProjectPath,
|
||||
agentId: null,
|
||||
messageId: directAssistantMessageId,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: text,
|
||||
agentId: null,
|
||||
},
|
||||
},
|
||||
);
|
||||
const persistDirectPartialMessage = (messageId: string, text: string) =>
|
||||
directInvoke<LocalConversationResult>(
|
||||
'append_local_conversation_message',
|
||||
{
|
||||
projectPath: directProjectPath,
|
||||
agentId: null,
|
||||
messageId,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
// An interrupted partial is intentionally a normal assistant
|
||||
// record so replay sees exactly what Codex emitted before the
|
||||
// disconnect; the marker is product data, not UI metadata.
|
||||
content: `${text.trim()}\nunexpected interrupt happened here`,
|
||||
agentId: null,
|
||||
},
|
||||
},
|
||||
);
|
||||
const recoveredDirectCodexTurnClaimKey = `${directProjectPath}\u0000${clientTurnId}`;
|
||||
recoveredDirectCodexTurnClaimsRef.current.add(
|
||||
recoveredDirectCodexTurnClaimKey,
|
||||
);
|
||||
activeDirectCodexTurnRef.current = {
|
||||
projectPath: directProjectPath,
|
||||
turnId: clientTurnId,
|
||||
@@ -5558,36 +5554,6 @@ export function App({
|
||||
setDirectCodexTransientReplyUpdatedAt(null);
|
||||
setProjectSupervisorRuntimeError('');
|
||||
try {
|
||||
const interruptedPartial = directCodexInterruptedPartialRef.current;
|
||||
if (
|
||||
interruptedPartial?.projectPath === directProjectPath &&
|
||||
interruptedPartial.text.trim()
|
||||
) {
|
||||
await persistDirectPartialMessage(
|
||||
interruptedPartial.messageId,
|
||||
interruptedPartial.text,
|
||||
);
|
||||
directCodexInterruptedPartialRef.current = null;
|
||||
}
|
||||
// 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;
|
||||
@@ -5609,28 +5575,11 @@ export function App({
|
||||
'chat_with_game_creator_direct_codex',
|
||||
directTurnInput,
|
||||
);
|
||||
try {
|
||||
await persistDirectAssistantMessage(reply);
|
||||
} catch (error) {
|
||||
if (localProjectPathRef.current === directProjectPath) {
|
||||
setProjectSupervisorRuntimeError(
|
||||
`陶泥儿回复保存失败:${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Rust persists a successful Direct reply before returning Ok. The
|
||||
// browser append is redundant, so the in-memory turn claim can be
|
||||
// released without reopening the Provider side effect. Invalidate
|
||||
// any history snapshot captured before Rust committed the terminal
|
||||
// reply first.
|
||||
// Rust already persisted the complete raw response items. Invalidate
|
||||
// any history snapshot captured before the turn completed.
|
||||
if (localProjectPathRef.current === directProjectPath) {
|
||||
projectSupervisorHistoryLoadVersionRef.current += 1;
|
||||
}
|
||||
recoveredDirectCodexTurnClaimsRef.current.delete(
|
||||
recoveredDirectCodexTurnClaimKey,
|
||||
);
|
||||
if (localProjectPathRef.current === directProjectPath) {
|
||||
clearDirectCodexTransientReply(directProjectPath, clientTurnId);
|
||||
setMessages((current) =>
|
||||
@@ -5643,9 +5592,6 @@ export function App({
|
||||
}
|
||||
} catch (error) {
|
||||
if (isDirectCodexTurnAlreadyRunningError(error)) {
|
||||
recoveredDirectCodexTurnClaimsRef.current.delete(
|
||||
recoveredDirectCodexTurnClaimKey,
|
||||
);
|
||||
if (localProjectPathRef.current === directProjectPath) {
|
||||
clearDirectCodexTransientReply(directProjectPath, clientTurnId);
|
||||
setProjectSupervisorRuntimeError(
|
||||
@@ -5665,46 +5611,7 @@ export function App({
|
||||
'陶泥儿智能创作',
|
||||
true,
|
||||
);
|
||||
const partial = directCodexTransientReplyRef.current.trim();
|
||||
if (partial) {
|
||||
const partialMessageId =
|
||||
globalThis.crypto?.randomUUID?.() ||
|
||||
`direct-partial-${Date.now().toString(36)}`;
|
||||
directCodexInterruptedPartialRef.current = {
|
||||
projectPath: directProjectPath,
|
||||
text: partial,
|
||||
messageId: partialMessageId,
|
||||
};
|
||||
try {
|
||||
await persistDirectPartialMessage(partialMessageId, partial);
|
||||
} catch {
|
||||
// The next user send retries this idempotent append before
|
||||
// constructing the replay prompt.
|
||||
}
|
||||
}
|
||||
try {
|
||||
await persistDirectAssistantMessage(visibleMessage);
|
||||
if (localProjectPathRef.current === directProjectPath) {
|
||||
projectSupervisorHistoryLoadVersionRef.current += 1;
|
||||
}
|
||||
recoveredDirectCodexTurnClaimsRef.current.delete(
|
||||
recoveredDirectCodexTurnClaimKey,
|
||||
);
|
||||
} catch {
|
||||
if (localProjectPathRef.current === directProjectPath) {
|
||||
// Do not release the claim while the React conversation writer
|
||||
// can still persist this terminal record. That writer releases
|
||||
// the claim only after its exact append resolves or rejects.
|
||||
directCodexClaimReleaseOnConversationWriteFailureRef.current.set(
|
||||
directAssistantMessageId,
|
||||
recoveredDirectCodexTurnClaimKey,
|
||||
);
|
||||
} else {
|
||||
recoveredDirectCodexTurnClaimsRef.current.delete(
|
||||
recoveredDirectCodexTurnClaimKey,
|
||||
);
|
||||
}
|
||||
}
|
||||
projectSupervisorHistoryLoadVersionRef.current += 1;
|
||||
if (localProjectPathRef.current === directProjectPath) {
|
||||
clearDirectCodexTransientReply(directProjectPath, clientTurnId);
|
||||
setDirectCodexStatus('failed');
|
||||
|
||||
@@ -2122,6 +2122,7 @@ export function registerHomeProjectCreationTests() {
|
||||
'existing-project',
|
||||
'已有项目',
|
||||
);
|
||||
const persistedMessages: Array<Record<string, unknown>> = [];
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'get_local_game_manifest') {
|
||||
@@ -2131,14 +2132,45 @@ export function registerHomeProjectCreationTests() {
|
||||
if (command === 'append_local_permission_log') {
|
||||
return {};
|
||||
}
|
||||
if (command === 'append_local_conversation_message') {
|
||||
if (command === 'hydrate_game_creator_plan_gdd_state') {
|
||||
return null;
|
||||
}
|
||||
if (command === 'read_project_permission_policy') {
|
||||
return {
|
||||
path: '.agent/policy.json',
|
||||
policy: { deniedCommands: [], confirmCommands: [] },
|
||||
};
|
||||
}
|
||||
if (
|
||||
command === 'read_local_conversation' ||
|
||||
command === 'read_direct_project_conversation'
|
||||
) {
|
||||
return {
|
||||
path: `${projectPath}/.agent/conversations/project.jsonl`,
|
||||
agentId: null,
|
||||
messages: [],
|
||||
sessionId: null,
|
||||
messages: [...persistedMessages],
|
||||
};
|
||||
}
|
||||
if (command === 'append_local_conversation_message') {
|
||||
throw new Error(
|
||||
'DirectProject must not use browser conversation writer',
|
||||
);
|
||||
}
|
||||
if (command === 'chat_with_game_creator_direct_codex') {
|
||||
const clientTurnId = String(args?.clientTurnId ?? '');
|
||||
persistedMessages.push(
|
||||
{
|
||||
role: 'user',
|
||||
content: String(args?.prompt ?? ''),
|
||||
messageId: `direct-codex:${clientTurnId}:user`,
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'DIRECT_EXISTING_PROJECT_OK',
|
||||
messageId: `direct-codex:${clientTurnId}:assistant`,
|
||||
},
|
||||
);
|
||||
return 'DIRECT_EXISTING_PROJECT_OK';
|
||||
}
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
@@ -2165,32 +2197,18 @@ export function registerHomeProjectCreationTests() {
|
||||
},
|
||||
);
|
||||
});
|
||||
const persistedTurnCall = invoke.mock.calls.findIndex(
|
||||
([command, args]) =>
|
||||
command === 'append_local_conversation_message' &&
|
||||
(args as Record<string, unknown> | undefined)?.messageId !== undefined,
|
||||
);
|
||||
const directTurnCall = invoke.mock.calls.findIndex(
|
||||
([command]) => command === 'chat_with_game_creator_direct_codex',
|
||||
);
|
||||
expect(persistedTurnCall).toBeGreaterThanOrEqual(0);
|
||||
expect(persistedTurnCall).toBeLessThan(directTurnCall);
|
||||
const persistedTurnArgs = invoke.mock.calls[persistedTurnCall]?.[1] as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const directTurnArgs = invoke.mock.calls[directTurnCall]?.[1] as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
expect(persistedTurnArgs).toEqual({
|
||||
projectPath,
|
||||
agentId: null,
|
||||
messageId: `direct-codex:${String(directTurnArgs?.clientTurnId ?? '')}:user`,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: '继续修改已有项目',
|
||||
agentId: null,
|
||||
},
|
||||
});
|
||||
expect(directTurnCall).toBeGreaterThanOrEqual(0);
|
||||
expect(invoke).not.toHaveBeenCalledWith(
|
||||
'append_local_conversation_message',
|
||||
expect.anything(),
|
||||
);
|
||||
expect(directTurnArgs?.clientTurnId).toEqual(expect.any(String));
|
||||
expect(invoke).not.toHaveBeenCalledWith(
|
||||
'create_automatic_local_game_project',
|
||||
);
|
||||
@@ -2214,7 +2232,10 @@ export function registerHomeProjectCreationTests() {
|
||||
expect(args).toEqual({ projectPath });
|
||||
return manifest;
|
||||
}
|
||||
if (command === 'read_local_conversation') {
|
||||
if (
|
||||
command === 'read_local_conversation' ||
|
||||
command === 'read_direct_project_conversation'
|
||||
) {
|
||||
return {
|
||||
path: `${projectPath}/.agent/conversations/project.jsonl`,
|
||||
agentId: null,
|
||||
@@ -2257,6 +2278,23 @@ export function registerHomeProjectCreationTests() {
|
||||
};
|
||||
}
|
||||
if (command === 'chat_with_game_creator_direct_codex') {
|
||||
const clientTurnId = String(args?.clientTurnId ?? '');
|
||||
persistedMessages.push(
|
||||
{
|
||||
schemaVersion: 'game-creator-conversation.v1',
|
||||
role: 'user',
|
||||
content: String(args?.prompt ?? ''),
|
||||
messageId: `direct-codex:${clientTurnId}:user`,
|
||||
updatedAt: 1,
|
||||
},
|
||||
{
|
||||
schemaVersion: 'game-creator-conversation.v1',
|
||||
role: 'assistant',
|
||||
content: '陶泥儿智能创作 鉴权失败,请重新登录后重试',
|
||||
messageId: `direct-codex:${clientTurnId}:assistant`,
|
||||
updatedAt: 2,
|
||||
},
|
||||
);
|
||||
throw new Error('codex-app-server-error:unauthorized');
|
||||
}
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
- [LLM 累计额度结算](./technical/【技术方案】LLM累计额度结算-2026-09-05.md):Router 累计额度、首次基线与原子钱包结算。
|
||||
|
||||
- [AI 游戏创作智能体 App 实施计划](./technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md):当前 DirectProject、受控语义工具、UI workflow、资源和运行时合同。
|
||||
- [DirectProject Codex 原始历史与异常恢复](./technical/【技术方案】DirectProject%20Codex原始历史与异常恢复-2026-09-04.md):原始 Responses item 持久化、线程注入与异常回合收尾。
|
||||
- [DirectProject 客户端 Skill 与 MCP 扩展导入方案](./technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md):客户端扩展导入、按独立 Skill/MCP 拆分、命名、启用和启动时注入边界。
|
||||
- [AGC 客户端更新检查与下载](./technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md):启动版本检测、OSS 清单格式和下载约定。
|
||||
- [DirectProject 本轮附件路径映射](./technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md):Direct 首轮只映射附件原名与项目相对路径,不灌正文、不区别 GDD。
|
||||
|
||||
@@ -7961,6 +7961,12 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
|
||||
- 新建/恢复 ephemeral Codex thread 时,replay 使用 `contextWindowTokens`、`autoCompactTokenLimit`、本次 `maxOutputTokens` 与 4096 安全余量计算预算,从最新记录向前选择连续完整的 `user` / `assistant` / `tool` 行;超预算旧前缀被省略,单条记录不截断,当前 user request 始终保留。
|
||||
- 发生省略时在 prompt 开头加入普通 `system: Earlier conversation history was omitted due to context budget.` 提示;当前请求本身超过硬上下文预算则直接失败。该策略是 Direct 专用滑动窗口,不复用 Runtime Agent 的摘要、tail 或 session compaction 生命周期。
|
||||
|
||||
## 2026-09-07 DirectProject 用户消息由 AGC 预写并过滤 Codex 回显
|
||||
|
||||
- `.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 规则落盘。
|
||||
- 本地 AGC user-item 写入必须使用允许 user item 的内部入口,Codex raw item 写入使用过滤入口,避免“过滤回显”反过来阻断预写。相同 `clientTurnId` 只能复用相同规范化 prompt,内容冲突必须失败关闭。
|
||||
|
||||
## 2026-08-31 AGC 错误报告与诊断上传
|
||||
|
||||
- AGC 采用 IDEA 风格的当前进程错误池:按 fingerprint 合并 React / window / Promise / Tauri / Agent 错误,重启后不恢复,不使用 run 或 run_id。
|
||||
|
||||
@@ -22,14 +22,15 @@
|
||||
AI 游戏创作 / DirectProject / UI workflow:
|
||||
|
||||
1. `docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`
|
||||
2. `docs/technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md`
|
||||
3. `docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md`
|
||||
4. `docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md`
|
||||
5. `docs/technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md`
|
||||
6. `docs/technical/【技术方案】Direct回合行为审计账本-2026-08-31.md`
|
||||
7. `docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md`
|
||||
8. `docs/【技术方案】UI工作流资源桥接与Runtime执行-2026-08-24.md`
|
||||
9. UI 编辑器、宿主壳和当前测试专题文档
|
||||
2. `docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md`
|
||||
3. `docs/technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md`
|
||||
4. `docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md`
|
||||
5. `docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md`
|
||||
6. `docs/technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md`
|
||||
7. `docs/technical/【技术方案】Direct回合行为审计账本-2026-08-31.md`
|
||||
8. `docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md`
|
||||
9. `docs/【技术方案】UI工作流资源桥接与Runtime执行-2026-08-24.md`
|
||||
10. UI 编辑器、宿主壳和当前测试专题文档
|
||||
|
||||
图片画布 / 媒体生成:
|
||||
|
||||
|
||||
@@ -238,7 +238,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 事实源;每个 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。
|
||||
- DirectProject replay:Codex thread 仍保持 `ephemeral=true`。`.agent/conversations/project.jsonl` 是聊天唯一、append-only 事实源;AGC 是 DirectProject 用户消息的唯一持久化来源:每个 GUI turn 在发起 app-server turn 前,先把渲染后的规范化 user prompt 以 `direct-codex:{clientTurnId}:user` 幂等追加,持久化失败则不发起 turn 并公开 failed;LLM 失败 / 中断时保留该 user 记录,重试同一 `clientTurnId` 只复用它。Codex 回显的 `userMessage` / `role=user` item 只用于事件观察和关联校验,不得再次追加到项目 JSONL,避免把服务端 echo 当作第二条用户消息;本地 user item 写入与 Codex echo 过滤必须区分来源,不能用同一个“忽略 user item”入口阻断 AGC 自己的预写。仅当 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` 或静默继续。
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# DirectProject Codex 原始历史与异常恢复
|
||||
|
||||
更新时间:`2026-09-07`
|
||||
|
||||
## 目标
|
||||
|
||||
DirectProject 只使用 `.agent/conversations/project.jsonl` 作为对话历史。历史保存 Codex Responses API 的完整 item,使聊天展示与新线程恢复使用同一份事实来源;两者只是不同读取动作。
|
||||
|
||||
本方案只适用于 DirectProject,不改变 DirectHome、Agent session 历史或 `runtime/direct-codex/turns` 审计账本。
|
||||
|
||||
## 文件格式
|
||||
|
||||
每行采用 Codex CLI rollout 的最小事件外壳,不保存 AGC 自有的顺序号或运行环境字段:
|
||||
|
||||
```json
|
||||
{"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 事件。
|
||||
|
||||
DirectProject 不迁移旧 `{role,content}` 行;实现按新格式工作。
|
||||
|
||||
## 正常回合
|
||||
|
||||
1. 启动 `ephemeral: true` 线程,并启用 `experimentalRawEvents: true`。
|
||||
2. 新线程先把历史 item 数组一次注入;注入成功后执行新的 `turn/start`。本轮用户 item 只接受 Codex 回传的 `rawResponseItem/completed`,不由 AGC 预写。
|
||||
3. 收到 `rawResponseItem/completed` 后立即追加其 `params.item` 并 flush。
|
||||
4. 正常 `turn/completed: completed` 不生成额外记录。
|
||||
|
||||
## 异常回合收尾
|
||||
|
||||
AGC 判定本轮不会再产生新事件时收尾:用户中断、turn failed、无响应/idle timeout、硬超时、transport closed、stdout EOF 或 app-server 卡死终止均属于异常终态;正常 completed 不收尾。
|
||||
|
||||
`item/agentMessage/delta` 正常带有 `itemId`;若协议异常缺失,AGC 记录 warning 并按当前 turn 生成稳定回退 id。AGC 在内存中按该 id 累计 assistant 文本,不实时写 delta。异常终态时,对仍有累计文本的 item 合成普通 Responses assistant `message` item:
|
||||
|
||||
```json
|
||||
{"type":"response_item","payload":{"type":"message","role":"assistant","id":"msg_1","content":[{"type":"output_text","text":"已累计文本"}]}}
|
||||
```
|
||||
|
||||
合成 item 在返回错误、销毁连接或启动恢复线程前追加并 flush。没有文本 delta 的半截工具/MCP 调用不合成,等待完整 `rawResponseItem/completed`。
|
||||
|
||||
`rawResponseItem/completed` 缺少 `item`(包括 `null`)时视为反序列化错误;该回合按异常终态收尾,历史中不会写入非法空 item。
|
||||
|
||||
Codex 启动时注入的 `host_skills.instructions`、`permissions.instructions` 和 `environments.environment_context` item 不属于项目对话历史;落盘时过滤,读取和线程注入时也过滤。过滤同时识别 role=user 的完整上下文标签包裹文本,即使该 item 没有 `internal_chat_message_metadata_passthrough` 元数据,也不能把它当成用户回合。
|
||||
|
||||
## 恢复
|
||||
|
||||
创建新的 ephemeral thread 后,读取 `project.jsonl` 中所有 `response_item.payload`,按文件行顺序一次调用 `thread/inject_items`,再执行新的 `turn/start`。Codex 负责上下文窗口管理;注入失败直接失败,AGC 不截断、摘要或改写历史。新 thread 已进入连接池但历史读取或注入失败时,必须先从池中淘汰并取消订阅该 thread,重试只能创建新 thread 并重新注入。
|
||||
|
||||
`clientUserMessageId` 仅作为 Codex 用户消息的稳定标识随 `turn/start` 发送,不等价于 turn 级 exactly-once 幂等。断线后的重试仍须由项目侧持久化 turn ledger 或服务端去重合同决定,不能仅凭该字段再次执行。
|
||||
|
||||
聊天界面只从 message item 提取 user/assistant 内容;工具 item 不再拼成 `tool: ...` 假文本。
|
||||
|
||||
DirectProject 的浏览器层只负责显示和乐观状态,不再调用通用对话写入器;Rust 是该历史文件的唯一写入方。历史读写与回合累计分别位于 `agent/direct_project_history.rs` 和 `agent/direct_project_turn_history.rs`。
|
||||
|
||||
## 写入与损坏边界
|
||||
|
||||
写入使用 `write_all + flush`。读取时允许丢弃文件末尾一条不完整 JSON 行;中间坏行直接失败。不会对旧格式做迁移或兼容。
|
||||
Reference in New Issue
Block a user
P1:这里仍会把 Codex 0.147.0 产生的内部 contextual user item 当成项目用户消息。该版本的上下文构造使用
role: user,且internal_chat_message_metadata_passthrough为空;它不会命中前面的 role 或content_item_kinds过滤,随后会被追加、展示并在新 thread 中注入,污染对话历史。请按真实来源/元数据区分上下文 user item 与用户 turn,并补一个 0.147.0 形状的回归测试。