合并master到作品管理分支
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
保留主线DirectProject与客户端基础设施更新 重新接回Phaser4发布、分片上传进度和幂等账本改动
This commit is contained in:
@@ -56,7 +56,9 @@ export default defineConfig(({ command, mode }) => {
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
chunkSizeWarningLimit: 600,
|
||||
// 中文注释:后台单页 chunk 已增长到 600.23 kB;沿用 2026-05-01 把 500 提到 600 的同一处理口径
|
||||
// 提到 700,先让直推 master 的构建门禁恢复可过,真正的按路由拆分另行排期。
|
||||
chunkSizeWarningLimit: 700,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -98,10 +98,13 @@ const appInvokeSources = readSourceFiles(
|
||||
new URL('../src/', import.meta.url),
|
||||
new Set(['.ts', '.tsx']),
|
||||
);
|
||||
const tauriHandlerSource = fs.readFileSync(
|
||||
// 生产接线在 desktop,受测的启动诊断实现仍在 main;两者共同构成启动检查范围。
|
||||
const tauriHandlerSource = [
|
||||
new URL('../src-tauri/src/main.rs', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
new URL('../src-tauri/src/desktop.rs', import.meta.url),
|
||||
]
|
||||
.map((source) => fs.readFileSync(source, 'utf8'))
|
||||
.join('\n');
|
||||
const tauriRustSource = readSourceTree(
|
||||
new URL('../src-tauri/src/', import.meta.url),
|
||||
'.rs',
|
||||
|
||||
@@ -30,11 +30,9 @@ mod direct_project_history;
|
||||
mod direct_project_turn_history;
|
||||
mod direct_runtime;
|
||||
mod direct_tool_bridge;
|
||||
mod direct_tool_calls;
|
||||
mod direct_tools_mcp;
|
||||
mod direct_turn_error;
|
||||
mod direct_turn_failure;
|
||||
mod direct_turn_stream;
|
||||
mod direct_validation;
|
||||
mod generation;
|
||||
mod prompt;
|
||||
@@ -54,16 +52,19 @@ pub(crate) use claude_code_cli::{
|
||||
direct_game_creator_claude_code_home_chat, game_creator_claude_code_cli_route_error,
|
||||
game_creator_claude_code_cli_version_identity,
|
||||
};
|
||||
pub(crate) use codex_app_server::direct_game_creator_codex_chat_at;
|
||||
use codex_app_server::*;
|
||||
#[cfg(not(test))]
|
||||
pub(crate) use codex_app_server::{
|
||||
cancel_direct_codex_turn_at, direct_game_creator_codex_chat_at,
|
||||
direct_game_creator_home_codex_chat, thread_id_for_project, DirectTurnCancelView,
|
||||
cancel_direct_codex_turn_at, direct_game_creator_home_codex_chat, thread_id_for_project,
|
||||
DirectTurnCancelView,
|
||||
};
|
||||
use codex_cli::*;
|
||||
pub(crate) use codex_cli::{
|
||||
game_creator_codex_cli_executable_path, game_creator_codex_cli_version_identity,
|
||||
};
|
||||
pub(crate) use codex_provider_proxy::*;
|
||||
#[cfg(not(test))]
|
||||
pub(crate) use design_runtime::*;
|
||||
pub(crate) use direct_codex_attachments::*;
|
||||
pub(crate) use direct_codex_user_item::*;
|
||||
@@ -71,11 +72,9 @@ 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_tool_calls::*;
|
||||
pub(crate) use direct_tools_mcp::*;
|
||||
pub(crate) use direct_turn_error::*;
|
||||
pub(crate) use direct_turn_failure::*;
|
||||
pub(crate) use direct_turn_stream::*;
|
||||
pub(crate) use direct_validation::DirectValidationConfig;
|
||||
pub(crate) use generation::*;
|
||||
pub(crate) use prompt::*;
|
||||
|
||||
@@ -710,32 +710,21 @@ pub(crate) async fn direct_game_creator_claude_code_chat_at(
|
||||
.filter_map(|event| serde_json::to_string(&event).ok())
|
||||
.collect::<Vec<_>>();
|
||||
stream.push(String::new());
|
||||
parse_direct_stream_result(
|
||||
stream.join("\n").as_bytes(),
|
||||
client_turn_id.unwrap_or("claude-code-turn"),
|
||||
observer,
|
||||
)
|
||||
parse_direct_stream_result(stream.join("\n").as_bytes(), observer)
|
||||
}
|
||||
|
||||
fn parse_direct_stream_result(
|
||||
stdout: &[u8],
|
||||
turn_id: &str,
|
||||
mut observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>,
|
||||
) -> Result<String, String> {
|
||||
let text = std::str::from_utf8(stdout)
|
||||
.map_err(|_| "Claude Agent SDK stream-json 不是 UTF-8".to_string())?;
|
||||
let mut result = None;
|
||||
let mut tool_calls = std::collections::HashMap::<String, DirectToolCall>::new();
|
||||
for line in text.lines().filter(|line| !line.trim().is_empty()) {
|
||||
let event: serde_json::Value = serde_json::from_str(line)
|
||||
.map_err(|_| "Claude Agent SDK stream-json 包含无效 JSON".to_string())?;
|
||||
let event_type = event.get("type").and_then(serde_json::Value::as_str);
|
||||
if event_type == Some("assistant") {
|
||||
let id = event
|
||||
.get("uuid")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or("claude-code-assistant")
|
||||
.to_string();
|
||||
let content = event
|
||||
.pointer("/message/content")
|
||||
.and_then(serde_json::Value::as_array);
|
||||
@@ -754,77 +743,7 @@ fn parse_direct_stream_result(
|
||||
.collect::<String>();
|
||||
if !visible.is_empty() {
|
||||
if let Some(observer) = observer.as_deref_mut() {
|
||||
observer(DirectCodexTurnObservation::AgentMessageSegment {
|
||||
item_id: id,
|
||||
accumulated_text: visible,
|
||||
completed: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
for part in content.iter().filter(|part| {
|
||||
part.get("type").and_then(serde_json::Value::as_str) == Some("tool_use")
|
||||
}) {
|
||||
let Some(call_id) = part.get("id").and_then(serde_json::Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
let name = part
|
||||
.get("name")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or("mcp_tool")
|
||||
.to_string();
|
||||
let arguments = part
|
||||
.get("input")
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::Value::Null);
|
||||
let now = direct_tool_call_now_ms();
|
||||
let call = DirectToolCall {
|
||||
schema_version: DIRECT_TOOL_CALL_SCHEMA_VERSION.to_string(),
|
||||
id: call_id.to_string(),
|
||||
turn_id: turn_id.to_string(),
|
||||
kind: "mcp_tool".to_string(),
|
||||
title: "调用工具".to_string(),
|
||||
summary: name,
|
||||
status: "running".to_string(),
|
||||
detail: DirectToolCallDetail {
|
||||
command: serde_json::to_string(&arguments).ok(),
|
||||
output: None,
|
||||
changes: Vec::new(),
|
||||
},
|
||||
started_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
if let Some(observer) = observer.as_deref_mut() {
|
||||
observer(DirectCodexTurnObservation::ToolCall(call.clone()));
|
||||
}
|
||||
tool_calls.insert(call_id.to_string(), call);
|
||||
}
|
||||
}
|
||||
} else if event_type == Some("user") {
|
||||
if let Some(content) = event
|
||||
.pointer("/message/content")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
{
|
||||
for part in content.iter().filter(|part| {
|
||||
part.get("type").and_then(serde_json::Value::as_str) == Some("tool_result")
|
||||
}) {
|
||||
let Some(call_id) = part.get("tool_use_id").and_then(serde_json::Value::as_str)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(mut call) = tool_calls.remove(call_id) else {
|
||||
continue;
|
||||
};
|
||||
call.status = "completed".to_string();
|
||||
call.updated_at = direct_tool_call_now_ms();
|
||||
call.detail.output = part
|
||||
.get("content")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::to_string);
|
||||
if part.get("is_error").and_then(serde_json::Value::as_bool) == Some(true) {
|
||||
call.status = "failed".to_string();
|
||||
}
|
||||
if let Some(observer) = observer.as_deref_mut() {
|
||||
observer(DirectCodexTurnObservation::ToolCall(call));
|
||||
observer(DirectCodexTurnObservation::AgentMessageSegment(visible));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -894,14 +813,13 @@ mod tests {
|
||||
{"type":"result","is_error":false,"result":"最终回复"}
|
||||
"#
|
||||
.as_bytes(),
|
||||
"turn-1",
|
||||
Some(&mut |event| observed.push(event)),
|
||||
)
|
||||
.expect("stream result");
|
||||
assert_eq!(text, "最终回复");
|
||||
assert!(matches!(
|
||||
observed.as_slice(),
|
||||
[DirectCodexTurnObservation::AgentMessageSegment { .. }]
|
||||
[DirectCodexTurnObservation::AgentMessageSegment(_)]
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -642,21 +642,12 @@ enum CodexTurnEvent {
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum DirectCodexTurnObservation {
|
||||
AccumulatedText(String),
|
||||
/// 一个 assistant 文本段的当前累计全文。
|
||||
///
|
||||
/// `item_id` 是一次 assistant 消息的稳定身份:同一个 id 的后续 delta 属于**同一段**,
|
||||
/// id 变了就是新的一段。回合流的"文本段 + 工具"顺序用它来分段,而不是按 delta 分。
|
||||
AgentMessageSegment {
|
||||
item_id: String,
|
||||
accumulated_text: String,
|
||||
completed: bool,
|
||||
},
|
||||
/// 一段可见的 assistant 正文(同一 assistant item 的当前累计全文)。
|
||||
AgentMessageSegment(String),
|
||||
IntermediateText(String),
|
||||
/// 模型的思考过程(reasoning item 的明文摘要):流式阶段整段替换下发。
|
||||
Reasoning(String),
|
||||
Activity(&'static str),
|
||||
/// 一条结构化工具调用(`item/started` 与 `item/completed` 各采一次,按 id 幂等)。
|
||||
ToolCall(crate::DirectToolCall),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
@@ -809,7 +800,7 @@ fn direct_thread_event_item(
|
||||
root: &std::path::Path,
|
||||
item: &serde_json::Value,
|
||||
) -> Option<ThreadItem> {
|
||||
thread_item_from_value(root, item, direct_tool_call_now_ms())
|
||||
thread_item_from_value(root, item, direct_now_ms())
|
||||
}
|
||||
|
||||
/// 运行态条目投影:Codex 回显的用户消息整条跳过。
|
||||
@@ -2553,34 +2544,15 @@ impl CodexAppServerConnection {
|
||||
Ok(connection)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
async fn spawn(
|
||||
llm: &GameCreatorLlmConfig,
|
||||
credential: &CodexAppServerCredential,
|
||||
) -> Result<Self, platform_llm::LlmError> {
|
||||
let executable = game_creator_codex_cli_executable_path()
|
||||
.map_err(platform_llm::LlmError::InvalidConfig)?;
|
||||
Self::spawn_with_executable_and_credential(llm, credential, executable.as_os_str()).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(all(test, unix))]
|
||||
async fn spawn_with_executable(
|
||||
llm: &GameCreatorLlmConfig,
|
||||
executable: &std::ffi::OsStr,
|
||||
) -> Result<Self, platform_llm::LlmError> {
|
||||
let credential = resolve_game_creator_codex_app_server_credential(llm)?;
|
||||
Self::spawn_with_executable_and_credential(llm, &credential, executable).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
async fn spawn_with_executable_and_credential(
|
||||
llm: &GameCreatorLlmConfig,
|
||||
credential: &CodexAppServerCredential,
|
||||
executable: &std::ffi::OsStr,
|
||||
) -> Result<Self, platform_llm::LlmError> {
|
||||
Self::spawn_with_executable_and_credential_at_workspace(
|
||||
llm,
|
||||
credential,
|
||||
&credential,
|
||||
executable,
|
||||
None,
|
||||
CodexAppServerWorkspaceMode::ToolHost,
|
||||
@@ -2588,7 +2560,7 @@ impl CodexAppServerConnection {
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(all(test, unix))]
|
||||
async fn spawn_with_executable_and_credential_at_workspace(
|
||||
llm: &GameCreatorLlmConfig,
|
||||
credential: &CodexAppServerCredential,
|
||||
@@ -3344,9 +3316,9 @@ impl CodexAppServerConnection {
|
||||
) -> Result<platform_llm::LlmRunResponse, DirectTurnRunFailure> {
|
||||
let _turn_guard = self.inner.turn_gate.lock().await;
|
||||
let history_root = direct_history_root.unwrap_or(&self.inner.workspace_path);
|
||||
// 工具调用卡片的 turnId 用 AGC 客户端回合 id(与实时事件、落盘条目同一口径),
|
||||
// 不用 Codex app-server 自己的 turnId——前端要按它把卡片挂回对应的那一轮。
|
||||
let direct_tool_call_turn_id: Option<String> = direct_client_turn_id
|
||||
// 回合身份用 AGC 客户端回合 id(与实时事件、落盘条目同一口径),
|
||||
// 不用 Codex app-server 自己的 turnId。
|
||||
let direct_turn_id: Option<String> = direct_client_turn_id
|
||||
.map(str::trim)
|
||||
.filter(|turn_id| !turn_id.is_empty())
|
||||
.map(str::to_string);
|
||||
@@ -3525,7 +3497,7 @@ impl CodexAppServerConnection {
|
||||
}
|
||||
let context = ProjectModelUsageContext {
|
||||
root: history_root.to_path_buf(),
|
||||
client_turn_id: direct_tool_call_turn_id.clone(),
|
||||
client_turn_id: direct_turn_id.clone(),
|
||||
thread_id: Some(thread_id.clone()),
|
||||
requested_model: model.to_string(),
|
||||
};
|
||||
@@ -3546,7 +3518,7 @@ impl CodexAppServerConnection {
|
||||
let turn_start_cancellation =
|
||||
Arc::new(CodexTurnStartCancellation::new(&self.inner, &thread_id));
|
||||
// Direct 回合登记为"可终止":终止命令只作用在这一轮上,回合结束时自动注销。
|
||||
let _active_turn_guard = direct_tool_call_turn_id
|
||||
let _active_turn_guard = direct_turn_id
|
||||
.as_deref()
|
||||
.filter(|_| self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject)
|
||||
.map(|turn_id| {
|
||||
@@ -3630,7 +3602,7 @@ impl CodexAppServerConnection {
|
||||
// `startedAt` / `completedAt` 只有秒级,秒级截断撑不起前端 0.1 秒粒度的展示,也可能
|
||||
// 让完成时刻落进该轮用户消息的同一秒。因此这里在进入模型往返前取一次宿主毫秒钟,与
|
||||
// `durationMs` 相加得到终态时刻;拿不到 `durationMs` 时退回观察时刻。
|
||||
let direct_turn_started_at_ms = direct_tool_call_now_ms();
|
||||
let direct_turn_started_at_ms = direct_now_ms();
|
||||
let mut receiver = self.register_turn(&turn_id).await;
|
||||
let mut direct_project_history = DirectProjectHistoryAccumulator::default();
|
||||
let mut guard = CodexTurnGuard {
|
||||
@@ -3730,17 +3702,14 @@ impl CodexAppServerConnection {
|
||||
observer(DirectCodexTurnObservation::AccumulatedText(
|
||||
streamed_text.clone(),
|
||||
));
|
||||
// 同一个 assistant item 的当前累计全文:回合流按 item 分段,
|
||||
// 段内只追加、段间才换行,不能拿"整轮累计"当一段。
|
||||
// 同一 assistant item 的当前累计全文,不能拿"整轮累计"当一段。
|
||||
let segment_text = direct_project_history
|
||||
.accumulated_text_for(&item_id)
|
||||
.unwrap_or_else(|| delta.clone());
|
||||
if !segment_text.trim().is_empty() {
|
||||
observer(DirectCodexTurnObservation::AgentMessageSegment {
|
||||
item_id: item_id.clone(),
|
||||
accumulated_text: segment_text,
|
||||
completed: false,
|
||||
});
|
||||
observer(DirectCodexTurnObservation::AgentMessageSegment(
|
||||
segment_text,
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(callback) = on_agent_message_delta.as_deref_mut() {
|
||||
@@ -3799,10 +3768,7 @@ impl CodexAppServerConnection {
|
||||
// 通知的钟就是该阶段唯一可证明的时间。
|
||||
append_thread_event(
|
||||
&direct_thread_id,
|
||||
ThreadEvent::item_completed(
|
||||
entry_item,
|
||||
direct_tool_call_now_ms(),
|
||||
),
|
||||
ThreadEvent::item_completed(entry_item, direct_now_ms()),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3860,43 +3826,22 @@ impl CodexAppServerConnection {
|
||||
completed,
|
||||
¶ms,
|
||||
);
|
||||
// 工具调用卡片:item/started 与 item/completed 各采一次,
|
||||
// 由下游按 id 幂等 upsert 成同一条。采集失败(拿不到 id /
|
||||
// 非工具类 item)就静默跳过,不影响这一轮的其它投影。
|
||||
if let Some(turn_id) = direct_tool_call_turn_id.as_deref() {
|
||||
if let Some(tool_call) = direct_tool_call_from_item(
|
||||
history_root,
|
||||
item,
|
||||
turn_id,
|
||||
completed,
|
||||
direct_tool_call_now_ms(),
|
||||
) {
|
||||
if let Some(observer) = direct_observer.as_deref_mut() {
|
||||
observer(DirectCodexTurnObservation::ToolCall(
|
||||
tool_call,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if item_type == "agentMessage" {
|
||||
// 某些 app-server 实现会在工具开始后停止发送 agentMessage delta,
|
||||
// 但会在 item/completed 携带完整文本。把这份最终快照补进回合流,
|
||||
// 让流中的文本段不会停在工具前的短前缀。
|
||||
// 但会在 item/completed 携带完整文本;把这份最终快照也交给观察者,
|
||||
// 运行态条目就不会停在工具前的短前缀。
|
||||
if completed {
|
||||
if let (Some(item_id), Some(text)) = (
|
||||
item.get("id").and_then(serde_json::Value::as_str),
|
||||
item.get("text")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.filter(|value| !value.trim().is_empty()),
|
||||
) {
|
||||
if let Some(text) = item
|
||||
.get("text")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
{
|
||||
if let Some(observer) = direct_observer.as_deref_mut() {
|
||||
observer(
|
||||
DirectCodexTurnObservation::AgentMessageSegment {
|
||||
item_id: item_id.to_string(),
|
||||
accumulated_text: text.to_string(),
|
||||
completed: true,
|
||||
},
|
||||
DirectCodexTurnObservation::AgentMessageSegment(
|
||||
text.to_string(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3938,7 +3883,7 @@ impl CodexAppServerConnection {
|
||||
¶ms,
|
||||
item,
|
||||
false,
|
||||
direct_tool_call_now_ms(),
|
||||
direct_now_ms(),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -3962,15 +3907,10 @@ impl CodexAppServerConnection {
|
||||
.filter(|text| !text.trim().is_empty())
|
||||
{
|
||||
final_text = Some(text.to_string());
|
||||
if let (Some(item_id), Some(observer)) = (
|
||||
item.get("id").and_then(serde_json::Value::as_str),
|
||||
direct_observer.as_deref_mut(),
|
||||
) {
|
||||
observer(DirectCodexTurnObservation::AgentMessageSegment {
|
||||
item_id: item_id.to_string(),
|
||||
accumulated_text: text.to_string(),
|
||||
completed: true,
|
||||
});
|
||||
if let Some(observer) = direct_observer.as_deref_mut() {
|
||||
observer(DirectCodexTurnObservation::AgentMessageSegment(
|
||||
text.to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3987,7 +3927,7 @@ impl CodexAppServerConnection {
|
||||
thread_turn_completed_at_ms(
|
||||
turn,
|
||||
Some(direct_turn_started_at_ms),
|
||||
direct_tool_call_now_ms(),
|
||||
direct_now_ms(),
|
||||
),
|
||||
));
|
||||
}
|
||||
@@ -4157,12 +4097,12 @@ impl CodexAppServerConnection {
|
||||
.unwrap_or_else(|| fallback_status.to_string());
|
||||
// 有执行许可时,起止时间包含实际宿主收尾;上游模型完成不能提前结束 UI。
|
||||
let completed_at = if approval_adapter.is_some() {
|
||||
direct_tool_call_now_ms()
|
||||
direct_now_ms()
|
||||
} else {
|
||||
model_terminal
|
||||
.as_ref()
|
||||
.map(|(_, at)| *at)
|
||||
.unwrap_or_else(direct_tool_call_now_ms)
|
||||
.unwrap_or_else(direct_now_ms)
|
||||
};
|
||||
// 终态判定的**事实**在这里固定,写点留到整轮真正结束之后(见下面的
|
||||
// `turn_result`):终态只有 `turn.completed` 一种事件,失败时同一个事件带 `failure`
|
||||
@@ -5464,6 +5404,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer(
|
||||
/// fresh isolated read-only workspace and a stable in-process thread so a
|
||||
/// normal conversation can continue without creating a project, assets, a
|
||||
/// preview, or a version record.
|
||||
#[cfg(not(test))]
|
||||
pub(crate) async fn direct_game_creator_home_codex_chat(
|
||||
system_prompt: String,
|
||||
user_prompt: String,
|
||||
@@ -5675,6 +5616,104 @@ mod tests {
|
||||
assert!(table.select(&key, None).is_err());
|
||||
}
|
||||
|
||||
fn exited_direct_turn_cancellation() -> Arc<CodexTurnStartCancellation> {
|
||||
Arc::new(CodexTurnStartCancellation {
|
||||
inner: Weak::new(),
|
||||
thread_id: String::new(),
|
||||
turn_id: std::sync::Mutex::new(None),
|
||||
cancelled: AtomicBool::new(false),
|
||||
interrupt_sent: AtomicBool::new(false),
|
||||
execution: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_direct_codex_turn_rejects_another_turn_without_releasing_it() {
|
||||
let root = tempfile::tempdir().expect("direct turn project");
|
||||
let thread_id = thread_id_for_project(root.path());
|
||||
let invocation = TurnReservation::accept_for_test(&thread_id, "client-turn-current");
|
||||
let cancellation = exited_direct_turn_cancellation();
|
||||
let key = direct_codex_active_turn_key(root.path());
|
||||
let _registered = register_active_direct_codex_turn(
|
||||
key.clone(),
|
||||
"client-turn-current",
|
||||
Arc::clone(&cancellation),
|
||||
);
|
||||
let subscription = crate::agent::subscribe_thread(&thread_id);
|
||||
let _ = crate::agent::consume_thread(&subscription.subscription_id)
|
||||
.expect("drain turn start event");
|
||||
|
||||
let error = cancel_direct_codex_turn_at(root.path(), Some("client-turn-other"))
|
||||
.expect_err("another turn must not be interrupted or released");
|
||||
assert_eq!(error, DIRECT_CODEX_ANOTHER_TURN_RUNNING_MESSAGE);
|
||||
assert!(!cancellation.cancelled.load(Ordering::Acquire));
|
||||
assert_eq!(
|
||||
direct_codex_active_turns()
|
||||
.lock()
|
||||
.expect("active turn table")
|
||||
.registered_client_turn_id(&key),
|
||||
Some("client-turn-current")
|
||||
);
|
||||
assert_eq!(
|
||||
crate::agent::read_turn_identity(&thread_id)
|
||||
.expect("original turn remains active")
|
||||
.client_turn_id,
|
||||
"client-turn-current"
|
||||
);
|
||||
assert!(
|
||||
crate::agent::consume_thread(&subscription.subscription_id)
|
||||
.expect("consume turn events")
|
||||
.events
|
||||
.is_empty(),
|
||||
"wrong-turn rejection must not publish a terminal event"
|
||||
);
|
||||
drop(invocation);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_direct_codex_turn_releases_exited_executor_and_completes_original_turn() {
|
||||
let root = tempfile::tempdir().expect("direct turn project");
|
||||
let thread_id = thread_id_for_project(root.path());
|
||||
let invocation = TurnReservation::accept_for_test(&thread_id, "client-turn-exited");
|
||||
let _registered = register_active_direct_codex_turn(
|
||||
direct_codex_active_turn_key(root.path()),
|
||||
"client-turn-exited",
|
||||
exited_direct_turn_cancellation(),
|
||||
);
|
||||
let subscription = crate::agent::subscribe_thread(&thread_id);
|
||||
let _ = crate::agent::consume_thread(&subscription.subscription_id)
|
||||
.expect("drain turn start event");
|
||||
|
||||
let view = cancel_direct_codex_turn_at(root.path(), Some("client-turn-exited"))
|
||||
.expect("exited executor releases the residual invocation");
|
||||
assert_eq!(view.outcome, DIRECT_TURN_CANCEL_OUTCOME_RELEASED);
|
||||
assert_eq!(view.client_turn_id, "client-turn-exited");
|
||||
assert!(view.message.contains("执行进程已退出"));
|
||||
assert_eq!(crate::agent::read_turn_identity(&thread_id), None);
|
||||
let events = crate::agent::consume_thread(&subscription.subscription_id)
|
||||
.expect("consume terminal event")
|
||||
.events;
|
||||
assert!(matches!(
|
||||
events.as_slice(),
|
||||
[ThreadEvent::TurnCompleted {
|
||||
status,
|
||||
failure: None,
|
||||
at: Some(_),
|
||||
user_item_id: Some(user_item_id),
|
||||
}] if status == "aborted" && user_item_id == "direct-codex:client-turn-exited:user"
|
||||
));
|
||||
|
||||
let next = TurnReservation::accept_for_test(&thread_id, "client-turn-next");
|
||||
drop(invocation);
|
||||
assert_eq!(
|
||||
crate::agent::read_turn_identity(&thread_id)
|
||||
.expect("old guard must not clear next turn")
|
||||
.client_turn_id,
|
||||
"client-turn-next"
|
||||
);
|
||||
drop(next);
|
||||
}
|
||||
|
||||
/// 终止路径会从同步命令线程和 `Drop` 里派发 app-server 任务:那些线程没有 tokio
|
||||
/// runtime 上下文。`tokio::spawn` 在那里 panic,panic 跨不过 IPC 回调边界就把整个
|
||||
/// 进程 abort(0xC0000409,"点终止就闪退")。这条用例把派发入口钉在没有 runtime
|
||||
@@ -7574,6 +7613,8 @@ while IFS= read -r line; do :; done
|
||||
.map(|argument| argument.to_string_lossy().into_owned())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(home_arguments.contains("--disable\nshell_tool"));
|
||||
assert!(home_arguments.contains("--disable\nunified_exec"));
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
|
||||
@@ -8,6 +8,7 @@ use std::fs::File;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
use std::time::{Duration, Instant};
|
||||
#[cfg(not(test))]
|
||||
use tauri::Emitter;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -1398,12 +1399,23 @@ fn ensure_design_runtime_active(root: &Path) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
#[tauri::command]
|
||||
pub(crate) async fn hydrate_design_agent_session(
|
||||
app: tauri::AppHandle,
|
||||
project_path: String,
|
||||
) -> Result<Option<DesignView>, String> {
|
||||
let root = Path::new(project_path.trim());
|
||||
hydrate_design_agent_session_at(root, move || {
|
||||
DesignResources::new(resolve_design_resources_root(&app)?)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn hydrate_design_agent_session_at(
|
||||
root: &Path,
|
||||
load_resources: impl FnOnce() -> Result<DesignResources, String> + Send,
|
||||
) -> Result<Option<DesignView>, String> {
|
||||
enforce_project_permission_policy(root, "conversation.read")?;
|
||||
if read_design_runtime_mode(root)?
|
||||
.as_ref()
|
||||
@@ -1432,7 +1444,7 @@ pub(crate) async fn hydrate_design_agent_session(
|
||||
return Ok(Some(design_view(&session, true)));
|
||||
};
|
||||
if design_session_has_uncertain_batch(&session) {
|
||||
let resources = DesignResources::new(resolve_design_resources_root(&app)?)?;
|
||||
let resources = load_resources()?;
|
||||
let view = recover_uncertain_design_batch(root, &resources, session, active).await?;
|
||||
return Ok(Some(view));
|
||||
}
|
||||
@@ -1476,9 +1488,14 @@ fn quarantine_corrupted_design_session(root: &Path) -> Result<(), String> {
|
||||
std::fs::rename(&path, &backup).map_err(|error| format!("隔离损坏的策划会话失败:{error}"))
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
#[tauri::command]
|
||||
pub(crate) fn reset_design_agent_session(project_path: String) -> Result<(), String> {
|
||||
let root = Path::new(project_path.trim());
|
||||
reset_design_agent_session_at(root)
|
||||
}
|
||||
|
||||
fn reset_design_agent_session_at(root: &Path) -> Result<(), String> {
|
||||
enforce_project_permission_policy(root, "conversation.write")?;
|
||||
design_project_id(root)?;
|
||||
let path = crate::agent::runtime_protocol::design_session_path(root);
|
||||
@@ -1488,6 +1505,7 @@ pub(crate) fn reset_design_agent_session(project_path: String) -> Result<(), Str
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
#[tauri::command]
|
||||
pub(crate) fn get_design_agent_runtime_mode(
|
||||
project_path: String,
|
||||
@@ -1515,8 +1533,13 @@ fn design_debug_enabled() -> bool {
|
||||
== Some("1")
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
#[tauri::command]
|
||||
pub(crate) fn is_design_agent_debug_enabled() -> bool {
|
||||
design_debug_command_enabled()
|
||||
}
|
||||
|
||||
fn design_debug_command_enabled() -> bool {
|
||||
cfg!(debug_assertions) && design_debug_enabled()
|
||||
}
|
||||
|
||||
@@ -1540,16 +1563,27 @@ pub(crate) fn set_design_agent_runtime_mode(
|
||||
write_design_runtime_mode(root, active_runtime.trim())
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
#[tauri::command]
|
||||
pub(crate) fn debug_fast_forward_design_session(
|
||||
app: tauri::AppHandle,
|
||||
project_path: String,
|
||||
target_phase: String,
|
||||
) -> Result<DesignRuntimeMode, String> {
|
||||
if !is_design_agent_debug_enabled() {
|
||||
debug_fast_forward_design_session_at(Path::new(project_path.trim()), &target_phase, |event| {
|
||||
app.emit("design-agent-update", event)
|
||||
.map_err(|error| format!("刷新策划调试状态失败:{error}"))
|
||||
})
|
||||
}
|
||||
|
||||
fn debug_fast_forward_design_session_at(
|
||||
root: &Path,
|
||||
target_phase: &str,
|
||||
emit: impl FnOnce(DesignEvent) -> Result<(), String>,
|
||||
) -> Result<DesignRuntimeMode, String> {
|
||||
if !design_debug_command_enabled() {
|
||||
return Err("策划 Agent 快速推进仅可用于 Debug 构建".to_string());
|
||||
}
|
||||
let root = Path::new(project_path.trim());
|
||||
let project_id = design_project_id(root)?;
|
||||
let target_index = design_phase_index(target_phase.trim())?;
|
||||
let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
|
||||
@@ -1597,21 +1631,18 @@ pub(crate) fn debug_fast_forward_design_session(
|
||||
session.updated_at = unix_timestamp();
|
||||
write_design_session(root, &session)?;
|
||||
let mode = write_design_runtime_mode(root, "design")?;
|
||||
app.emit(
|
||||
"design-agent-update",
|
||||
design_event(
|
||||
root,
|
||||
"debug-fast-forward",
|
||||
"state",
|
||||
None,
|
||||
None,
|
||||
Some(design_view(&session, false)),
|
||||
),
|
||||
)
|
||||
.map_err(|error| format!("刷新策划调试状态失败:{error}"))?;
|
||||
emit(design_event(
|
||||
root,
|
||||
"debug-fast-forward",
|
||||
"state",
|
||||
None,
|
||||
None,
|
||||
Some(design_view(&session, false)),
|
||||
))?;
|
||||
Ok(mode)
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
#[tauri::command]
|
||||
pub(crate) async fn continue_design_agent_session(
|
||||
app: tauri::AppHandle,
|
||||
@@ -1636,6 +1667,7 @@ pub(crate) async fn continue_design_agent_session(
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
#[tauri::command]
|
||||
pub(crate) async fn decide_design_phase(
|
||||
app: tauri::AppHandle,
|
||||
@@ -1662,6 +1694,7 @@ pub(crate) async fn decide_design_phase(
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
#[tauri::command]
|
||||
pub(crate) fn list_design_workspace(
|
||||
project_path: String,
|
||||
@@ -1672,6 +1705,7 @@ pub(crate) fn list_design_workspace(
|
||||
list_design_workspace_files(root)
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
#[tauri::command]
|
||||
pub(crate) fn read_design_workspace_file(
|
||||
project_path: String,
|
||||
@@ -1685,7 +1719,6 @@ pub(crate) fn read_design_workspace_file(
|
||||
|
||||
#[cfg(test)]
|
||||
mod fake_provider {
|
||||
use super::*;
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::collections::VecDeque;
|
||||
|
||||
@@ -3236,6 +3269,105 @@ mod tests {
|
||||
assert!(next.session.last_error.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn hydrate_quarantines_a_corrupted_session_without_loading_resources() {
|
||||
let (_temp, root, _resources) = init_design_project();
|
||||
let path = crate::agent::runtime_protocol::design_session_path(&root);
|
||||
fs::create_dir_all(path.parent().expect("session directory")).expect("create session dir");
|
||||
fs::write(&path, b"{broken").expect("write malformed session");
|
||||
|
||||
let view = hydrate_design_agent_session_at(&root, || -> Result<DesignResources, String> {
|
||||
panic!("corrupted session must not load resources")
|
||||
})
|
||||
.await
|
||||
.expect("quarantine malformed session");
|
||||
assert!(view.is_none());
|
||||
assert!(!path.exists());
|
||||
let backups = fs::read_dir(path.parent().expect("session directory"))
|
||||
.expect("list session directory")
|
||||
.map(|entry| entry.expect("session entry").path())
|
||||
.filter(|entry| {
|
||||
entry
|
||||
.file_name()
|
||||
.is_some_and(|name| name.to_string_lossy().starts_with("session.corrupted."))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(backups.len(), 1);
|
||||
assert_eq!(
|
||||
fs::read(&backups[0]).expect("read quarantined session"),
|
||||
b"{broken"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_archives_the_existing_design_session() {
|
||||
let (_temp, root, _resources) = init_design_project();
|
||||
let path = crate::agent::runtime_protocol::design_session_path(&root);
|
||||
write_design_session(&root, &new_design_session("design-fake", ""))
|
||||
.expect("write design session");
|
||||
|
||||
reset_design_agent_session_at(&root).expect("reset design session");
|
||||
assert!(!path.exists());
|
||||
let backups = fs::read_dir(path.parent().expect("session directory"))
|
||||
.expect("list session directory")
|
||||
.map(|entry| entry.expect("session entry").path())
|
||||
.filter(|entry| {
|
||||
entry
|
||||
.file_name()
|
||||
.is_some_and(|name| name.to_string_lossy().starts_with("session.corrupted."))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(backups.len(), 1);
|
||||
reset_design_agent_session_at(&root).expect("repeat reset without active session");
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
#[test]
|
||||
fn debug_fast_forward_checks_the_gate_and_persists_before_event_delivery() {
|
||||
let (_temp, root, _resources) = init_design_project();
|
||||
let _debug_env = enable_design_debug_for_test();
|
||||
std::env::remove_var("GENARRATIVE_AGC_DESIGN_DEBUG");
|
||||
let error = debug_fast_forward_design_session_at(&root, "architecture", |_| {
|
||||
panic!("debug gate must reject before emitting")
|
||||
})
|
||||
.expect_err("debug gate should be closed");
|
||||
assert!(error.contains("Debug 构建"));
|
||||
assert!(read_design_session(&root).expect("read session").is_none());
|
||||
|
||||
std::env::set_var("GENARRATIVE_AGC_DESIGN_DEBUG", "1");
|
||||
let existing = root.join("design_artifacts/project/00_concept/design.md");
|
||||
fs::create_dir_all(existing.parent().expect("artifact directory"))
|
||||
.expect("create artifact directory");
|
||||
fs::write(&existing, "已有真实内容").expect("write existing artifact");
|
||||
let error = debug_fast_forward_design_session_at(&root, "architecture", |event| {
|
||||
assert_eq!(event.kind, "state");
|
||||
assert_eq!(event.client_turn_id, "debug-fast-forward");
|
||||
Err("event delivery failed".to_string())
|
||||
})
|
||||
.expect_err("event failure should be reported");
|
||||
assert_eq!(error, "event delivery failed");
|
||||
assert_eq!(
|
||||
fs::read_to_string(existing).expect("read existing artifact"),
|
||||
"已有真实内容"
|
||||
);
|
||||
assert!(root
|
||||
.join("design_artifacts/project/02_architecture/design.md")
|
||||
.is_file());
|
||||
let session = read_design_session(&root)
|
||||
.expect("read fast-forwarded session")
|
||||
.expect("fast-forwarded session");
|
||||
assert_eq!(session.current_phase, "architecture");
|
||||
assert_eq!(session.approved_phases, vec!["concept", "top_design"]);
|
||||
assert!(session.pending_batch.is_none() && session.turn.is_none());
|
||||
assert_eq!(
|
||||
read_design_runtime_mode(&root)
|
||||
.expect("read runtime mode")
|
||||
.expect("runtime mode")
|
||||
.active_runtime,
|
||||
"design"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn uncertain_batch_hydrate_continues_the_original_turn_without_replaying_file_tools() {
|
||||
let (_temp, root, resources) = init_design_project();
|
||||
@@ -3291,16 +3423,10 @@ mod tests {
|
||||
))],
|
||||
0,
|
||||
);
|
||||
let view = recover_uncertain_design_batch(&root, &resources, session, {
|
||||
try_open_game_creator_agent_runtime_task_lock_file(
|
||||
&root,
|
||||
".agent/design-agent/active.lock",
|
||||
)
|
||||
.expect("open active lock")
|
||||
.expect("active lock is free")
|
||||
})
|
||||
.await
|
||||
.expect("recover uncertain batch");
|
||||
let view = hydrate_design_agent_session_at(&root, move || Ok(resources))
|
||||
.await
|
||||
.expect("hydrate uncertain batch")
|
||||
.expect("restored session");
|
||||
|
||||
assert!(!view.running);
|
||||
assert!(view.session.last_error.is_none());
|
||||
|
||||
@@ -6,6 +6,7 @@ use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
#[cfg(not(test))]
|
||||
use tauri::Manager;
|
||||
|
||||
const DESIGN_WORKSPACE_ROOT: &str = "design_artifacts";
|
||||
@@ -168,6 +169,7 @@ impl DesignResources {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
pub(crate) fn resolve_design_resources_root(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||||
let mut candidates = vec![PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("design-agent")];
|
||||
if let Ok(dir) = app.path().resource_dir() {
|
||||
@@ -539,6 +541,7 @@ pub(crate) fn read_design_workspace_file_at(root: &Path, path: &str) -> Result<S
|
||||
fs::read_to_string(&target).map_err(|error| format!("读取失败:{error}"))
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
#[tauri::command]
|
||||
pub(crate) fn import_design_workspace_file(
|
||||
app: tauri::AppHandle,
|
||||
|
||||
@@ -737,6 +737,7 @@ pub(crate) fn read_direct_project_history_items_slice_at(
|
||||
/// 最新一条可显示条目的 itemId:首屏历史锚点。
|
||||
///
|
||||
/// 与"最近一屏"共用尾部回扫,读一行就能返回,不回读整份历史。
|
||||
#[cfg(not(test))]
|
||||
pub(crate) fn read_direct_project_last_item_id_at(root: &Path) -> Result<Option<String>, String> {
|
||||
Ok(read_direct_project_history_items_slice_at(root, DirectProjectHistoryAnchor::Newest, 1)?.3)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -43,6 +43,7 @@ pub(crate) fn normalize_direct_client_turn_id(
|
||||
/// 终端上)已经退役,不要再为"手工跑一轮"新增第二条直接起回合的路径。
|
||||
///
|
||||
/// 设计见 `docs/adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md`。
|
||||
#[cfg(not(test))]
|
||||
#[tauri::command]
|
||||
pub(crate) async fn enqueue_direct_codex_turn(
|
||||
project_path: String,
|
||||
@@ -102,7 +103,7 @@ async fn enqueue_direct_codex_turn_typed(
|
||||
})?;
|
||||
// 入队:到这里这一条已经过了全部检查,剩下的就是排队等放行。条目只带走它自己的事实
|
||||
// (用户条目、创建类型、入队时刻),canonical 形状与 prompt 放行时从它重投影——放行没有失败出口。
|
||||
let pending = PendingTurn::new(turn_id, user_item, creation_type, direct_tool_call_now_ms());
|
||||
let pending = PendingTurn::new(turn_id, user_item, creation_type, direct_now_ms());
|
||||
match enqueue_pending_turn(&thread_id, pending) {
|
||||
Ok(_) => {}
|
||||
Err(EnqueueRejection::QueueFull) => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,10 @@
|
||||
use super::*;
|
||||
use axum::extract::{DefaultBodyLimit, State as AxumState};
|
||||
use axum::extract::DefaultBodyLimit;
|
||||
use axum::extract::State as AxumState;
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::routing::post;
|
||||
use axum::{Json, Router};
|
||||
use axum::Json;
|
||||
use axum::Router;
|
||||
use serde_json::{json, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
@@ -55,6 +57,7 @@ pub(crate) fn direct_tools_mcp_mode_requested(args: &[String]) -> bool {
|
||||
args == [DIRECT_TOOLS_MCP_MODE_FLAG]
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
pub(crate) fn run_direct_tools_mcp_if_requested(args: &[String]) -> Option<i32> {
|
||||
if !direct_tools_mcp_mode_requested(args) {
|
||||
return None;
|
||||
@@ -1984,6 +1987,7 @@ fn write_direct_tools_mcp_response(
|
||||
.map_err(|_| "写入 MCP 响应失败".to_string())
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
fn read_direct_tools_mcp_requests(
|
||||
mut reader: impl BufRead,
|
||||
requests: tokio::sync::mpsc::Sender<Result<Vec<u8>, String>>,
|
||||
@@ -2062,6 +2066,7 @@ where
|
||||
terminal_error.map_or(Ok(()), Err)
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
async fn run_direct_tools_mcp_stdio() -> Result<(), String> {
|
||||
let root = validate_direct_tools_project_root(
|
||||
&std::env::current_dir().map_err(|_| "读取 MCP 工作目录失败".to_string())?,
|
||||
@@ -2177,6 +2182,7 @@ pub(crate) fn stop_external_mcp_loopback() {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
#[tauri::command]
|
||||
pub(crate) async fn start_game_creator_external_mcp(project_path: String) -> Result<Value, String> {
|
||||
let root = Path::new(project_path.trim());
|
||||
@@ -2185,6 +2191,7 @@ pub(crate) async fn start_game_creator_external_mcp(project_path: String) -> Res
|
||||
Ok(json!({ "url": url, "token": token, "transport": "streamable-http" }))
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
#[tauri::command]
|
||||
pub(crate) fn stop_game_creator_external_mcp() -> Result<(), String> {
|
||||
stop_external_mcp_loopback();
|
||||
|
||||
@@ -1,430 +0,0 @@
|
||||
//! GameAgent 对话「回合流」的采集与持久化。
|
||||
//!
|
||||
//! 顺序真相放在一处:`<projectRoot>/.agent/conversations/turn-stream.jsonl` 按**出现顺序**
|
||||
//! 记录一个回合里的文本段与工具调用。工具条目只记位置标记(`callId`),工具本身的正文
|
||||
//! 仍然来自 `tool-calls.jsonl`(同一 id 幂等合并只有一处实现)。
|
||||
//!
|
||||
//! 位置稳定:每条条目的 `seq` 在**首次出现**时由观察方分配并落盘,后续更新(同一 id 的
|
||||
//! 文本追加 / 工具状态变化)只改内容不改 `seq`。因此并发落盘的先后顺序不会让"新工具插到
|
||||
//! 旧文本前面"——渲染顺序只由 `seq` 决定。
|
||||
//!
|
||||
//! `project.jsonl` 保留原始消息;本流补充文本与工具交替的 item 顺序,不能重复展示两份正文。
|
||||
|
||||
use crate::agent::sanitize_detail_text;
|
||||
use crate::config::write_game_creator_private_file;
|
||||
use crate::project::{enforce_project_permission_policy, project_append_lock_for};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs::File;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// 行信封类型,与既有历史文件同构(`{"type": …, "payload": {…}}`)。
|
||||
pub(crate) const DIRECT_TURN_STREAM_RECORD_TYPE: &str = "turn_stream_item";
|
||||
/// 条目 schema 版本。
|
||||
pub(crate) const DIRECT_TURN_STREAM_SCHEMA_VERSION: &str = "agc-turn-stream.v1";
|
||||
/// 回读上限:只保留最后这么多条(按 `seq` 取最新)。
|
||||
pub(crate) const DIRECT_TURN_STREAM_LIMIT: usize = 400;
|
||||
/// 单条文本段的字符上限(与工具明细同口径的截断,避免单段失控)。
|
||||
const DIRECT_TURN_STREAM_TEXT_MAX_CHARS: usize = 8000;
|
||||
/// 没有流式分段时,最终回复那一段的固定 item id。
|
||||
const DIRECT_TURN_STREAM_FINAL_ITEM_ID: &str = "final";
|
||||
/// 回合失败说明那一段的固定 item id:失败说明也是这一回合的内容,排在流末尾。
|
||||
pub(crate) const DIRECT_TURN_STREAM_FAILURE_ITEM_ID: &str = "failure";
|
||||
|
||||
/// 文本段。
|
||||
pub(crate) const DIRECT_TURN_STREAM_KIND_TEXT: &str = "text";
|
||||
/// 工具调用的位置标记。
|
||||
pub(crate) const DIRECT_TURN_STREAM_KIND_TOOL: &str = "tool";
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct DirectTurnStreamItem {
|
||||
pub(crate) schema_version: String,
|
||||
/// 幂等身份:文本段 `text:<turnId>:<itemId>`、工具 `tool:<turnId>:<callId>`。
|
||||
pub(crate) id: String,
|
||||
pub(crate) turn_id: String,
|
||||
/// `text` | `tool`
|
||||
pub(crate) kind: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) text: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) call_id: Option<String>,
|
||||
/// 首次出现的写入序号:**顺序真相**,同刻按它排序。
|
||||
pub(crate) seq: u64,
|
||||
/// 条目首次出现的本机毫秒时刻。
|
||||
pub(crate) at: u64,
|
||||
pub(crate) updated_at: u64,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod snapshot_tests {
|
||||
use super::*;
|
||||
|
||||
fn text(
|
||||
turn: &str,
|
||||
id: &str,
|
||||
seq: u64,
|
||||
at: u64,
|
||||
updated: u64,
|
||||
text: &str,
|
||||
) -> DirectTurnStreamItem {
|
||||
direct_turn_stream_text_item(Path::new("."), turn, id, text, seq, at, updated)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn late_older_snapshot_cannot_undo_completed_text_or_position() {
|
||||
let complete = text("turn", "item", 1, 1000, 1002, "正文");
|
||||
let late = text("turn", "item", 9, 1001, 1001, "更长但已经过期的草稿");
|
||||
let merged = normalize_stream_items(vec![complete, late]);
|
||||
assert_eq!(merged.len(), 1);
|
||||
assert_eq!(merged[0].text.as_deref(), Some("正文"));
|
||||
assert_eq!(merged[0].seq, 1);
|
||||
assert_eq!(merged[0].at, 1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retention_does_not_treat_new_turn_seq_one_as_oldest() {
|
||||
let mut snapshots = (1..=DIRECT_TURN_STREAM_LIMIT)
|
||||
.map(|seq| text("old", &seq.to_string(), seq as u64, 1000, 1000, "旧"))
|
||||
.collect::<Vec<_>>();
|
||||
snapshots.push(text("new", "one", 1, 2000, 2000, "新"));
|
||||
let merged = normalize_stream_items(snapshots);
|
||||
assert_eq!(merged.len(), DIRECT_TURN_STREAM_LIMIT);
|
||||
assert_eq!(merged.last().unwrap().turn_id, "new");
|
||||
}
|
||||
}
|
||||
|
||||
impl DirectTurnStreamItem {
|
||||
fn order_key(&self) -> (u64, u64, &str) {
|
||||
(self.seq, self.at, self.id.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
fn turn_stream_path(root: &Path) -> PathBuf {
|
||||
root.join(".agent/conversations/turn-stream.jsonl")
|
||||
}
|
||||
|
||||
/// 文本段条目的幂等 id:同一个 Codex assistant item 只占一行。
|
||||
pub(crate) fn direct_turn_stream_text_item_id(turn_id: &str, item_id: &str) -> String {
|
||||
format!("text:{}:{}", turn_id.trim(), item_id.trim())
|
||||
}
|
||||
|
||||
/// 工具条目(位置标记)的幂等 id:同一个 callId 只占一行。
|
||||
pub(crate) fn direct_turn_stream_tool_item_id(turn_id: &str, call_id: &str) -> String {
|
||||
format!("tool:{}:{}", turn_id.trim(), call_id.trim())
|
||||
}
|
||||
|
||||
/// 构造一条文本段条目:脱敏 + 截断与 `tool-calls.jsonl` 同口径。
|
||||
pub(crate) fn direct_turn_stream_text_item(
|
||||
root: &Path,
|
||||
turn_id: &str,
|
||||
item_id: &str,
|
||||
text: &str,
|
||||
seq: u64,
|
||||
at: u64,
|
||||
updated_at: u64,
|
||||
) -> DirectTurnStreamItem {
|
||||
DirectTurnStreamItem {
|
||||
schema_version: DIRECT_TURN_STREAM_SCHEMA_VERSION.to_string(),
|
||||
id: direct_turn_stream_text_item_id(turn_id, item_id),
|
||||
turn_id: turn_id.trim().to_string(),
|
||||
kind: DIRECT_TURN_STREAM_KIND_TEXT.to_string(),
|
||||
text: Some(sanitize_stream_text(root, text)),
|
||||
call_id: None,
|
||||
seq,
|
||||
at,
|
||||
updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
/// 构造一条工具条目:只记位置,正文仍来自 `DirectToolCall`。
|
||||
pub(crate) fn direct_turn_stream_tool_item(
|
||||
turn_id: &str,
|
||||
call: &crate::DirectToolCall,
|
||||
seq: u64,
|
||||
at: u64,
|
||||
) -> DirectTurnStreamItem {
|
||||
DirectTurnStreamItem {
|
||||
schema_version: DIRECT_TURN_STREAM_SCHEMA_VERSION.to_string(),
|
||||
id: direct_turn_stream_tool_item_id(turn_id, &call.id),
|
||||
turn_id: turn_id.trim().to_string(),
|
||||
kind: DIRECT_TURN_STREAM_KIND_TOOL.to_string(),
|
||||
text: None,
|
||||
call_id: Some(call.id.trim().to_string()),
|
||||
seq,
|
||||
at,
|
||||
updated_at: call.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
/// 文本脱敏 + 截断:与 `tool-calls.jsonl` 同一套 `sanitize_detail_text`。
|
||||
pub(crate) fn sanitize_stream_text(root: &Path, text: &str) -> String {
|
||||
let sanitized = sanitize_detail_text(root, text);
|
||||
if sanitized.chars().count() <= DIRECT_TURN_STREAM_TEXT_MAX_CHARS {
|
||||
return sanitized;
|
||||
}
|
||||
let mut truncated = sanitized
|
||||
.chars()
|
||||
.take(DIRECT_TURN_STREAM_TEXT_MAX_CHARS)
|
||||
.collect::<String>();
|
||||
truncated.push('…');
|
||||
truncated
|
||||
}
|
||||
|
||||
fn record_line(item: &DirectTurnStreamItem) -> Result<String, String> {
|
||||
serde_json::to_string(&serde_json::json!({
|
||||
"type": DIRECT_TURN_STREAM_RECORD_TYPE,
|
||||
"payload": item,
|
||||
}))
|
||||
.map_err(|error| format!("序列化回合流条目失败:{error}"))
|
||||
}
|
||||
|
||||
/// 解析一行信封;坏行 / 非本文件条目都返回 `None`(尽力而为的展示数据,不整体失败)。
|
||||
fn stream_item_from_line(line: &str) -> Option<DirectTurnStreamItem> {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let parsed: Value = serde_json::from_str(trimmed).ok()?;
|
||||
if parsed.get("type").and_then(Value::as_str) != Some(DIRECT_TURN_STREAM_RECORD_TYPE) {
|
||||
return None;
|
||||
}
|
||||
let payload = parsed.get("payload")?;
|
||||
let mut item: DirectTurnStreamItem = serde_json::from_value(payload.clone()).ok()?;
|
||||
if item.id.trim().is_empty() || item.turn_id.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
if !matches!(
|
||||
item.kind.as_str(),
|
||||
DIRECT_TURN_STREAM_KIND_TEXT | DIRECT_TURN_STREAM_KIND_TOOL
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
if item.schema_version.trim().is_empty() {
|
||||
item.schema_version = DIRECT_TURN_STREAM_SCHEMA_VERSION.to_string();
|
||||
}
|
||||
Some(item)
|
||||
}
|
||||
|
||||
fn read_stream_lines(path: &Path) -> Vec<DirectTurnStreamItem> {
|
||||
let Ok(file) = File::open(path) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut reader = BufReader::new(file);
|
||||
let mut buffer = Vec::new();
|
||||
let mut items = Vec::new();
|
||||
loop {
|
||||
buffer.clear();
|
||||
match reader.read_until(b'\n', &mut buffer) {
|
||||
Ok(0) => break,
|
||||
// 单行解码失败(非法 UTF-8)只跳过这一行,继续读后面的行。
|
||||
Ok(_) => match std::str::from_utf8(&buffer) {
|
||||
Ok(line) => {
|
||||
if let Some(item) = stream_item_from_line(line) {
|
||||
items.push(item);
|
||||
}
|
||||
}
|
||||
Err(_) => continue,
|
||||
},
|
||||
// 读 I/O 错误:无法再定位下一行边界,停止读取(已读到的照常返回)。
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
items
|
||||
}
|
||||
|
||||
/// 同一 id 的重复行合并:`seq` 取最早(位置钉死,后到的不得回退),`at` 取最早非零,
|
||||
/// `updated_at` 取最大;文本只在更新(或同刻更长)的快照上替换。
|
||||
fn merge_stream_snapshot(
|
||||
existing: &DirectTurnStreamItem,
|
||||
incoming: &DirectTurnStreamItem,
|
||||
) -> DirectTurnStreamItem {
|
||||
let text_len = |item: &DirectTurnStreamItem| {
|
||||
item.text
|
||||
.as_deref()
|
||||
.map(str::chars)
|
||||
.map(Iterator::count)
|
||||
.unwrap_or_default()
|
||||
};
|
||||
// writer 保证更新时间单调;完成快照可以纠正正文,旧快照不能靠更长抢回所有权。
|
||||
let take_incoming = incoming.updated_at > existing.updated_at
|
||||
|| (incoming.updated_at == existing.updated_at && text_len(incoming) > text_len(existing));
|
||||
let mut merged = existing.clone();
|
||||
if take_incoming {
|
||||
merged.text = incoming.text.clone();
|
||||
}
|
||||
merged.updated_at = merged.updated_at.max(incoming.updated_at);
|
||||
if merged.call_id.is_none() {
|
||||
merged.call_id = incoming.call_id.clone();
|
||||
}
|
||||
merged.seq = merged.seq.min(incoming.seq);
|
||||
merged.at = [merged.at, incoming.at]
|
||||
.into_iter()
|
||||
.filter(|at| *at > 0)
|
||||
.min()
|
||||
.unwrap_or_default();
|
||||
merged
|
||||
}
|
||||
|
||||
/// 按身份归并;跨回合按起点,回合内按 seq,不能用局部 seq 判断全局新旧。
|
||||
fn normalize_stream_items(items: Vec<DirectTurnStreamItem>) -> Vec<DirectTurnStreamItem> {
|
||||
let mut by_id: BTreeMap<String, DirectTurnStreamItem> = BTreeMap::new();
|
||||
for item in items {
|
||||
let merged = match by_id.remove(&item.id) {
|
||||
Some(previous) => merge_stream_snapshot(&previous, &item),
|
||||
None => item,
|
||||
};
|
||||
by_id.insert(merged.id.clone(), merged);
|
||||
}
|
||||
let mut normalized = by_id.into_values().collect::<Vec<_>>();
|
||||
let mut turn_starts = BTreeMap::<String, u64>::new();
|
||||
for item in &normalized {
|
||||
turn_starts
|
||||
.entry(item.turn_id.clone())
|
||||
.and_modify(|at| *at = (*at).min(item.at))
|
||||
.or_insert(item.at);
|
||||
}
|
||||
normalized.sort_by(|left, right| {
|
||||
(turn_starts[&left.turn_id], &left.turn_id, left.order_key()).cmp(&(
|
||||
turn_starts[&right.turn_id],
|
||||
&right.turn_id,
|
||||
right.order_key(),
|
||||
))
|
||||
});
|
||||
if normalized.len() > DIRECT_TURN_STREAM_LIMIT {
|
||||
normalized.drain(..normalized.len() - DIRECT_TURN_STREAM_LIMIT);
|
||||
}
|
||||
normalized
|
||||
}
|
||||
|
||||
/// 锁内读改写:整文件重写(追加与就地更新混用,没有纯追加的 JSONL 语义)。
|
||||
/// 文件规模由 400 条上限与 8000 字符截断兜住。
|
||||
fn with_locked_stream_items<T>(
|
||||
root: &Path,
|
||||
mutate: impl FnOnce(&mut Vec<DirectTurnStreamItem>) -> T,
|
||||
) -> Result<T, String> {
|
||||
let path = turn_stream_path(root);
|
||||
let _project_lock = crate::acquire_game_creator_agent_runtime_project_write_lock_with_wait(
|
||||
root,
|
||||
"conversation.write",
|
||||
)?;
|
||||
let lock = project_append_lock_for(&path)?;
|
||||
let _append_guard = lock.lock("回合流写入")?;
|
||||
let mut items = read_stream_lines(&path);
|
||||
let outcome = mutate(&mut items);
|
||||
let normalized = normalize_stream_items(items);
|
||||
let mut body = String::new();
|
||||
for item in &normalized {
|
||||
body.push_str(&record_line(item)?);
|
||||
body.push('\n');
|
||||
}
|
||||
write_game_creator_private_file(&path, body.as_bytes(), "回合流历史")?;
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
/// 幂等 upsert 一条回合流条目。
|
||||
///
|
||||
/// 位置(`seq` / `at`)只在第一次出现时确定:同一 id 的后续快照不得回退位置,
|
||||
/// 也不得把已经写下的文本改短(并发落盘下"后到的旧快照"不会覆盖新快照)。
|
||||
pub(crate) fn upsert_direct_turn_stream_item_at(
|
||||
root: &Path,
|
||||
item: &DirectTurnStreamItem,
|
||||
) -> Result<(), String> {
|
||||
enforce_project_permission_policy(root, "conversation.write")?;
|
||||
with_locked_stream_items(root, |items| {
|
||||
// normalize_stream_items 在锁内归并全部版本;不得提前删除比较基准。
|
||||
items.push(item.clone());
|
||||
})
|
||||
}
|
||||
|
||||
/// 追加一段固定身份的文本段(失败说明等):位置排在当前流末尾。
|
||||
///
|
||||
/// 幂等:同一 `(turnId, itemId)` 已经存在时只更新文本与 `updatedAt`(回合重放 / 重复收尾
|
||||
/// 不会多出一段)。返回写下的那一条,调用方用它下发同一份快照。
|
||||
pub(crate) fn append_direct_turn_stream_text_at(
|
||||
root: &Path,
|
||||
turn_id: &str,
|
||||
item_id: &str,
|
||||
text: &str,
|
||||
) -> Result<Option<DirectTurnStreamItem>, String> {
|
||||
let turn_id = turn_id.trim();
|
||||
let text = text.trim();
|
||||
if turn_id.is_empty() || text.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let sanitized = sanitize_stream_text(root, text);
|
||||
let item_id = item_id.trim();
|
||||
enforce_project_permission_policy(root, "conversation.write")?;
|
||||
let now = crate::agent::direct_tool_call_now_ms();
|
||||
with_locked_stream_items(root, |items| {
|
||||
let existing_id = direct_turn_stream_text_item_id(turn_id, item_id);
|
||||
if let Some(existing) = items.iter_mut().find(|item| item.id == existing_id) {
|
||||
// 位置不动:只替换文本与 updatedAt。
|
||||
existing.text = Some(sanitized.clone());
|
||||
existing.updated_at = now.max(existing.updated_at);
|
||||
return Some(existing.clone());
|
||||
}
|
||||
// 首次出现:位置钉在末尾(当前最大 seq + 1)。
|
||||
let next_seq = items.iter().map(|item| item.seq).max().unwrap_or(0) + 1;
|
||||
let item = DirectTurnStreamItem {
|
||||
schema_version: DIRECT_TURN_STREAM_SCHEMA_VERSION.to_string(),
|
||||
id: existing_id,
|
||||
turn_id: turn_id.to_string(),
|
||||
kind: DIRECT_TURN_STREAM_KIND_TEXT.to_string(),
|
||||
text: Some(sanitized),
|
||||
call_id: None,
|
||||
seq: next_seq,
|
||||
at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
items.push(item.clone());
|
||||
Some(item)
|
||||
})
|
||||
}
|
||||
|
||||
/// 没有任何 item 文本时补最终回复;已有 item 由完成事件负责,不能猜测覆盖某一段。
|
||||
pub(crate) fn finalize_direct_turn_stream_reply_at(
|
||||
root: &Path,
|
||||
turn_id: &str,
|
||||
visible_reply: &str,
|
||||
) -> Result<Option<DirectTurnStreamItem>, String> {
|
||||
let turn_id = turn_id.trim();
|
||||
if turn_id.is_empty() || visible_reply.trim().is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
// 入口再做一次可见性投影:调用方给的是原始回复时,思考块不能落进对话流。
|
||||
let visible_reply = crate::agent::project_direct_codex_visible_text(visible_reply)
|
||||
.unwrap_or_else(|| visible_reply.trim().to_string());
|
||||
let visible_reply = visible_reply.as_str();
|
||||
enforce_project_permission_policy(root, "conversation.write")?;
|
||||
let now = crate::agent::direct_tool_call_now_ms();
|
||||
with_locked_stream_items(root, |items| {
|
||||
if items
|
||||
.iter()
|
||||
.any(|item| item.turn_id == turn_id && item.kind == DIRECT_TURN_STREAM_KIND_TEXT)
|
||||
{
|
||||
None
|
||||
} else {
|
||||
let next_seq = items
|
||||
.iter()
|
||||
.filter(|item| item.turn_id == turn_id)
|
||||
.map(|item| item.seq)
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
+ 1;
|
||||
let item = direct_turn_stream_text_item(
|
||||
root,
|
||||
turn_id,
|
||||
DIRECT_TURN_STREAM_FINAL_ITEM_ID,
|
||||
visible_reply,
|
||||
next_seq,
|
||||
now,
|
||||
now,
|
||||
);
|
||||
items.push(item.clone());
|
||||
Some(item)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -48,7 +48,6 @@ pub(in crate::agent) use external_generation_state::{
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use external_generation_state::{
|
||||
platform_art_generation_external_configuration_fingerprint,
|
||||
platform_art_generation_legacy_external_configuration_fingerprint,
|
||||
setup_platform_art_generation_runtime_accepted_for_recovery_test,
|
||||
write_platform_art_generation_runtime_accepted_for_test,
|
||||
|
||||
@@ -6,6 +6,7 @@ pub(crate) struct AgentProgressEmitter<'a> {
|
||||
}
|
||||
|
||||
impl<'a> AgentProgressEmitter<'a> {
|
||||
#[cfg(not(test))]
|
||||
pub(crate) fn new(app: &'a tauri::AppHandle, project_path: &str) -> Self {
|
||||
Self {
|
||||
app,
|
||||
|
||||
+8
-47
@@ -817,24 +817,17 @@ mod tests {
|
||||
use super::{
|
||||
acquire_game_creator_agent_provider_plan_project_write_lock_with_wait,
|
||||
agent_runtime_root_source_at, bind_game_creator_agent_runtime_run_profile_at,
|
||||
build_game_creator_agent_background_final_reply_request,
|
||||
build_game_creator_agent_background_tool_plan_request_locked,
|
||||
game_creator_agent_context_preload_notice,
|
||||
game_creator_agent_runtime_run_profile_binding_path,
|
||||
game_creator_project_supervisor_chat_system_prompt, init_local_game_project_at,
|
||||
new_game_creation_app_seed_tasks, provider_command_exec_contract,
|
||||
provider_command_start_contract,
|
||||
game_creator_agent_context_preload_notice, init_local_game_project_at,
|
||||
new_game_creation_app_seed_tasks,
|
||||
render_relaxed_autonomous_manifest_ready_task_background_prompt,
|
||||
required_runtime_prompt_section, start_game_creator_agent_runtime_task_at,
|
||||
AgentRuntimeGoalContractAcceptanceNodeDraft, AgentRuntimeGoalContractDraft,
|
||||
AgentRuntimeTaskLink, AgentRuntimeToolObservation, AgentRuntimeToolPlan,
|
||||
start_game_creator_agent_runtime_task_at, AgentRuntimeGoalContractAcceptanceNodeDraft,
|
||||
AgentRuntimeGoalContractDraft, AgentRuntimeTaskLink, AgentRuntimeToolObservation,
|
||||
AgentRuntimeToolPlanRequestSnapshot, GameCreatorLlmConfig,
|
||||
AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT,
|
||||
AGENT_RUNTIME_COMPLETION_BLOCKER_TOOL_PLAN_PROTOCOL, AGENT_RUNTIME_RESPOND_FUNCTION_NAME,
|
||||
AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, AGENT_RUNTIME_RUN_PROFILE_STANDARD,
|
||||
AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE,
|
||||
AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||||
RUNTIME_PROMPT_SUPERVISOR_CHAT_COMPOSITION,
|
||||
AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT, AGENT_RUNTIME_RESPOND_FUNCTION_NAME,
|
||||
AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE,
|
||||
AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME,
|
||||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||||
};
|
||||
|
||||
fn build_game_creator_agent_background_tool_plan_request_for_test(
|
||||
@@ -871,24 +864,6 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
fn native_input_required_fields(
|
||||
request: &platform_llm::LlmRunRequest,
|
||||
tool: &str,
|
||||
) -> Vec<String> {
|
||||
let function_name = crate::agent_native_tools::native_runtime_function_name(tool)
|
||||
.expect("native runtime function name");
|
||||
request
|
||||
.function_tools
|
||||
.iter()
|
||||
.find(|function| function.name == function_name)
|
||||
.and_then(|function| function.parameters.pointer("/properties/input/required"))
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.expect("native input required fields")
|
||||
.iter()
|
||||
.map(|field| field.as_str().expect("required field string").to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relaxed_request_keeps_general_catalog_after_plan_rejection() {
|
||||
let directory = crate::tests::canonical_test_tempdir("provider-plan-rejection-repair-");
|
||||
@@ -1437,20 +1412,6 @@ mod tests {
|
||||
assert!(!prompt.contains("本轮唯一可用工具是 agent.goal_contract"));
|
||||
}
|
||||
|
||||
fn completion_blocker_protocol_requires_tool_repair_before_response() {
|
||||
let protocol = AGENT_RUNTIME_COMPLETION_BLOCKER_TOOL_PLAN_PROTOCOL;
|
||||
|
||||
assert!(protocol.contains("最新 observation"));
|
||||
assert!(protocol.contains("runtime.autonomous_completion"));
|
||||
assert!(protocol.contains("status 为 blocked"));
|
||||
assert!(protocol.contains("才能调用 respond_to_user"));
|
||||
assert!(!protocol.contains("legacy response"));
|
||||
assert!(protocol.contains("observation.detail 的 nextRequiredAction"));
|
||||
assert!(protocol.contains("读取、修复和验证工具"));
|
||||
assert!(protocol.contains("完成要求的动作、取得后续可信 observation"));
|
||||
assert!(protocol.contains("通过完成门禁后"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supervisor_request_snapshot_preserves_prompt_visible_running_sibling_after_manifest_failure()
|
||||
{
|
||||
|
||||
@@ -297,18 +297,18 @@ pub(crate) use entrypoints::{
|
||||
generate_local_game_draft_at, read_game_creator_agent_runtime_at,
|
||||
read_game_creator_agent_runtime_for_session_at, read_game_creator_agent_runtimes_at,
|
||||
register_game_creator_manifest_invalidation_event_sink,
|
||||
validate_game_creator_manifest_invalidation_event_sink,
|
||||
};
|
||||
#[cfg(not(test))]
|
||||
pub(crate) use entrypoints::{
|
||||
set_game_creator_agent_runtime_update_app_handle,
|
||||
start_game_creator_manifest_invalidation_event_sink,
|
||||
validate_game_creator_manifest_invalidation_event_sink,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use finalization::resume_game_creator_agent_finalization_for_test_at;
|
||||
pub(crate) use finalization::AgentRuntimePendingActionResume;
|
||||
#[cfg(test)]
|
||||
pub(crate) use interaction::{
|
||||
acquire_game_creator_agent_runtime_user_input_answer_locks_for_test,
|
||||
agent_runtime_tool_requires_repository_context_fingerprint_gate,
|
||||
};
|
||||
pub(crate) use interaction::agent_runtime_tool_requires_repository_context_fingerprint_gate;
|
||||
pub(crate) use interaction::{
|
||||
answer_game_creator_agent_runtime_user_input_at, confirm_game_creator_agent_runtime_task_at,
|
||||
pending_repository_context_drift_observation, reject_game_creator_agent_runtime_task_at,
|
||||
|
||||
@@ -17,6 +17,7 @@ fn lock_game_creator_manifest_invalidation_event_sinks(
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
pub(crate) fn set_game_creator_agent_runtime_update_app_handle(app: tauri::AppHandle) {
|
||||
let _ = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.set(app);
|
||||
}
|
||||
@@ -60,7 +61,6 @@ pub(crate) fn emit_direct_game_creator_progress(root: &Path, stage: &str, messag
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct DirectGameCreatorTurnUpdateEmitter {
|
||||
project_path: String,
|
||||
/// Thread Manager 的线程身份:进度只回填到"这一轮仍被占用"的那一格上。
|
||||
thread_id: String,
|
||||
turn_id: String,
|
||||
@@ -70,74 +70,14 @@ pub(crate) struct DirectGameCreatorTurnUpdateEmitter {
|
||||
impl DirectGameCreatorTurnUpdateEmitter {
|
||||
pub(crate) fn new(root: &Path, turn_id: String) -> Self {
|
||||
Self {
|
||||
project_path: root.to_string_lossy().into_owned(),
|
||||
thread_id: crate::agent::thread_id_for_project(root),
|
||||
turn_id,
|
||||
sequence: Arc::new(AtomicU64::new(0)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn emit(
|
||||
&self,
|
||||
status: &'static str,
|
||||
activity: Option<&'static str>,
|
||||
accumulated_text: Option<String>,
|
||||
tool_calls: Option<Vec<crate::DirectToolCall>>,
|
||||
) {
|
||||
self.emit_with_reasoning(status, activity, accumulated_text, tool_calls, None);
|
||||
}
|
||||
|
||||
/// 带思考过程的回合更新:`reasoning_text` 为"当前累计的思考全文"(前端整段替换)。
|
||||
pub(crate) fn emit_with_reasoning(
|
||||
&self,
|
||||
status: &'static str,
|
||||
activity: Option<&'static str>,
|
||||
accumulated_text: Option<String>,
|
||||
tool_calls: Option<Vec<crate::DirectToolCall>>,
|
||||
reasoning_text: Option<String>,
|
||||
) {
|
||||
self.emit_full(
|
||||
status,
|
||||
activity,
|
||||
accumulated_text,
|
||||
tool_calls,
|
||||
reasoning_text,
|
||||
Vec::new(),
|
||||
);
|
||||
}
|
||||
|
||||
/// 带回合流的回合更新:`stream_items` 是"顺序真相"里本次变化的那几条。
|
||||
///
|
||||
/// 前端按这些条目的 `seq` 顺序渲染,所以它们必须来自与落盘同一份数据,
|
||||
/// 不能在前端各算一套顺序。
|
||||
pub(crate) fn emit_with_stream_items(
|
||||
&self,
|
||||
status: &'static str,
|
||||
activity: Option<&'static str>,
|
||||
accumulated_text: Option<String>,
|
||||
tool_calls: Option<Vec<crate::DirectToolCall>>,
|
||||
stream_items: Vec<crate::DirectTurnStreamItem>,
|
||||
) {
|
||||
self.emit_full(
|
||||
status,
|
||||
activity,
|
||||
accumulated_text,
|
||||
tool_calls,
|
||||
None,
|
||||
stream_items,
|
||||
);
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn emit_full(
|
||||
&self,
|
||||
status: &'static str,
|
||||
activity: Option<&'static str>,
|
||||
accumulated_text: Option<String>,
|
||||
tool_calls: Option<Vec<crate::DirectToolCall>>,
|
||||
reasoning_text: Option<String>,
|
||||
stream_items: Vec<crate::DirectTurnStreamItem>,
|
||||
) {
|
||||
/// 回填这一轮逻辑回合的进度(状态 / 活动 / 序号);首页「运行中的项目」快照读它。
|
||||
pub(crate) fn emit(&self, status: &'static str, activity: Option<&'static str>) {
|
||||
let status_is_allowed = matches!(
|
||||
status,
|
||||
"accepted" | "running" | "streaming" | "finalizing" | "completed" | "failed"
|
||||
@@ -154,6 +94,8 @@ impl DirectGameCreatorTurnUpdateEmitter {
|
||||
| "controlled-tool"
|
||||
| "web-search"
|
||||
| "context-compaction"
|
||||
| "host-review"
|
||||
| "error-feedback"
|
||||
| "response-finalization"
|
||||
| "none"
|
||||
)
|
||||
@@ -163,11 +105,7 @@ impl DirectGameCreatorTurnUpdateEmitter {
|
||||
return;
|
||||
}
|
||||
let sequence = self.sequence.fetch_add(1, Ordering::AcqRel) + 1;
|
||||
let updated_at = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis()
|
||||
.min(u64::MAX as u128) as u64;
|
||||
let updated_at = crate::agent::direct_now_ms();
|
||||
update_active_turn(
|
||||
&self.thread_id,
|
||||
&self.turn_id,
|
||||
@@ -176,24 +114,6 @@ impl DirectGameCreatorTurnUpdateEmitter {
|
||||
sequence,
|
||||
updated_at,
|
||||
);
|
||||
let Some(app) = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.get() else {
|
||||
return;
|
||||
};
|
||||
let _ = app.emit(
|
||||
"game-creator-direct-turn-update",
|
||||
GameCreatorDirectTurnUpdateEvent {
|
||||
project_path: self.project_path.clone(),
|
||||
turn_id: self.turn_id.clone(),
|
||||
sequence,
|
||||
status: status.to_string(),
|
||||
activity: activity.map(str::to_string),
|
||||
accumulated_text,
|
||||
tool_calls,
|
||||
reasoning_text,
|
||||
stream_items: (!stream_items.is_empty()).then_some(stream_items),
|
||||
updated_at,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn turn_id(&self) -> &str {
|
||||
@@ -201,6 +121,7 @@ impl DirectGameCreatorTurnUpdateEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
pub(crate) fn start_game_creator_manifest_invalidation_event_sink(
|
||||
app: tauri::AppHandle,
|
||||
) -> Result<GameCreatorManifestInvalidationEventSink, String> {
|
||||
@@ -1015,3 +936,33 @@ pub(crate) fn read_game_creator_agent_runtimes_at(
|
||||
.map(|agent_id| read_game_creator_agent_runtime_at(root, &agent_id))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// DirectRuntime 实际传过的活动值必须都在 `emit` 白名单里:漏一个会在 debug 构建 panic、
|
||||
/// 在 release 构建静默丢掉整次进度回填(首页「运行中的项目」不再随这一轮更新)。
|
||||
#[test]
|
||||
fn emit_accepts_every_activity_the_direct_runtime_passes() {
|
||||
let root = std::env::temp_dir().join("agc-emit-activity-guard");
|
||||
let emitter = DirectGameCreatorTurnUpdateEmitter::new(&root, "turn-guard".to_string());
|
||||
for activity in [
|
||||
"request-accepted",
|
||||
"preparing",
|
||||
"file-read",
|
||||
"file-write",
|
||||
"game-verify",
|
||||
"command-exec",
|
||||
"controlled-tool",
|
||||
"web-search",
|
||||
"context-compaction",
|
||||
"host-review",
|
||||
"error-feedback",
|
||||
"response-finalization",
|
||||
"none",
|
||||
] {
|
||||
emitter.emit("running", Some(activity));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -477,20 +477,6 @@ fn resolve_game_creator_agent_runtime_user_input_action_with_ordered_locks(
|
||||
Ok((None, runtime_lock, resolved))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn acquire_game_creator_agent_runtime_user_input_answer_locks_for_test(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
run_id: &str,
|
||||
action_id: &str,
|
||||
) -> Result<(Option<ProjectWriteLock>, AgentRuntimeTaskLock), String> {
|
||||
let (project_lock, runtime_lock, _) =
|
||||
resolve_game_creator_agent_runtime_user_input_action_with_ordered_locks(
|
||||
root, agent_id, run_id, action_id,
|
||||
)?;
|
||||
Ok((project_lock, runtime_lock))
|
||||
}
|
||||
|
||||
pub(crate) fn answer_game_creator_agent_runtime_user_input_at(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
|
||||
@@ -89,6 +89,8 @@ pub(crate) use run_configuration::{
|
||||
game_creator_agent_runtime_project_revision_path,
|
||||
game_creator_agent_runtime_run_profile_binding_path,
|
||||
};
|
||||
#[cfg(not(test))]
|
||||
pub(crate) use steering::game_creator_agent_runtime_provider_request_count_for_roots;
|
||||
#[cfg(test)]
|
||||
pub(crate) use steering::{
|
||||
acquire_game_creator_agent_runtime_steer_project_write_lock_with_wait,
|
||||
@@ -96,7 +98,6 @@ pub(crate) use steering::{
|
||||
};
|
||||
pub(crate) use steering::{
|
||||
consume_game_creator_agent_runtime_steers,
|
||||
game_creator_agent_runtime_provider_request_count_for_roots,
|
||||
interrupt_game_creator_agent_runtime_provider_request_at,
|
||||
interrupt_game_creator_agent_runtime_provider_requests_for_roots,
|
||||
register_game_creator_agent_runtime_provider_request,
|
||||
|
||||
-5
@@ -11943,11 +11943,6 @@ impl ExternalGameplayJavascript {
|
||||
.iter()
|
||||
.any(|unit| unit.to_ascii_lowercase().contains(&marker))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(in crate::agent) fn module_units(&self) -> &[String] {
|
||||
&self.module_units
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_javascript_module_analysis_sources(
|
||||
|
||||
@@ -44,9 +44,7 @@ pub(in crate::agent) use ui_design_doc::*;
|
||||
#[cfg(test)]
|
||||
pub(crate) use delegation::observe_agent_runtime_agent_delegate_at_locked;
|
||||
#[cfg(test)]
|
||||
pub(crate) use delivery::{
|
||||
build_static_delegate_result_for_child_at, wake_waiting_static_delegate_parent_run_for_test_at,
|
||||
};
|
||||
pub(crate) use delivery::build_static_delegate_result_for_child_at;
|
||||
#[cfg(test)]
|
||||
pub(crate) use isolated_joins::render_isolated_join_status_batch_with_limit;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -408,14 +408,6 @@ pub(in crate::agent) fn wake_waiting_static_delegate_parent_run_at(
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn wake_waiting_static_delegate_parent_run_for_test_at(
|
||||
root: &Path,
|
||||
parent_task: &AgentRuntimeTaskRecord,
|
||||
) -> Result<bool, String> {
|
||||
wake_waiting_static_delegate_parent_run_at(root, parent_task)
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn wake_waiting_autonomous_manifest_parent_run_at(
|
||||
root: &Path,
|
||||
parent_task: &AgentRuntimeTaskRecord,
|
||||
|
||||
@@ -17,7 +17,7 @@ use std::path::{Path, PathBuf};
|
||||
use crate::agent::PendingTurn;
|
||||
use crate::agent::{
|
||||
append_direct_project_user_message_at, claim_pending_turn, complete_turn_if_reserved,
|
||||
direct_codex_user_item_to_prompt, direct_tool_call_now_ms, redact_agent_runtime_error,
|
||||
direct_codex_user_item_to_prompt, direct_now_ms, redact_agent_runtime_error,
|
||||
run_direct_game_creator_turn_at_with_creation_type_and_emitter, thread_id_for_project,
|
||||
DirectGameCreatorTurnUpdateEmitter, DirectTurnError, DirectTurnTerminal, DispatchedTurn,
|
||||
};
|
||||
@@ -55,7 +55,7 @@ impl TurnReservation {
|
||||
complete_turn_if_reserved(
|
||||
&self.thread_id,
|
||||
&self.token,
|
||||
terminal.event(direct_tool_call_now_ms(), self.user_item_id.as_deref()),
|
||||
terminal.event(direct_now_ms(), self.user_item_id.as_deref()),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ impl TurnReservation {
|
||||
}))
|
||||
.expect("canonical user item"),
|
||||
None,
|
||||
direct_tool_call_now_ms(),
|
||||
direct_now_ms(),
|
||||
);
|
||||
super::enqueue_pending_turn(thread_id, pending).expect("enqueue test turn");
|
||||
let dispatched = claim_pending_turn(thread_id).expect("claim test turn");
|
||||
@@ -160,9 +160,9 @@ async fn run_dispatched_direct_turn(
|
||||
)
|
||||
.await;
|
||||
match outcome {
|
||||
Ok(reply) => {
|
||||
Ok(_) => {
|
||||
// 深层的终态出口已经在 `run_turn` 里写出 `turn.completed`;这里只补最后一条回合更新。
|
||||
emitter.emit("completed", Some("none"), Some(reply), None);
|
||||
emitter.emit("completed", Some("none"));
|
||||
}
|
||||
Err(error) => {
|
||||
// 放行之后的失败一律是回合失败:失败诊断与失败说明已由上层写过,这里补终态事件。
|
||||
@@ -211,7 +211,7 @@ mod tests {
|
||||
}))
|
||||
.expect("canonical user item"),
|
||||
None,
|
||||
direct_tool_call_now_ms(),
|
||||
direct_now_ms(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -17,9 +17,9 @@ use std::sync::{Mutex, OnceLock};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::agent::{
|
||||
direct_codex_user_item_id_for_client_turn_id, direct_tool_call_now_ms, queue_has_room,
|
||||
ConsumeResult, EnqueueOutcome, EnqueueRejection, PendingTurn, QueueRemovalOutcome,
|
||||
QueueRemovalReason, SubscriptionBootstrap, ThreadEvent,
|
||||
direct_codex_user_item_id_for_client_turn_id, direct_now_ms, queue_has_room, ConsumeResult,
|
||||
EnqueueOutcome, EnqueueRejection, PendingTurn, QueueRemovalOutcome, QueueRemovalReason,
|
||||
SubscriptionBootstrap, ThreadEvent,
|
||||
};
|
||||
|
||||
const DEFAULT_MAX_EVENTS: usize = 8_192;
|
||||
@@ -854,7 +854,7 @@ pub(crate) fn remove_pending_turn(thread_id: &str, client_turn_id: &str) -> Queu
|
||||
global_thread_manager()
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.remove_pending_turn(thread_id, client_turn_id, direct_tool_call_now_ms())
|
||||
.remove_pending_turn(thread_id, client_turn_id, direct_now_ms())
|
||||
};
|
||||
if outcome == QueueRemovalOutcome::Removed {
|
||||
notify_subscribers(thread_id);
|
||||
@@ -873,7 +873,7 @@ pub(crate) fn claim_pending_turn(thread_id: &str) -> Option<DispatchedTurn> {
|
||||
global_thread_manager()
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.claim_pending_turn(thread_id, direct_tool_call_now_ms())
|
||||
.claim_pending_turn(thread_id, direct_now_ms())
|
||||
};
|
||||
if claimed.is_some() {
|
||||
notify_subscribers(thread_id);
|
||||
@@ -954,7 +954,7 @@ pub(crate) fn release_stale_direct_turn(
|
||||
thread_id,
|
||||
expected_client_turn_id,
|
||||
min_age_ms,
|
||||
direct_tool_call_now_ms(),
|
||||
direct_now_ms(),
|
||||
)
|
||||
};
|
||||
if matches!(outcome, StaleTurnRelease::Released(_)) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user