拆分 DirectProject 历史收尾并停用浏览器写入
DirectProject 历史累计逻辑拆到独立回合模块 浏览器仅显示项目对话,Rust 负责原始 item 持久化 拒绝空 rawResponseItem 并更新测试适配
This commit is contained in:
@@ -15,6 +15,7 @@ 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;
|
||||
@@ -40,6 +41,7 @@ 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::*;
|
||||
|
||||
@@ -2299,9 +2299,12 @@ impl CodexAppServerConnection {
|
||||
}
|
||||
}
|
||||
Some(CodexTurnEvent::RawItem(item)) => {
|
||||
if self.inner.workspace_mode
|
||||
== CodexAppServerWorkspaceMode::DirectProject
|
||||
{
|
||||
if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
|
||||
if item.is_null() {
|
||||
return Err(platform_llm::LlmError::Deserialize(
|
||||
"rawResponseItem/completed 缺少 item".to_string(),
|
||||
));
|
||||
}
|
||||
append_direct_project_history_item_at(history_root, &item)
|
||||
.map_err(platform_llm::LlmError::Transport)?;
|
||||
direct_project_history.complete_item(&item);
|
||||
@@ -3353,125 +3356,27 @@ pub(in crate::agent) fn shutdown_game_creator_codex_app_servers_impl() -> Result
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn build_direct_codex_history_prompt(
|
||||
root: &Path,
|
||||
_client_turn_id: &str,
|
||||
current_prompt: &str,
|
||||
_request: &LlmRunRequest,
|
||||
_llm: &GameCreatorLlmConfig,
|
||||
) -> Result<String, String> {
|
||||
let mut lines = read_direct_project_chat_history_at(root)?
|
||||
.messages
|
||||
.into_iter()
|
||||
.map(|message| format!("{}: {}", message.role, message.content))
|
||||
.collect::<Vec<_>>();
|
||||
lines.push(format!("user: {}", current_prompt.trim()));
|
||||
Ok(lines.join("\n"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/* legacy text replay tests removed with the prompt replay implementation
|
||||
#[test]
|
||||
fn direct_history_prompt_replays_all_project_messages_in_order() {
|
||||
let root = tempfile::tempdir().expect("temp dir");
|
||||
init_local_game_project_at(root.path(), "history-project", "history").expect("init");
|
||||
for (role, content, message_id) in [
|
||||
("user", "hello", "direct-codex:old:user"),
|
||||
(
|
||||
"assistant",
|
||||
"partial\nunexpected interrupt happened here",
|
||||
"partial-id",
|
||||
),
|
||||
("tool", "file-read result", "tool-id"),
|
||||
("user", "stored raw request", "direct-codex:new-turn:user"),
|
||||
] {
|
||||
append_local_conversation_message_for_session_idempotent_at(
|
||||
root.path(),
|
||||
None,
|
||||
None,
|
||||
LocalConversationMessage {
|
||||
role: role.to_string(),
|
||||
content: content.to_string(),
|
||||
agent_id: None,
|
||||
},
|
||||
message_id,
|
||||
)
|
||||
.expect("append history");
|
||||
}
|
||||
let request = LlmRunRequest::single_turn("system", "new request")
|
||||
.with_model("fixture-model")
|
||||
.with_max_output_tokens(16_000);
|
||||
let prompt = build_direct_codex_history_prompt(
|
||||
root.path(),
|
||||
"new-turn",
|
||||
"new request",
|
||||
&request,
|
||||
&test_llm(),
|
||||
)
|
||||
.expect("build prompt");
|
||||
assert_eq!(
|
||||
prompt,
|
||||
"user: hello\nassistant: partial\nunexpected interrupt happened here\ntool: file-read result\nuser: new request"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_history_prompt_slides_old_prefix_when_budget_is_exceeded() {
|
||||
let root = tempfile::tempdir().expect("temp dir");
|
||||
init_local_game_project_at(root.path(), "window-project", "window").expect("init");
|
||||
for (role, content, message_id) in [
|
||||
("user", "old ".repeat(1_000), "old-user"),
|
||||
("assistant", "middle ".repeat(100), "middle-assistant"),
|
||||
("tool", "newest ".repeat(100), "newest-tool"),
|
||||
] {
|
||||
append_local_conversation_message_for_session_idempotent_at(
|
||||
root.path(),
|
||||
None,
|
||||
None,
|
||||
LocalConversationMessage {
|
||||
role: role.to_string(),
|
||||
content,
|
||||
agent_id: None,
|
||||
},
|
||||
message_id,
|
||||
)
|
||||
.expect("append history");
|
||||
}
|
||||
let mut llm = test_llm();
|
||||
llm.auto_compact_token_limit = 800;
|
||||
let request = LlmRunRequest::single_turn("system", "new request")
|
||||
.with_model("fixture-model")
|
||||
.with_max_output_tokens(16_000);
|
||||
let before =
|
||||
std::fs::read_to_string(root.path().join(".agent/conversations/project.jsonl"))
|
||||
.expect("read history");
|
||||
let prompt = build_direct_codex_history_prompt(
|
||||
root.path(),
|
||||
"new-turn",
|
||||
"new request",
|
||||
&request,
|
||||
&llm,
|
||||
)
|
||||
.expect("build prompt");
|
||||
let after = std::fs::read_to_string(root.path().join(".agent/conversations/project.jsonl"))
|
||||
.expect("read history");
|
||||
|
||||
assert!(prompt.starts_with(DIRECT_CODEX_REPLAY_OMISSION_MARKER));
|
||||
assert!(prompt.contains("tool: newest"));
|
||||
assert!(!prompt.contains("user: old"));
|
||||
assert!(prompt.ends_with("user: new request"));
|
||||
assert_eq!(before, after);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_history_prompt_rejects_current_request_that_exceeds_context() {
|
||||
let root = tempfile::tempdir().expect("temp dir");
|
||||
init_local_game_project_at(root.path(), "oversized-project", "oversized").expect("init");
|
||||
let mut llm = test_llm();
|
||||
llm.context_window_tokens = 5_000;
|
||||
llm.auto_compact_token_limit = 1_000;
|
||||
let request = LlmRunRequest::single_turn("system", "new request")
|
||||
.with_model("fixture-model")
|
||||
.with_max_output_tokens(100);
|
||||
let error = build_direct_codex_history_prompt(
|
||||
root.path(),
|
||||
"new-turn",
|
||||
&"request ".repeat(10_000),
|
||||
&request,
|
||||
&llm,
|
||||
)
|
||||
.expect_err("oversized request should fail");
|
||||
assert!(error.contains("Direct replay 请求"));
|
||||
}
|
||||
*/
|
||||
|
||||
#[test]
|
||||
fn direct_item_activities_are_closed_safe_categories() {
|
||||
let allowed = [
|
||||
|
||||
@@ -1,56 +1,15 @@
|
||||
use super::*;
|
||||
use crate::project::{append_jsonl_line_unlocked, enforce_project_permission_policy, project_append_lock_for};
|
||||
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::collections::BTreeMap;
|
||||
use std::fs::File;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const DIRECT_PROJECT_HISTORY_RECORD_TYPE: &str = "response_item";
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn take_partial_items(&mut self) -> Vec<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}],
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn persist_direct_project_partial_items_at(
|
||||
root: &Path,
|
||||
accumulator: &mut DirectProjectHistoryAccumulator,
|
||||
) -> Result<(), String> {
|
||||
for item in accumulator.take_partial_items() {
|
||||
append_direct_project_history_item_at(root, &item)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn history_path(root: &Path) -> PathBuf {
|
||||
root.join(".agent/conversations/project.jsonl")
|
||||
}
|
||||
@@ -74,9 +33,10 @@ pub(crate) fn append_direct_project_history_item_at(
|
||||
let lock = project_append_lock_for(&path)?;
|
||||
let _append_guard = lock.lock("DirectProject 历史追加写")?;
|
||||
if let Some(item_id) = item.get("id").and_then(Value::as_str) {
|
||||
if let Some(existing) = read_direct_project_history_items_at(root)?.into_iter().find(|existing| {
|
||||
existing.get("id").and_then(Value::as_str) == Some(item_id)
|
||||
}) {
|
||||
if let Some(existing) = read_direct_project_history_items_at(root)?
|
||||
.into_iter()
|
||||
.find(|existing| existing.get("id").and_then(Value::as_str) == Some(item_id))
|
||||
{
|
||||
if &existing == item {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -119,9 +79,7 @@ pub(crate) fn direct_project_user_message_item(prompt: &str) -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn read_direct_project_history_items_at(
|
||||
root: &Path,
|
||||
) -> Result<Vec<Value>, String> {
|
||||
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());
|
||||
@@ -153,8 +111,7 @@ pub(crate) fn read_direct_project_history_items_at(
|
||||
));
|
||||
}
|
||||
};
|
||||
if parsed.get("type").and_then(Value::as_str) != Some(DIRECT_PROJECT_HISTORY_RECORD_TYPE)
|
||||
{
|
||||
if parsed.get("type").and_then(Value::as_str) != Some(DIRECT_PROJECT_HISTORY_RECORD_TYPE) {
|
||||
return Err(format!(
|
||||
"DirectProject 历史记录类型无效:{}",
|
||||
path.display()
|
||||
@@ -197,10 +154,7 @@ pub(crate) fn read_direct_project_chat_history_at(
|
||||
role: role.to_string(),
|
||||
content,
|
||||
agent_id: None,
|
||||
message_id: item
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string),
|
||||
message_id: item.get("id").and_then(Value::as_str).map(str::to_string),
|
||||
updated_at: 0,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
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> {
|
||||
for item in accumulator.take_partial_items() {
|
||||
append_direct_project_history_item_at(root, &item)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -4327,8 +4327,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
|
||||
@@ -4341,7 +4341,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 冲突")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4358,8 +4358,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
|
||||
@@ -4372,7 +4372,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 冲突")
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -495,11 +495,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,
|
||||
@@ -510,10 +505,6 @@ export function App({
|
||||
lastSequence: number;
|
||||
receivedDirectUpdate: boolean;
|
||||
} | 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
|
||||
@@ -1675,6 +1666,12 @@ 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) {
|
||||
savedConversationCountRef.current = messages.length;
|
||||
return;
|
||||
}
|
||||
const start = savedConversationCountRef.current;
|
||||
const pendingMessages = messages.slice(start);
|
||||
if (pendingMessages.length === 0) {
|
||||
@@ -1745,7 +1742,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()) {
|
||||
@@ -1760,56 +1756,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;
|
||||
}
|
||||
@@ -1824,12 +1786,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)
|
||||
@@ -1859,6 +1819,7 @@ export function App({
|
||||
conversationWriteVersion,
|
||||
pendingUiConfirmation,
|
||||
projectSupervisorOnly,
|
||||
directCodexProductRuntime,
|
||||
]);
|
||||
|
||||
function appendLocalPermissionLog(
|
||||
@@ -5350,20 +5311,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[],
|
||||
@@ -5387,41 +5373,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,
|
||||
@@ -5436,36 +5387,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;
|
||||
@@ -5487,28 +5408,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) =>
|
||||
@@ -5520,9 +5424,6 @@ export function App({
|
||||
}
|
||||
} catch (error) {
|
||||
if (isDirectCodexTurnAlreadyRunningError(error)) {
|
||||
recoveredDirectCodexTurnClaimsRef.current.delete(
|
||||
recoveredDirectCodexTurnClaimKey,
|
||||
);
|
||||
if (localProjectPathRef.current === directProjectPath) {
|
||||
clearDirectCodexTransientReply(directProjectPath, clientTurnId);
|
||||
setProjectSupervisorRuntimeError(
|
||||
@@ -5542,46 +5443,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);
|
||||
setProjectSupervisorRuntimeError(visibleMessage);
|
||||
|
||||
Reference in New Issue
Block a user