Merge branch 'master' into feat/ui-editor-edit-history
This commit is contained in:
@@ -88,10 +88,12 @@ export function AdminErrorReportsPage({ token, onUnauthorized }: Props) {
|
||||
setStatus('');
|
||||
try {
|
||||
const updated = await updateAdminErrorReport(token, selected.batchId, {
|
||||
status: nextStatus,
|
||||
note: selected.note,
|
||||
});
|
||||
setSelected((current) => (current ? { ...current, ...updated } : current));
|
||||
status: nextStatus,
|
||||
note: selected.note,
|
||||
});
|
||||
setSelected((current) =>
|
||||
current ? { ...current, ...updated } : current,
|
||||
);
|
||||
await loadRef.current();
|
||||
} catch (error) {
|
||||
if (isAdminApiError(error) && error.status === 401) onUnauthorized();
|
||||
@@ -244,7 +246,9 @@ export function AdminErrorReportsPage({ token, onUnauthorized }: Props) {
|
||||
value={selected.note ?? ''}
|
||||
onChange={(event) =>
|
||||
setSelected((current) =>
|
||||
current ? { ...current, note: event.target.value } : current,
|
||||
current
|
||||
? { ...current, note: event.target.value }
|
||||
: current,
|
||||
)
|
||||
}
|
||||
maxLength={2000}
|
||||
|
||||
@@ -1943,17 +1943,23 @@ impl CodexAppServerConnection {
|
||||
snapshot: &AgentRuntimeProviderRequestSnapshot,
|
||||
request: &LlmRunRequest,
|
||||
llm: &GameCreatorLlmConfig,
|
||||
) -> Result<CodexThreadLease, platform_llm::LlmError> {
|
||||
) -> Result<(CodexThreadLease, bool), platform_llm::LlmError> {
|
||||
let key = CodexNodeThreadKey::from(snapshot);
|
||||
let mut threads = self.inner.threads.lock().await;
|
||||
// The caller holds `turn_gate` while invoking this method. Returning
|
||||
// the creation bit from the same threads lock keeps the replay
|
||||
// decision atomic with thread reuse/creation.
|
||||
if let Some(entry) = threads.get_mut(&key) {
|
||||
entry.active_uses = entry.active_uses.saturating_add(1);
|
||||
entry.last_used = next_game_creator_codex_app_server_usage_tick();
|
||||
return Ok(CodexThreadLease {
|
||||
connection: self.clone(),
|
||||
key,
|
||||
thread_id: entry.thread_id.clone(),
|
||||
});
|
||||
return Ok((
|
||||
CodexThreadLease {
|
||||
connection: self.clone(),
|
||||
key,
|
||||
thread_id: entry.thread_id.clone(),
|
||||
},
|
||||
false,
|
||||
));
|
||||
}
|
||||
if threads.len() >= GAME_CREATOR_CODEX_APP_SERVER_THREAD_MAX {
|
||||
let evict_key = threads
|
||||
@@ -2015,11 +2021,14 @@ impl CodexAppServerConnection {
|
||||
active_uses: 1,
|
||||
},
|
||||
);
|
||||
Ok(CodexThreadLease {
|
||||
connection: self.clone(),
|
||||
key,
|
||||
thread_id,
|
||||
})
|
||||
Ok((
|
||||
CodexThreadLease {
|
||||
connection: self.clone(),
|
||||
key,
|
||||
thread_id,
|
||||
},
|
||||
true,
|
||||
))
|
||||
}
|
||||
|
||||
async fn register_turn(&self, turn_id: &str) -> mpsc::UnboundedReceiver<CodexTurnEvent> {
|
||||
@@ -2085,11 +2094,60 @@ impl CodexAppServerConnection {
|
||||
mut on_agent_message_delta: Option<&mut (dyn FnMut(&platform_llm::LlmStreamDelta) + Send)>,
|
||||
mut direct_observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>,
|
||||
mut audit: Option<&mut DirectCodexTurnAudit>,
|
||||
) -> Result<platform_llm::LlmRunResponse, platform_llm::LlmError> {
|
||||
self.run_turn_with_direct_observer_and_history(
|
||||
snapshot,
|
||||
llm,
|
||||
request,
|
||||
None,
|
||||
None,
|
||||
on_agent_message_delta,
|
||||
direct_observer,
|
||||
audit,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn run_turn_with_direct_observer_and_history(
|
||||
&self,
|
||||
snapshot: &AgentRuntimeProviderRequestSnapshot,
|
||||
llm: &GameCreatorLlmConfig,
|
||||
request: LlmRunRequest,
|
||||
direct_history_root: Option<&std::path::Path>,
|
||||
direct_client_turn_id: Option<&str>,
|
||||
mut on_agent_message_delta: Option<&mut (dyn FnMut(&platform_llm::LlmStreamDelta) + Send)>,
|
||||
mut direct_observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>,
|
||||
mut audit: Option<&mut DirectCodexTurnAudit>,
|
||||
) -> Result<platform_llm::LlmRunResponse, platform_llm::LlmError> {
|
||||
let _turn_guard = self.inner.turn_gate.lock().await;
|
||||
let thread_lease = self.thread_for(snapshot, &request, llm).await?;
|
||||
let (thread_lease, thread_created) = self.thread_for(snapshot, &request, llm).await?;
|
||||
self.wait_for_initial_client_mcp_startup().await;
|
||||
let thread_id = thread_lease.thread_id.clone();
|
||||
let mut request = request;
|
||||
if thread_created && self.inner.workspace_mode.uses_direct_conversation() {
|
||||
// DirectProject owns the append-only project history in AGC. The
|
||||
// replay builder derives a bounded prompt without mutating that
|
||||
// durable fact source, so a new ephemeral thread can recover the
|
||||
// newest contiguous context within the model budget.
|
||||
let current_prompt = direct_codex_current_user_prompt(&request).to_string();
|
||||
let history_root = direct_history_root.unwrap_or(&self.inner.workspace_path);
|
||||
let history_prompt = build_direct_codex_history_prompt(
|
||||
history_root,
|
||||
direct_client_turn_id.unwrap_or("__none__"),
|
||||
¤t_prompt,
|
||||
&request,
|
||||
llm,
|
||||
)
|
||||
.map_err(platform_llm::LlmError::InvalidRequest)?;
|
||||
if let Some(message) = request
|
||||
.messages
|
||||
.iter_mut()
|
||||
.rev()
|
||||
.find(|message| message.role == LlmMessageRole::User)
|
||||
{
|
||||
message.content = history_prompt;
|
||||
}
|
||||
}
|
||||
let prompt = if self.inner.workspace_mode.uses_direct_conversation() {
|
||||
direct_codex_user_prompt(&request)
|
||||
} else {
|
||||
@@ -3020,6 +3078,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at(
|
||||
user_prompt,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -3034,6 +3093,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_observer(
|
||||
root,
|
||||
system_prompt,
|
||||
user_prompt,
|
||||
None,
|
||||
Some(observer),
|
||||
None,
|
||||
)
|
||||
@@ -3090,6 +3150,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer(
|
||||
root: &std::path::Path,
|
||||
system_prompt: String,
|
||||
user_prompt: String,
|
||||
client_turn_id: Option<&str>,
|
||||
observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>,
|
||||
audit: Option<&mut DirectCodexTurnAudit>,
|
||||
) -> Result<String, String> {
|
||||
@@ -3129,11 +3190,6 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer(
|
||||
};
|
||||
let api_kind =
|
||||
parse_game_creator_llm_api_kind(&config.llm.api_kind).map_err(|error| error.to_string())?;
|
||||
let request = LlmRunRequest::single_turn(system_prompt, user_prompt)
|
||||
.with_api_kind(api_kind)
|
||||
.with_model(config.llm.model.clone())
|
||||
.with_request_timeout_ms(config.llm.request_timeout_ms)
|
||||
.with_max_output_tokens(16_000);
|
||||
let connection = CodexAppServerConnection::acquire_at_workspace(
|
||||
&snapshot,
|
||||
&config.llm,
|
||||
@@ -3142,13 +3198,176 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer(
|
||||
)
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
let request = LlmRunRequest::single_turn(system_prompt, user_prompt)
|
||||
.with_api_kind(api_kind)
|
||||
.with_model(config.llm.model.clone())
|
||||
.with_request_timeout_ms(config.llm.request_timeout_ms)
|
||||
.with_max_output_tokens(16_000);
|
||||
connection
|
||||
.run_turn_with_direct_observer(&snapshot, &config.llm, request, None, observer, audit)
|
||||
.run_turn_with_direct_observer_and_history(
|
||||
&snapshot,
|
||||
&config.llm,
|
||||
request,
|
||||
Some(&codex_root),
|
||||
client_turn_id,
|
||||
None,
|
||||
observer,
|
||||
audit,
|
||||
)
|
||||
.await
|
||||
.map(|value| value.text)
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
/// Builds the prompt used when a new DirectProject thread needs replay.
|
||||
///
|
||||
/// The JSONL history remains immutable; only the derived prompt is bounded.
|
||||
/// When the full request exceeds the replay target, the oldest contiguous
|
||||
/// records are omitted and a plain `system:` marker is prepended.
|
||||
pub(crate) fn build_direct_codex_history_prompt(
|
||||
root: &std::path::Path,
|
||||
client_turn_id: &str,
|
||||
current_prompt: &str,
|
||||
base_request: &LlmRunRequest,
|
||||
llm: &GameCreatorLlmConfig,
|
||||
) -> Result<String, String> {
|
||||
let conversation = read_local_conversation_for_session_at(root, None, None)?;
|
||||
let current_message_id = format!("direct-codex:{client_turn_id}:user");
|
||||
// The deliberately simple role-prefix format is part of the Direct
|
||||
// replay contract. Do not introduce an envelope or implicit escaping
|
||||
// here without updating the persisted-history compatibility decision.
|
||||
let lines = conversation
|
||||
.messages
|
||||
.iter()
|
||||
.filter(|message| message.message_id.as_deref() != Some(current_message_id.as_str()))
|
||||
.map(|message| format!("{}: {}", message.role, message.content))
|
||||
.collect::<Vec<_>>();
|
||||
let current_line = format!("user: {}", current_prompt.trim());
|
||||
let full_prompt = format_direct_codex_replay_prompt(&lines, ¤t_line, None);
|
||||
let target_budget = direct_codex_replay_target_budget(llm, base_request)?;
|
||||
if direct_codex_replay_prompt_fits(base_request, llm, &full_prompt, target_budget)? {
|
||||
return Ok(full_prompt);
|
||||
}
|
||||
|
||||
// The persisted project conversation is immutable. We only derive a
|
||||
// bounded prompt for this replay, keeping the newest contiguous records.
|
||||
let omission_marker = DIRECT_CODEX_REPLAY_OMISSION_MARKER;
|
||||
let mut selected_reversed = Vec::new();
|
||||
for line in lines.iter().rev() {
|
||||
let mut candidate_reversed = selected_reversed.clone();
|
||||
candidate_reversed.push(line.as_str());
|
||||
let candidate_lines = candidate_reversed.iter().rev().copied().collect::<Vec<_>>();
|
||||
let candidate_prompt = format_direct_codex_replay_prompt(
|
||||
&candidate_lines,
|
||||
¤t_line,
|
||||
Some(omission_marker),
|
||||
);
|
||||
if direct_codex_replay_prompt_fits(base_request, llm, &candidate_prompt, target_budget)? {
|
||||
selected_reversed.push(line.as_str());
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let selected_lines = selected_reversed.iter().rev().copied().collect::<Vec<_>>();
|
||||
let marked_prompt =
|
||||
format_direct_codex_replay_prompt(&selected_lines, ¤t_line, Some(omission_marker));
|
||||
if direct_codex_replay_prompt_fits(base_request, llm, &marked_prompt, target_budget)? {
|
||||
return Ok(marked_prompt);
|
||||
}
|
||||
|
||||
// If the marker itself would push the request over the target, preserve
|
||||
// the current user request and omit only the marker.
|
||||
let current_only_prompt =
|
||||
format_direct_codex_replay_prompt(&[] as &[&str], ¤t_line, None);
|
||||
if direct_codex_replay_prompt_fits(base_request, llm, ¤t_only_prompt, target_budget)? {
|
||||
return Ok(current_only_prompt);
|
||||
}
|
||||
direct_codex_replay_validate_context_budget(base_request, llm, ¤t_only_prompt)
|
||||
}
|
||||
|
||||
const DIRECT_CODEX_REPLAY_OMISSION_MARKER: &str =
|
||||
"system: Earlier conversation history was omitted due to context budget.";
|
||||
|
||||
fn format_direct_codex_replay_prompt(
|
||||
history_lines: &[impl AsRef<str>],
|
||||
current_line: &str,
|
||||
omission_marker: Option<&str>,
|
||||
) -> String {
|
||||
let mut lines = Vec::with_capacity(history_lines.len() + 2);
|
||||
if let Some(marker) = omission_marker {
|
||||
lines.push(marker.to_string());
|
||||
}
|
||||
lines.extend(history_lines.iter().map(|line| line.as_ref().to_string()));
|
||||
lines.push(current_line.to_string());
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn direct_codex_replay_target_budget(
|
||||
llm: &GameCreatorLlmConfig,
|
||||
request: &LlmRunRequest,
|
||||
) -> Result<u64, String> {
|
||||
const SAFETY_MARGIN_TOKENS: u64 = 4_096;
|
||||
let max_output_tokens = u64::from(request.max_output_tokens.unwrap_or(0));
|
||||
let hard_budget = llm
|
||||
.context_window_tokens
|
||||
.checked_sub(max_output_tokens)
|
||||
.and_then(|value| value.checked_sub(SAFETY_MARGIN_TOKENS))
|
||||
.ok_or_else(|| "Direct replay 没有可用的输入上下文预算".to_string())?;
|
||||
Ok(llm.auto_compact_token_limit.min(hard_budget))
|
||||
}
|
||||
|
||||
fn direct_codex_replay_estimate(
|
||||
base_request: &LlmRunRequest,
|
||||
prompt: &str,
|
||||
) -> Result<LlmRunRequest, String> {
|
||||
let mut request = base_request.clone();
|
||||
let user = request
|
||||
.messages
|
||||
.iter_mut()
|
||||
.rev()
|
||||
.find(|message| message.role == LlmMessageRole::User)
|
||||
.ok_or_else(|| "Direct replay 请求缺少 user message".to_string())?;
|
||||
user.content = prompt.to_string();
|
||||
Ok(request)
|
||||
}
|
||||
|
||||
fn direct_codex_replay_prompt_fits(
|
||||
base_request: &LlmRunRequest,
|
||||
llm: &GameCreatorLlmConfig,
|
||||
prompt: &str,
|
||||
target_budget: u64,
|
||||
) -> Result<bool, String> {
|
||||
let request = direct_codex_replay_estimate(base_request, prompt)?;
|
||||
let estimated = estimate_game_creator_llm_request_tokens(&request)?;
|
||||
if estimated > target_budget {
|
||||
return Ok(false);
|
||||
}
|
||||
validate_game_creator_llm_request_context_budget(
|
||||
llm,
|
||||
&request,
|
||||
estimated,
|
||||
"Direct replay 请求",
|
||||
)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn direct_codex_replay_validate_context_budget(
|
||||
base_request: &LlmRunRequest,
|
||||
llm: &GameCreatorLlmConfig,
|
||||
prompt: &str,
|
||||
) -> Result<String, String> {
|
||||
let request = direct_codex_replay_estimate(base_request, prompt)?;
|
||||
let estimated = estimate_game_creator_llm_request_tokens(&request)?;
|
||||
validate_game_creator_llm_request_context_budget(
|
||||
llm,
|
||||
&request,
|
||||
estimated,
|
||||
"Direct replay 请求",
|
||||
)?;
|
||||
Ok(prompt.to_string())
|
||||
}
|
||||
|
||||
/// Direct home-page chat never binds Codex to a user project. It gets a
|
||||
/// fresh isolated read-only workspace and a stable in-process thread so a
|
||||
/// normal conversation can continue without creating a project, assets, a
|
||||
@@ -3234,6 +3453,119 @@ pub(in crate::agent) fn shutdown_game_creator_codex_app_servers_impl() -> Result
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[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 = [
|
||||
|
||||
@@ -666,12 +666,27 @@ async fn request_game_creator_agent_codex_cli_with_executable(
|
||||
let mut stdin = child.stdin.take().ok_or_else(|| {
|
||||
platform_llm::LlmError::Transport("Codex CLI Agent stdin 未建立".to_string())
|
||||
})?;
|
||||
stdin.write_all(prompt.as_bytes()).await.map_err(|error| {
|
||||
platform_llm::LlmError::Transport(format!("写入 Codex CLI Agent prompt 失败:{error}"))
|
||||
})?;
|
||||
stdin.shutdown().await.map_err(|error| {
|
||||
platform_llm::LlmError::Transport(format!("关闭 Codex CLI Agent stdin 失败:{error}"))
|
||||
})?;
|
||||
// 短生命周期 CLI 可能在 prompt 写完前退出;继续采集退出状态和脱敏
|
||||
// stderr,确保调用方拿到可行动的进程错误,而不是平台相关的 BrokenPipe。
|
||||
let mut stdin_error = None;
|
||||
if let Err(error) = stdin.write_all(prompt.as_bytes()).await {
|
||||
if error.kind() == std::io::ErrorKind::BrokenPipe {
|
||||
stdin_error = Some(format!("写入 Codex CLI Agent prompt 失败:{error}"));
|
||||
} else {
|
||||
return Err(platform_llm::LlmError::Transport(format!(
|
||||
"写入 Codex CLI Agent prompt 失败:{error}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
if let Err(error) = stdin.shutdown().await {
|
||||
if error.kind() == std::io::ErrorKind::BrokenPipe {
|
||||
stdin_error.get_or_insert_with(|| format!("关闭 Codex CLI Agent stdin 失败:{error}"));
|
||||
} else {
|
||||
return Err(platform_llm::LlmError::Transport(format!(
|
||||
"关闭 Codex CLI Agent stdin 失败:{error}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
drop(stdin);
|
||||
|
||||
let stdout = child.stdout.take().ok_or_else(|| {
|
||||
@@ -744,6 +759,9 @@ async fn request_game_creator_agent_codex_cli_with_executable(
|
||||
stderr.sha256
|
||||
)));
|
||||
}
|
||||
if let Some(error) = stdin_error {
|
||||
return Err(platform_llm::LlmError::Transport(error));
|
||||
}
|
||||
parse_game_creator_codex_cli_response(&stdout, &request)
|
||||
}
|
||||
|
||||
|
||||
@@ -3835,6 +3835,7 @@ async fn run_direct_game_creator_turn_inner(
|
||||
DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error)
|
||||
})?;
|
||||
let reply = if let Some(emitter) = turn_emitter {
|
||||
let client_turn_id = emitter.turn_id().to_string();
|
||||
let emitter = emitter.clone();
|
||||
let mut has_streamed = false;
|
||||
let mut latest_accumulated_text = None;
|
||||
@@ -3856,6 +3857,7 @@ async fn run_direct_game_creator_turn_inner(
|
||||
root,
|
||||
system_prompt,
|
||||
prompt.to_string(),
|
||||
Some(&client_turn_id),
|
||||
Some(&mut observer),
|
||||
audit,
|
||||
)
|
||||
@@ -3866,6 +3868,7 @@ async fn run_direct_game_creator_turn_inner(
|
||||
system_prompt,
|
||||
prompt.to_string(),
|
||||
None,
|
||||
None,
|
||||
audit,
|
||||
)
|
||||
.await
|
||||
@@ -4189,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,
|
||||
@@ -4220,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,
|
||||
@@ -4261,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!(
|
||||
@@ -4326,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 {
|
||||
|
||||
@@ -93,6 +93,10 @@ impl DirectGameCreatorTurnUpdateEmitter {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn turn_id(&self) -> &str {
|
||||
&self.turn_id
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn start_game_creator_manifest_invalidation_event_sink(
|
||||
|
||||
@@ -297,56 +297,6 @@ function directCodexConversationMessageId(
|
||||
return `${DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX}${turnId}:${role}`;
|
||||
}
|
||||
|
||||
function directCodexConversationTurnId(
|
||||
message: ChatMessage,
|
||||
role: ChatMessage['role'],
|
||||
) {
|
||||
if (message.role !== role) {
|
||||
return null;
|
||||
}
|
||||
const messageId = message.messageId?.trim() ?? '';
|
||||
const roleSuffix = `:${role}`;
|
||||
if (
|
||||
!messageId.startsWith(DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX) ||
|
||||
!messageId.endsWith(roleSuffix)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const turnId = messageId.slice(
|
||||
DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX.length,
|
||||
-roleSuffix.length,
|
||||
);
|
||||
return /^[a-z0-9][a-z0-9-]{5,159}$/iu.test(turnId) ? turnId : null;
|
||||
}
|
||||
|
||||
export function unansweredDirectCodexConversationTurn(messages: ChatMessage[]) {
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const message = messages[index];
|
||||
if (!message) {
|
||||
continue;
|
||||
}
|
||||
const turnId = directCodexConversationTurnId(message, 'user');
|
||||
if (!turnId) {
|
||||
continue;
|
||||
}
|
||||
const assistantMessageId = directCodexConversationMessageId(
|
||||
turnId,
|
||||
'assistant',
|
||||
);
|
||||
if (
|
||||
messages.some(
|
||||
(candidate) =>
|
||||
candidate.role === 'assistant' &&
|
||||
candidate.messageId?.trim() === assistantMessageId,
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
return { prompt: message.text, turnId };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isDirectCodexTurnAlreadyRunningError(error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return message
|
||||
@@ -544,6 +494,12 @@ export function App({
|
||||
useState<number | null>(null);
|
||||
const [directCodexTransientReply, setDirectCodexTransientReply] =
|
||||
useState('');
|
||||
const directCodexTransientReplyRef = useRef('');
|
||||
const directCodexInterruptedPartialRef = useRef<{
|
||||
projectPath: string;
|
||||
text: string;
|
||||
messageId: string;
|
||||
} | null>(null);
|
||||
const [
|
||||
directCodexTransientReplyUpdatedAt,
|
||||
setDirectCodexTransientReplyUpdatedAt,
|
||||
@@ -583,6 +539,7 @@ export function App({
|
||||
setDirectCodexProgress('');
|
||||
setDirectCodexProgressUpdatedAt(null);
|
||||
setDirectCodexTransientReply('');
|
||||
directCodexTransientReplyRef.current = '';
|
||||
setDirectCodexTransientReplyUpdatedAt(null);
|
||||
}
|
||||
|
||||
@@ -596,6 +553,7 @@ export function App({
|
||||
}
|
||||
activeDirectCodexTurnRef.current = null;
|
||||
setDirectCodexTransientReply('');
|
||||
directCodexTransientReplyRef.current = '';
|
||||
setDirectCodexTransientReplyUpdatedAt(null);
|
||||
return true;
|
||||
}
|
||||
@@ -1234,10 +1192,10 @@ export function App({
|
||||
: Date.now();
|
||||
if (payload.status === 'failed') {
|
||||
activeDirectCodexTurnRef.current = null;
|
||||
setDirectCodexProgress('处理失败,正在同步错误');
|
||||
setDirectCodexProgressUpdatedAt(updatedAt);
|
||||
setDirectCodexTransientReply('');
|
||||
setDirectCodexTransientReplyUpdatedAt(null);
|
||||
setDirectCodexProgress('处理失败,正在同步错误');
|
||||
setDirectCodexProgressUpdatedAt(updatedAt);
|
||||
return;
|
||||
}
|
||||
if (payload.status === 'completed') {
|
||||
@@ -1249,6 +1207,7 @@ export function App({
|
||||
}
|
||||
if (typeof payload.accumulatedText === 'string') {
|
||||
setDirectCodexTransientReply(payload.accumulatedText);
|
||||
directCodexTransientReplyRef.current = payload.accumulatedText;
|
||||
setDirectCodexTransientReplyUpdatedAt(updatedAt);
|
||||
}
|
||||
},
|
||||
@@ -1844,9 +1803,8 @@ export function App({
|
||||
);
|
||||
if (localProjectPathRef.current === nextProjectPath) {
|
||||
// Any history read started before this terminal append may hold
|
||||
// a user-only snapshot. Invalidate it before releasing the
|
||||
// in-memory claim so that stale hydration cannot replay the
|
||||
// same billable Direct turn.
|
||||
// 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);
|
||||
@@ -2702,9 +2660,6 @@ export function App({
|
||||
projectConversation.messages,
|
||||
supervisorConversation?.messages ?? [],
|
||||
);
|
||||
const unansweredDirectTurn = directCodexProductRuntime
|
||||
? unansweredDirectCodexConversationTurn(conversationMessages)
|
||||
: null;
|
||||
if (
|
||||
conversationContainsProjectSupervisorResponseStream(
|
||||
supervisorConversation?.messages ?? [],
|
||||
@@ -2756,24 +2711,6 @@ export function App({
|
||||
});
|
||||
return nextConversationMessages;
|
||||
});
|
||||
if (unansweredDirectTurn) {
|
||||
const claimKey = `${nextProjectPath}\u0000${unansweredDirectTurn.turnId}`;
|
||||
void Promise.resolve().then(() => {
|
||||
if (
|
||||
projectSupervisorHistoryLoadVersionRef.current !== loadVersion ||
|
||||
localProjectPathRef.current !== nextProjectPath ||
|
||||
activeDirectCodexTurnRef.current ||
|
||||
recoveredDirectCodexTurnClaimsRef.current.has(claimKey)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
recoveredDirectCodexTurnClaimsRef.current.add(claimKey);
|
||||
void executeChatAgentReply({
|
||||
prompt: unansweredDirectTurn.prompt,
|
||||
clientTurnId: unansweredDirectTurn.turnId,
|
||||
});
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
projectSupervisorHistoryLoadVersionRef.current !== loadVersion ||
|
||||
@@ -5464,6 +5401,23 @@ export function App({
|
||||
},
|
||||
},
|
||||
);
|
||||
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,
|
||||
@@ -5478,26 +5432,40 @@ export function App({
|
||||
setDirectCodexProgress('已发送消息,正在等待陶泥儿回复');
|
||||
setDirectCodexProgressUpdatedAt(Date.now());
|
||||
setDirectCodexTransientReply('');
|
||||
directCodexTransientReplyRef.current = '';
|
||||
setDirectCodexTransientReplyUpdatedAt(null);
|
||||
setProjectSupervisorRuntimeError('');
|
||||
try {
|
||||
// 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,
|
||||
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;
|
||||
@@ -5531,10 +5499,10 @@ export function App({
|
||||
}
|
||||
}
|
||||
// Rust persists a successful Direct reply before returning Ok. The
|
||||
// browser append is redundant, so the hydrated-turn claim can be
|
||||
// browser append is redundant, so the in-memory turn claim can be
|
||||
// released without reopening the Provider side effect. Invalidate
|
||||
// any user-only history snapshot captured before Rust committed the
|
||||
// terminal reply first.
|
||||
// any history snapshot captured before Rust committed the terminal
|
||||
// reply first.
|
||||
if (localProjectPathRef.current === directProjectPath) {
|
||||
projectSupervisorHistoryLoadVersionRef.current += 1;
|
||||
}
|
||||
@@ -5574,6 +5542,23 @@ 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) {
|
||||
|
||||
@@ -2314,303 +2314,6 @@ export function registerHomeProjectCreationTests() {
|
||||
),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('releases a hydrated Direct Codex turn claim after an in-progress rejection so the same App can resume it later', async () => {
|
||||
const projectPath =
|
||||
'C:\\Users\\tester\\Documents\\Genarrative GameAgent\\running-direct-project';
|
||||
const manifest = createGameCreationAppManifest(
|
||||
'running-direct-project',
|
||||
'运行中直连项目',
|
||||
);
|
||||
const stableTurnId = 'stable-running-turn-001';
|
||||
let directTurnCallCount = 0;
|
||||
const persistedMessages: Array<Record<string, unknown>> = [
|
||||
{
|
||||
schemaVersion: 'game-creator-conversation.v1',
|
||||
role: 'user',
|
||||
content: '继续完成运行中的项目',
|
||||
agentId: null,
|
||||
messageId: `direct-codex:${stableTurnId}:user`,
|
||||
updatedAt: 1,
|
||||
},
|
||||
];
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'get_local_game_manifest') {
|
||||
return manifest;
|
||||
}
|
||||
if (command === 'read_local_conversation') {
|
||||
return {
|
||||
path: `${projectPath}/.agent/conversations/project.jsonl`,
|
||||
agentId: null,
|
||||
sessionId: null,
|
||||
messages: [...persistedMessages],
|
||||
};
|
||||
}
|
||||
if (command === 'append_local_permission_log') {
|
||||
return {};
|
||||
}
|
||||
if (command === 'read_project_permission_policy') {
|
||||
return {
|
||||
path: '.agent/policy.json',
|
||||
policy: { deniedCommands: [], confirmCommands: [] },
|
||||
};
|
||||
}
|
||||
if (command === 'append_local_conversation_message') {
|
||||
const message = args?.message as Record<string, unknown>;
|
||||
const messageId = String(args?.messageId ?? '');
|
||||
if (
|
||||
!messageId ||
|
||||
!persistedMessages.some(
|
||||
(candidate) => candidate.messageId === messageId,
|
||||
)
|
||||
) {
|
||||
persistedMessages.push({
|
||||
schemaVersion: 'game-creator-conversation.v1',
|
||||
...message,
|
||||
messageId,
|
||||
updatedAt: Number(
|
||||
message.updatedAt ?? persistedMessages.length + 1,
|
||||
),
|
||||
});
|
||||
}
|
||||
return {
|
||||
path: `${projectPath}/.agent/conversations/project.jsonl`,
|
||||
agentId: null,
|
||||
sessionId: null,
|
||||
messages: [...persistedMessages],
|
||||
};
|
||||
}
|
||||
if (command === 'chat_with_game_creator_direct_codex') {
|
||||
directTurnCallCount += 1;
|
||||
if (directTurnCallCount === 1) {
|
||||
throw new Error(
|
||||
'direct-codex-turn-already-running: 当前 Direct 客户端回合仍在运行',
|
||||
);
|
||||
}
|
||||
return '恢复后的最终回复';
|
||||
}
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
},
|
||||
);
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
|
||||
render(
|
||||
React.createElement(App, {
|
||||
initialProjectPath: projectPath,
|
||||
initialProjectManifest: manifest,
|
||||
projectSupervisorOnly: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(await screen.findByText('继续完成运行中的项目')).not.toBeNull();
|
||||
await waitFor(() => {
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
'chat_with_game_creator_direct_codex',
|
||||
{
|
||||
projectPath,
|
||||
prompt: '继续完成运行中的项目',
|
||||
clientTurnId: stableTurnId,
|
||||
},
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(persistedMessages).toHaveLength(1);
|
||||
expect(
|
||||
invoke.mock.calls.filter(
|
||||
([command, args]) =>
|
||||
command === 'append_local_conversation_message' &&
|
||||
(args as Record<string, unknown> | undefined)?.messageId ===
|
||||
`direct-codex:${stableTurnId}:assistant`,
|
||||
),
|
||||
).toHaveLength(0);
|
||||
expect(screen.queryByText(/direct-codex-turn-already-running/)).toBeNull();
|
||||
|
||||
const directComposer = screen.getByLabelText('陶泥儿对话内容');
|
||||
fireEvent.change(directComposer, { target: { value: '/history' } });
|
||||
fireEvent.submit(directComposer.closest('form') as HTMLFormElement);
|
||||
|
||||
expect(await screen.findByText('恢复后的最终回复')).not.toBeNull();
|
||||
await waitFor(() => {
|
||||
expect(directTurnCallCount).toBe(2);
|
||||
expect(
|
||||
persistedMessages.filter(
|
||||
(message) =>
|
||||
message.messageId === `direct-codex:${stableTurnId}:assistant`,
|
||||
),
|
||||
).toEqual([
|
||||
expect.objectContaining({
|
||||
role: 'assistant',
|
||||
content: '恢复后的最终回复',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
expect(
|
||||
invoke.mock.calls.filter(
|
||||
([command]) => command === 'read_local_conversation',
|
||||
).length,
|
||||
).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
caseName: 'a successful reply',
|
||||
firstError: null,
|
||||
firstReply: '首次成功回复',
|
||||
firstVisibleText: '首次成功回复',
|
||||
},
|
||||
{
|
||||
caseName: 'an ordinary error reply',
|
||||
firstError: 'codex-app-server-error:unauthorized',
|
||||
firstReply: null,
|
||||
firstVisibleText: '陶泥儿智能创作 鉴权失败,请检查 API Key 或登录态',
|
||||
},
|
||||
])(
|
||||
'reconciles a hydrated Direct Codex claim after persisting $caseName fails',
|
||||
async ({ firstError, firstReply, firstVisibleText }) => {
|
||||
const projectPath =
|
||||
'C:\\Users\\tester\\Documents\\Genarrative GameAgent\\retry-terminal-persistence';
|
||||
const manifest = createGameCreationAppManifest(
|
||||
'retry-terminal-persistence',
|
||||
'终态持久化重试项目',
|
||||
);
|
||||
const stableTurnId = 'stable-terminal-persistence-001';
|
||||
let directTurnCallCount = 0;
|
||||
let allowAssistantPersistence = false;
|
||||
let failedAssistantPersistenceCount = 0;
|
||||
const directTurnIds: string[] = [];
|
||||
const persistedMessages: Array<Record<string, unknown>> = [
|
||||
{
|
||||
schemaVersion: 'game-creator-conversation.v1',
|
||||
role: 'user',
|
||||
content: '恢复终态持久化失败的回合',
|
||||
agentId: null,
|
||||
messageId: `direct-codex:${stableTurnId}:user`,
|
||||
updatedAt: 1,
|
||||
},
|
||||
];
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'get_local_game_manifest') {
|
||||
return manifest;
|
||||
}
|
||||
if (command === 'read_local_conversation') {
|
||||
return {
|
||||
path: `${projectPath}/.agent/conversations/project.jsonl`,
|
||||
agentId: null,
|
||||
sessionId: null,
|
||||
messages: [...persistedMessages],
|
||||
};
|
||||
}
|
||||
if (command === 'append_local_permission_log') {
|
||||
return {};
|
||||
}
|
||||
if (command === 'read_project_permission_policy') {
|
||||
return {
|
||||
path: '.agent/policy.json',
|
||||
policy: { deniedCommands: [], confirmCommands: [] },
|
||||
};
|
||||
}
|
||||
if (command === 'append_local_conversation_message') {
|
||||
const message = args?.message as Record<string, unknown>;
|
||||
if (message.role === 'assistant' && !allowAssistantPersistence) {
|
||||
failedAssistantPersistenceCount += 1;
|
||||
throw new Error('assistant conversation persistence unavailable');
|
||||
}
|
||||
const messageId = String(args?.messageId ?? '');
|
||||
if (
|
||||
!messageId ||
|
||||
!persistedMessages.some(
|
||||
(candidate) => candidate.messageId === messageId,
|
||||
)
|
||||
) {
|
||||
persistedMessages.push({
|
||||
schemaVersion: 'game-creator-conversation.v1',
|
||||
...message,
|
||||
messageId,
|
||||
updatedAt: Number(
|
||||
message.updatedAt ?? persistedMessages.length + 1,
|
||||
),
|
||||
});
|
||||
}
|
||||
return {
|
||||
path: `${projectPath}/.agent/conversations/project.jsonl`,
|
||||
agentId: null,
|
||||
sessionId: null,
|
||||
messages: [...persistedMessages],
|
||||
};
|
||||
}
|
||||
if (command === 'chat_with_game_creator_direct_codex') {
|
||||
directTurnCallCount += 1;
|
||||
directTurnIds.push(String(args?.clientTurnId ?? ''));
|
||||
if (directTurnCallCount === 1) {
|
||||
if (firstError) {
|
||||
throw new Error(firstError);
|
||||
}
|
||||
persistedMessages.push({
|
||||
schemaVersion: 'game-creator-conversation.v1',
|
||||
role: 'assistant',
|
||||
content: firstReply,
|
||||
agentId: null,
|
||||
messageId: `direct-codex:${stableTurnId}:assistant`,
|
||||
updatedAt: 2,
|
||||
});
|
||||
return firstReply ?? '';
|
||||
}
|
||||
allowAssistantPersistence = true;
|
||||
return '恢复后的最终回复';
|
||||
}
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
},
|
||||
);
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
|
||||
render(
|
||||
React.createElement(App, {
|
||||
initialProjectPath: projectPath,
|
||||
initialProjectManifest: manifest,
|
||||
projectSupervisorOnly: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(await screen.findByText(firstVisibleText)).not.toBeNull();
|
||||
await waitFor(() => {
|
||||
expect(directTurnCallCount).toBe(1);
|
||||
expect(failedAssistantPersistenceCount).toBeGreaterThan(
|
||||
firstError ? 1 : 0,
|
||||
);
|
||||
expect(persistedMessages).toHaveLength(firstError ? 1 : 2);
|
||||
});
|
||||
|
||||
const directComposer = screen.getByLabelText('陶泥儿对话内容');
|
||||
fireEvent.change(directComposer, { target: { value: '/history' } });
|
||||
fireEvent.submit(directComposer.closest('form') as HTMLFormElement);
|
||||
|
||||
expect(
|
||||
await screen.findByText(firstError ? '恢复后的最终回复' : firstReply!),
|
||||
).not.toBeNull();
|
||||
await waitFor(() => {
|
||||
expect(directTurnCallCount).toBe(firstError ? 2 : 1);
|
||||
expect(directTurnIds).toEqual(
|
||||
firstError ? [stableTurnId, stableTurnId] : [stableTurnId],
|
||||
);
|
||||
expect(
|
||||
persistedMessages.filter(
|
||||
(message) =>
|
||||
message.messageId === `direct-codex:${stableTurnId}:assistant`,
|
||||
),
|
||||
).toEqual([
|
||||
expect.objectContaining({
|
||||
role: 'assistant',
|
||||
content: firstError ? '恢复后的最终回复' : firstReply,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function registerRecentProjectsTests() {
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import {
|
||||
isDirectCodexTurnAlreadyRunningError,
|
||||
unansweredDirectCodexConversationTurn,
|
||||
} from '../../src/App';
|
||||
import { isDirectCodexTurnAlreadyRunningError } from '../../src/App';
|
||||
import {
|
||||
act,
|
||||
agentRuntimeUserInputRequest,
|
||||
@@ -26,66 +23,6 @@ import {
|
||||
} from './harness';
|
||||
|
||||
export function registerProjectConversationTests() {
|
||||
it('replays only the latest unanswered Direct Codex turn with its original stable identity', () => {
|
||||
expect(
|
||||
unansweredDirectCodexConversationTurn([
|
||||
{
|
||||
role: 'user',
|
||||
text: '请重新生成美术',
|
||||
messageId: 'direct-codex:stable-turn-001:user',
|
||||
},
|
||||
]),
|
||||
).toEqual({
|
||||
prompt: '请重新生成美术',
|
||||
turnId: 'stable-turn-001',
|
||||
});
|
||||
expect(
|
||||
unansweredDirectCodexConversationTurn([
|
||||
{
|
||||
role: 'user',
|
||||
text: '请重新生成美术',
|
||||
messageId: 'direct-codex:stable-turn-001:user',
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
text: '已完成',
|
||||
messageId: 'direct-codex:stable-turn-001:assistant',
|
||||
},
|
||||
]),
|
||||
).toBeNull();
|
||||
expect(
|
||||
unansweredDirectCodexConversationTurn([
|
||||
{
|
||||
role: 'user',
|
||||
text: '恢复较早的未回答回合',
|
||||
messageId: 'direct-codex:stable-turn-older:user',
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
text: '较新的已回答回合',
|
||||
messageId: 'direct-codex:stable-turn-newer:user',
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
text: '较新的回复',
|
||||
messageId: 'direct-codex:stable-turn-newer:assistant',
|
||||
},
|
||||
]),
|
||||
).toEqual({
|
||||
prompt: '恢复较早的未回答回合',
|
||||
turnId: 'stable-turn-older',
|
||||
});
|
||||
expect(
|
||||
unansweredDirectCodexConversationTurn([
|
||||
{
|
||||
role: 'user',
|
||||
text: '伪造回合',
|
||||
messageId: 'direct-codex:../unsafe:user',
|
||||
},
|
||||
]),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('filters only the stable same-turn in-progress rejection from terminal Direct Codex failures', () => {
|
||||
expect(
|
||||
isDirectCodexTurnAlreadyRunningError(
|
||||
|
||||
Reference in New Issue
Block a user