Compare commits
127 Commits
fix/chat-state
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| f7a6235012 | |||
| 65da364b16 | |||
| 86c92280cf | |||
| bcbfe92afa | |||
| d8d3a50b88 | |||
| 8fcbd0be52 | |||
| 3c0c2b8104 | |||
| 285a867420 | |||
| 62bb16a3c6 | |||
| 32a1b283e5 | |||
| 195f47d720 | |||
| c996b5c127 | |||
| da74bf4817 | |||
| 677b9953dd | |||
| 9df73a200a | |||
| df415aff39 | |||
| e119f3a2b1 | |||
| aa0b180044 | |||
| 994fcd6b20 | |||
| 6b3952ce0c | |||
| bfc7eaa794 | |||
| 6ae188334b | |||
| 5cd02aa8b0 | |||
| d3c117de01 | |||
| 60d6619598 | |||
| 7a7a841733 | |||
| a4a030dfb5 | |||
| bca198a17e | |||
| d650bf9b74 | |||
| 9722442b44 | |||
| d8a167e0f3 | |||
| 09791eb9e4 | |||
| e90f1473c6 | |||
| a86672bb23 | |||
| bef1a42506 | |||
| c229a353be | |||
| a7dc2a9553 | |||
| 1a8c30c865 | |||
| e72a2da733 | |||
| ca9cde1b91 | |||
| 37f1e8a67b | |||
| 69b3b016b5 | |||
| fe02a99f62 | |||
| 5da6ff46a1 | |||
| 3f558bc1c1 | |||
| ab7ffded2f | |||
| ce6dcea018 | |||
| 0ce5eb35b0 | |||
| f4890d02b5 | |||
| 08eb475845 | |||
| 792530ad24 | |||
| cae6006a96 | |||
| f1632533df | |||
| 5519f9e3a7 | |||
| 44786066b0 | |||
| 2e2ff552f7 | |||
| b4e6f7e07e | |||
| 67126a981e | |||
| 2a9035ce20 | |||
| 7d5916aa62 | |||
| 9b7f7f110c | |||
| eeb0206164 | |||
| ab8e744ae7 | |||
| c85e8afea8 | |||
| 656dd2be2a | |||
| 8b5d0e9200 | |||
| 7043e8db5c | |||
| a40b7b31ee | |||
| a8559597ca | |||
| 5508456d3e | |||
| 96f1889164 | |||
| 7970bc68bd | |||
| 1b8053de15 | |||
| d6420c8db5 | |||
| e39714e17d | |||
| 05b4f9bc87 | |||
| d7f20a2f4d | |||
| 1b8e159153 | |||
| 1c1009c1d1 | |||
| b35f3efd55 | |||
| a3cedb5d50 | |||
| 7161e934e0 | |||
| e788bf0fea | |||
| d833ca9d32 | |||
| 48b482afaf | |||
| 52f1d7f08b | |||
| aab1c5c4e0 | |||
| 33a7205750 | |||
| b665cf6691 | |||
| 80a806aa02 | |||
| 608c3e8790 | |||
| 2c80aab621 | |||
| 1427ce31af | |||
| 426fb9b728 | |||
| 82ac6c160f | |||
| 0d2010168e | |||
| 24c8631869 | |||
| 35b26ea3ef | |||
| 802114e14b | |||
| cf7e4775b9 | |||
| 5728e64742 | |||
| f1da01111e | |||
| 19c3703027 | |||
| 3169baa2eb | |||
| b395185580 | |||
| 885f2f10f3 | |||
| 1f13412dde | |||
| bc89bf45c3 | |||
| abaae50c43 | |||
| a766ff9634 | |||
| a0efd92192 | |||
| 1a2331bdb1 | |||
| b8ff3cfb56 | |||
| 1b40822421 | |||
| 89e842d077 | |||
| 9f7aed0b2d | |||
| 35a46e9a44 | |||
| a60c04b53d | |||
| 99fd319607 | |||
| 58f6eceb69 | |||
| abbf1465e2 | |||
| 6ba86f5061 | |||
| 7adabea656 | |||
| bc796c1db4 | |||
| 8410426d12 | |||
| cd816078a3 | |||
| 92db1e7065 |
@@ -57,6 +57,7 @@
|
||||
"react-colorful": "^5.8.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"vite": "^6.2.0",
|
||||
"zustand": "^5.0.14"
|
||||
|
||||
+1
@@ -13,6 +13,7 @@ Use `agc_browser_playtest` from the `agc_tools` MCP server. Do not replace it wi
|
||||
2. Inspect both desktop and mobile results, including page readiness, visible text, screenshots, console errors, exceptions, failed requests, Canvas probes, blocked actions, and interaction evidence.
|
||||
3. Compare screenshots with the user's request. Check that the active game fills its intended area, HUD elements do not cover gameplay, controls are visible, and requested platform art appears in the core experience.
|
||||
4. If evidence exposes a defect, edit the actual game files and call the tool again when that is useful. The client enforces its own execution and resource bounds; do not invent a fixed repair loop in the response.
|
||||
Feed the structured diagnostics, console errors, failed requests, and exception text back to the same LLM repair turn before reporting the playtest as failed. Treat the evidence as debugging input and rerun the affected stage after a real code or project change.
|
||||
5. Treat browser infrastructure failure, an unloaded page, an unhandled exception, or missing evidence as a failed validation. Do not claim success from a partial result.
|
||||
6. Use game-specific reasoning for quality. Do not require a fixed board, fixed text, fixed number of slices, or a legacy harness scenario; the tool result is evidence for Codex to interpret.
|
||||
|
||||
|
||||
+2
@@ -27,6 +27,8 @@ For a small edit to an existing game where the brief and suitable assets are unc
|
||||
|
||||
When a stage tool, command, or verification fails, retry at most three times before treating that stage as failed. Keep the retries serial and scoped to the same stage and the same input: a retry must not open a parallel path, skip ahead to a later stage, or substitute a placeholder for the missing output.
|
||||
|
||||
Every repairable failure must be fed back to the current LLM as the next debugging context before the stage is considered failed. Preserve the redacted tool or command error, the stage, the attempted input, and the evidence already collected; ask the LLM to inspect the current project, make the smallest real repair, and rerun the failed stage. A client-side `isError` tool result or a failed verification is feedback for the LLM, not by itself a terminal user-facing result. Do not silently swallow the error, replace it with a placeholder, or stop after the first failed attempt. Authentication, permission, billing, project identity, corrupted history, transport loss, cancellation, and uncertain paid-operation state remain terminal safety boundaries.
|
||||
|
||||
Only after the third attempt also fails, stop and tell the user the failure reason — which stage failed, which tool or command reported the error, what the error says, and what is still missing. A stage whose three attempts never succeeded is not complete, and its missing output cannot be reported as delivered.
|
||||
|
||||
Read the referenced specialist Skills for their detailed contracts: `agc-project-structure`, `taonier-art-assets`, `agc-web-game-development`, `agc-client-projection`, and `agc-browser-playtest`.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schemaVersion": "agc-skill-pack.v1",
|
||||
"version": "2026-08-26.15",
|
||||
"version": "2026-08-26.16",
|
||||
"skills": [
|
||||
{
|
||||
"name": "agc-game-production-workflow",
|
||||
@@ -22,7 +22,7 @@
|
||||
"agents/openai.yaml",
|
||||
"references/workflow-contract.md"
|
||||
],
|
||||
"sha256": "d9d8e7e0a6bc512e0b463e0e4bd77edee1cc57f4a6965c9553e0920e38985d5c"
|
||||
"sha256": "f25e5bd27e8fc82c61b08dc66366b5b253ee8d16d7fa72dbf2c94d2462f4e7fc"
|
||||
},
|
||||
{
|
||||
"name": "agc-project-structure",
|
||||
@@ -98,7 +98,7 @@
|
||||
"agents/openai.yaml",
|
||||
"references/browser-evidence-contract.md"
|
||||
],
|
||||
"sha256": "4437cd8a927a1c79a5faf4bcd40e9946676c08a3b460ab171298cabf899f49ad"
|
||||
"sha256": "92ecce42d6589e034d32b75bcd155c1fee34a8c7b843eea5780c0577300ed521"
|
||||
},
|
||||
{
|
||||
"name": "agc-client-projection",
|
||||
|
||||
@@ -22,7 +22,9 @@ mod direct_project_turn_history;
|
||||
mod direct_runtime;
|
||||
mod direct_thread_manager;
|
||||
mod direct_tool_bridge;
|
||||
mod direct_tool_calls;
|
||||
mod direct_tools_mcp;
|
||||
mod direct_turn_stream;
|
||||
mod generation;
|
||||
mod interaction;
|
||||
mod prompt;
|
||||
@@ -36,8 +38,9 @@ mod runtime_tools;
|
||||
mod skill_pack;
|
||||
use codex_app_server::*;
|
||||
pub(crate) use codex_app_server::{
|
||||
cancel_direct_codex_turn_at,
|
||||
direct_codex_canonical_project_identity_for_commands as direct_codex_canonical_project_identity,
|
||||
direct_game_creator_codex_chat_at, direct_game_creator_home_codex_chat,
|
||||
direct_game_creator_codex_chat_at, direct_game_creator_home_codex_chat, DirectTurnCancelView,
|
||||
};
|
||||
use codex_cli::*;
|
||||
pub(crate) use codex_cli::{
|
||||
@@ -53,7 +56,9 @@ pub(crate) use direct_project_turn_history::*;
|
||||
pub(crate) use direct_runtime::*;
|
||||
pub(crate) use direct_thread_manager::*;
|
||||
pub(crate) use direct_tool_bridge::*;
|
||||
pub(crate) use direct_tool_calls::*;
|
||||
pub(crate) use direct_tools_mcp::*;
|
||||
pub(crate) use direct_turn_stream::*;
|
||||
pub(crate) use generation::*;
|
||||
pub(crate) use interaction::*;
|
||||
pub(crate) use prompt::*;
|
||||
|
||||
+98
-2
@@ -7,17 +7,89 @@ use super::direct_project_history_injection_oversize_error;
|
||||
use serde_json::Value;
|
||||
use std::path::Path;
|
||||
|
||||
const DIRECT_PROJECT_HISTORY_IMAGE_TOTAL_MAX_BYTES: usize = 8 * 1024 * 1024;
|
||||
const DIRECT_PROJECT_HISTORY_IMAGE_OMITTED_TEXT: &str =
|
||||
"[历史图片预览已省略:本次恢复图片预算已用尽]";
|
||||
|
||||
fn omit_image_block(object: &mut serde_json::Map<String, Value>, text_type: &str) {
|
||||
object.clear();
|
||||
object.insert("type".to_string(), Value::String(text_type.to_string()));
|
||||
object.insert(
|
||||
"text".to_string(),
|
||||
Value::String(DIRECT_PROJECT_HISTORY_IMAGE_OMITTED_TEXT.to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
fn compact_history_images(value: &mut Value, remaining_bytes: &mut usize) {
|
||||
match value {
|
||||
Value::Array(values) => values
|
||||
.iter_mut()
|
||||
.for_each(|value| compact_history_images(value, remaining_bytes)),
|
||||
Value::Object(object) => {
|
||||
let is_image_block = object.get("type").and_then(Value::as_str) == Some("image");
|
||||
if is_image_block {
|
||||
if let Some(data) = object.get("data").and_then(Value::as_str) {
|
||||
if let Some((preview, mime_type)) = crate::agent::compact_mcp_image_data(data) {
|
||||
if preview.len() > *remaining_bytes {
|
||||
omit_image_block(object, "text");
|
||||
} else {
|
||||
*remaining_bytes -= preview.len();
|
||||
object.insert("data".to_string(), Value::String(preview));
|
||||
object.insert(
|
||||
"mimeType".to_string(),
|
||||
Value::String(mime_type.to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if object.get("type").and_then(Value::as_str) == Some("input_image") {
|
||||
if let Some(url) = object
|
||||
.get("image_url")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
{
|
||||
if let Some((header, data)) = url.split_once(",") {
|
||||
if header.ends_with(";base64") {
|
||||
if let Some((preview, mime_type)) =
|
||||
crate::agent::compact_mcp_image_data(data)
|
||||
{
|
||||
if preview.len() > *remaining_bytes {
|
||||
omit_image_block(object, "input_text");
|
||||
} else {
|
||||
*remaining_bytes -= preview.len();
|
||||
object.insert(
|
||||
"image_url".to_string(),
|
||||
Value::String(format!("data:{mime_type};base64,{preview}")),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
object
|
||||
.values_mut()
|
||||
.for_each(|value| compact_history_images(value, remaining_bytes));
|
||||
}
|
||||
Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn build_direct_project_history_injection_params(
|
||||
history_root: &Path,
|
||||
thread_id: &str,
|
||||
) -> Result<Value, platform_llm::LlmError> {
|
||||
let canonical_items = read_direct_project_history_items_at(history_root)
|
||||
.map_err(platform_llm::LlmError::InvalidRequest)?;
|
||||
let mut remaining_image_bytes = DIRECT_PROJECT_HISTORY_IMAGE_TOTAL_MAX_BYTES;
|
||||
let items = canonical_items
|
||||
.iter()
|
||||
.map(|item| {
|
||||
direct_codex_user_item_to_response_item(history_root, item)
|
||||
.map_err(platform_llm::LlmError::InvalidRequest)
|
||||
let mut projected = direct_codex_user_item_to_response_item(history_root, item)
|
||||
.map_err(platform_llm::LlmError::InvalidRequest)?;
|
||||
compact_history_images(&mut projected, &mut remaining_image_bytes);
|
||||
Ok(projected)
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let params = serde_json::json!({"threadId": thread_id, "items": items});
|
||||
@@ -30,3 +102,27 @@ pub(super) fn build_direct_project_history_injection_params(
|
||||
}
|
||||
Ok(params)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{compact_history_images, DIRECT_PROJECT_HISTORY_IMAGE_OMITTED_TEXT};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn history_image_budget_omits_only_wire_preview_when_exhausted() {
|
||||
let mut item = json!({
|
||||
"type": "function_call_output",
|
||||
"output": {"content": [{
|
||||
"type": "image",
|
||||
"data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
||||
"mimeType": "image/png"
|
||||
}]}
|
||||
});
|
||||
let mut remaining = 1;
|
||||
compact_history_images(&mut item, &mut remaining);
|
||||
let block = &item["output"]["content"][0];
|
||||
assert_eq!(block["type"], "text");
|
||||
assert_eq!(block["text"], DIRECT_PROJECT_HISTORY_IMAGE_OMITTED_TEXT);
|
||||
assert_eq!(remaining, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,6 +222,12 @@ impl CodexTurnStartCancellation {
|
||||
self.maybe_interrupt();
|
||||
}
|
||||
|
||||
/// app-server 连接是否还活着:句柄只剩 Weak 时说明进程已被回收,此时"终止"必须
|
||||
/// 明确报错,而不是静默成功让界面以为回合已经停了。
|
||||
fn app_server_alive(&self) -> bool {
|
||||
self.inner.strong_count() > 0
|
||||
}
|
||||
|
||||
fn cancel(&self) {
|
||||
self.cancelled.store(true, Ordering::Release);
|
||||
self.maybe_interrupt();
|
||||
@@ -576,8 +582,21 @@ 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,
|
||||
},
|
||||
IntermediateText(String),
|
||||
/// 模型的思考过程(reasoning item 的明文摘要):流式阶段整段替换下发。
|
||||
Reasoning(String),
|
||||
Activity(&'static str),
|
||||
/// 一条结构化工具调用(`item/started` 与 `item/completed` 各采一次,按 id 幂等)。
|
||||
ToolCall(crate::DirectToolCall),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
@@ -862,6 +881,37 @@ fn direct_codex_mcp_tool_intermediate_text(item: &serde_json::Value) -> String {
|
||||
/// (with the concrete command/tool/path) while tools run; it does not push
|
||||
/// plan/reasoning text deltas. Showing what the agent is actually doing is
|
||||
/// the only reliable way to make the execution phase feel alive.
|
||||
/// 从 reasoning item 里抽明文思考文本:优先 `summary[].text`,其次 `content[].text`。
|
||||
///
|
||||
/// Codex 的 reasoning item 形如
|
||||
/// `{ "type": "reasoning", "summary": [...], "content": [{ "text": "..." }], "encrypted_content": ... }`,
|
||||
/// 没有 `role` 字段;明文(至少 content/summary 之一)存在时我们才展示,拿不到就返回 None。
|
||||
fn direct_codex_item_reasoning_text(item: &serde_json::Value) -> Option<String> {
|
||||
if item.get("type").and_then(serde_json::Value::as_str) != Some("reasoning") {
|
||||
return None;
|
||||
}
|
||||
let collect = |key: &str| -> Option<String> {
|
||||
let parts = item
|
||||
.get(key)?
|
||||
.as_array()?
|
||||
.iter()
|
||||
.filter_map(|entry| {
|
||||
entry
|
||||
.get("text")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|text| !text.is_empty())
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if parts.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(parts.join("\n\n"))
|
||||
}
|
||||
};
|
||||
collect("summary").or_else(|| collect("content"))
|
||||
}
|
||||
|
||||
fn direct_codex_item_intermediate_text(item: &serde_json::Value) -> Option<String> {
|
||||
const MAX_ITEM_TEXT_CHARS: usize = 240;
|
||||
let item_type = item
|
||||
@@ -2734,6 +2784,12 @@ impl CodexAppServerConnection {
|
||||
let _turn_guard = self.inner.turn_gate.lock().await;
|
||||
let mut request = request;
|
||||
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
|
||||
.map(str::trim)
|
||||
.filter(|turn_id| !turn_id.is_empty())
|
||||
.map(str::to_string);
|
||||
if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
|
||||
let current_prompt = direct_codex_current_user_prompt(&request).trim();
|
||||
if current_prompt.is_empty() {
|
||||
@@ -2821,6 +2877,17 @@ impl CodexAppServerConnection {
|
||||
}
|
||||
let turn_start_cancellation =
|
||||
Arc::new(CodexTurnStartCancellation::new(&self.inner, &thread_id));
|
||||
// Direct 回合登记为"可终止":终止命令只作用在这一轮上,回合结束时自动注销。
|
||||
let _active_turn_guard = direct_tool_call_turn_id
|
||||
.as_deref()
|
||||
.filter(|_| self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject)
|
||||
.map(|turn_id| {
|
||||
register_active_direct_codex_turn(
|
||||
direct_codex_active_turn_key(history_root),
|
||||
turn_id,
|
||||
Arc::clone(&turn_start_cancellation),
|
||||
)
|
||||
});
|
||||
let mut turn_start_guard = CodexTurnStartGuard {
|
||||
cancellation: Arc::clone(&turn_start_cancellation),
|
||||
armed: true,
|
||||
@@ -2938,6 +3005,18 @@ impl CodexAppServerConnection {
|
||||
observer(DirectCodexTurnObservation::AccumulatedText(
|
||||
streamed_text.clone(),
|
||||
));
|
||||
// 同一个 assistant item 的当前累计全文:回合流按 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Some(callback) = on_agent_message_delta.as_deref_mut() {
|
||||
callback(&platform_llm::LlmStreamDelta {
|
||||
@@ -3027,6 +3106,10 @@ impl CodexAppServerConnection {
|
||||
// 让执行期间聊天窗口显示“正在做什么”,而不是只
|
||||
// 有活动状态来回跳动。completed 事件不再重复。
|
||||
if !completed {
|
||||
if let Some(reasoning) = direct_codex_item_reasoning_text(item)
|
||||
{
|
||||
observer(DirectCodexTurnObservation::Reasoning(reasoning));
|
||||
}
|
||||
if let Some(text) = direct_codex_item_intermediate_text(item) {
|
||||
observer(DirectCodexTurnObservation::IntermediateText(
|
||||
text,
|
||||
@@ -3042,6 +3125,24 @@ 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 completed {
|
||||
if let Some(audit) = audit.as_mut() {
|
||||
audit.observe_item(¶ms);
|
||||
@@ -3049,6 +3150,27 @@ impl CodexAppServerConnection {
|
||||
}
|
||||
}
|
||||
if item_type == "agentMessage" {
|
||||
// 某些 app-server 实现会在工具开始后停止发送 agentMessage delta,
|
||||
// 但会在 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(observer) = direct_observer.as_deref_mut() {
|
||||
observer(
|
||||
DirectCodexTurnObservation::AgentMessageSegment {
|
||||
item_id: item_id.to_string(),
|
||||
accumulated_text: text.to_string(),
|
||||
completed: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(text) = item
|
||||
.get("text")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
@@ -3086,17 +3208,32 @@ impl CodexAppServerConnection {
|
||||
}
|
||||
Some(CodexTurnEvent::Terminal(params)) => {
|
||||
let turn = params.get("turn").unwrap_or(¶ms);
|
||||
if final_text.is_none() {
|
||||
final_text = turn
|
||||
.get("items")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.and_then(|items| {
|
||||
items.iter().rev().find_map(|item| {
|
||||
(item.get("type")?.as_str()? == "agentMessage")
|
||||
.then(|| item.get("text")?.as_str().map(str::to_string))
|
||||
.flatten()
|
||||
})
|
||||
});
|
||||
if let Some(items) = turn.get("items").and_then(serde_json::Value::as_array)
|
||||
{
|
||||
for item in items {
|
||||
if item.get("type").and_then(serde_json::Value::as_str)
|
||||
!= Some("agentMessage")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if let Some(text) = item
|
||||
.get("text")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let status = turn
|
||||
.get("status")
|
||||
@@ -3195,6 +3332,223 @@ impl Drop for CodexTurnStartGuard {
|
||||
}
|
||||
}
|
||||
|
||||
/// Direct 回合中断表:与具体取消句柄解耦的最小实现,"选哪一轮 / 注销哪一轮"可单测。
|
||||
struct DirectCodexActiveTurnTable<T> {
|
||||
entries: HashMap<std::path::PathBuf, (String, T)>,
|
||||
}
|
||||
|
||||
impl<T> DirectCodexActiveTurnTable<T> {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
entries: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn register(&mut self, key: std::path::PathBuf, client_turn_id: &str, value: T) {
|
||||
self.entries
|
||||
.insert(key, (client_turn_id.to_string(), value));
|
||||
}
|
||||
|
||||
/// 只有当前登记项仍是本回合的句柄时才注销,避免旧回合的收尾清掉后来注册的回合。
|
||||
fn unregister(&mut self, key: &Path, is_same: impl Fn(&T) -> bool) {
|
||||
if self
|
||||
.entries
|
||||
.get(key)
|
||||
.is_some_and(|(_, value)| is_same(value))
|
||||
{
|
||||
self.entries.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
/// 选中要终止的回合:没有活动回合、或前端给的 clientTurnId 与活动回合不一致时都返回
|
||||
/// 可读原因,绝不误伤另一个回合。
|
||||
fn select(&self, key: &Path, client_turn_id: Option<&str>) -> Result<&(String, T), String> {
|
||||
let active = self
|
||||
.entries
|
||||
.get(key)
|
||||
.ok_or_else(|| "当前项目没有正在运行的陶泥儿回合,无法终止".to_string())?;
|
||||
if let Some(expected) = client_turn_id
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
if active.0 != expected {
|
||||
return Err(DIRECT_CODEX_ANOTHER_TURN_RUNNING_MESSAGE.to_string());
|
||||
}
|
||||
}
|
||||
Ok(active)
|
||||
}
|
||||
|
||||
/// 当前登记在这一轮上的 clientTurnId;没有任何登记时返回 `None`。
|
||||
fn registered_client_turn_id(&self, key: &Path) -> Option<&str> {
|
||||
self.entries
|
||||
.get(key)
|
||||
.map(|(client_turn_id, _)| client_turn_id.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// "正在跑的是另一轮"的统一文案:`select` 与"终止"兜底路径共用,保证两处拒绝语义一致。
|
||||
const DIRECT_CODEX_ANOTHER_TURN_RUNNING_MESSAGE: &str = "正在运行的是另一个陶泥儿回合,已拒绝终止";
|
||||
|
||||
/// 正在运行的 Direct 回合中断句柄,按项目根(canonical,去掉 Windows `\\?\` 前缀)索引。
|
||||
///
|
||||
/// `CodexTurnStartCancellation` 本身已经能在 turn/start 响应到达**前后**发出
|
||||
/// `turn/interrupt`;这里只是把它留一个 Tauri 命令取得到的引用,回合结束后由
|
||||
/// [`DirectCodexActiveTurnGuard`] 移除。只做新增:不改既有事件、命令语义。
|
||||
static GAME_CREATOR_DIRECT_CODEX_ACTIVE_TURNS: OnceLock<
|
||||
std::sync::Mutex<DirectCodexActiveTurnTable<Arc<CodexTurnStartCancellation>>>,
|
||||
> = OnceLock::new();
|
||||
|
||||
fn direct_codex_active_turns(
|
||||
) -> &'static std::sync::Mutex<DirectCodexActiveTurnTable<Arc<CodexTurnStartCancellation>>> {
|
||||
GAME_CREATOR_DIRECT_CODEX_ACTIVE_TURNS
|
||||
.get_or_init(|| std::sync::Mutex::new(DirectCodexActiveTurnTable::new()))
|
||||
}
|
||||
|
||||
/// 注册键:与 Direct 回合用的 `codex_root` 同一形态(canonical 且去掉 `\\?\` 前缀),
|
||||
/// 这样前端传进来的项目路径与注册时的路径一定落到同一个键上。
|
||||
fn direct_codex_active_turn_key(root: &Path) -> std::path::PathBuf {
|
||||
let canonical = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
|
||||
match canonical
|
||||
.to_str()
|
||||
.and_then(|value| value.strip_prefix("\\\\?\\"))
|
||||
{
|
||||
Some(stripped) => std::path::PathBuf::from(stripped),
|
||||
None => canonical,
|
||||
}
|
||||
}
|
||||
|
||||
struct DirectCodexActiveTurnGuard {
|
||||
key: std::path::PathBuf,
|
||||
cancellation: Arc<CodexTurnStartCancellation>,
|
||||
}
|
||||
|
||||
impl Drop for DirectCodexActiveTurnGuard {
|
||||
fn drop(&mut self) {
|
||||
let Some(active_turns) = GAME_CREATOR_DIRECT_CODEX_ACTIVE_TURNS.get() else {
|
||||
return;
|
||||
};
|
||||
let Ok(mut entries) = active_turns.lock() else {
|
||||
return;
|
||||
};
|
||||
let cancellation = Arc::clone(&self.cancellation);
|
||||
entries.unregister(&self.key, |current| Arc::ptr_eq(current, &cancellation));
|
||||
}
|
||||
}
|
||||
|
||||
/// 把一个 Direct 回合登记为"可终止",返回的 guard 在回合结束时注销它。
|
||||
fn register_active_direct_codex_turn(
|
||||
key: std::path::PathBuf,
|
||||
client_turn_id: &str,
|
||||
cancellation: Arc<CodexTurnStartCancellation>,
|
||||
) -> DirectCodexActiveTurnGuard {
|
||||
if let Ok(mut entries) = direct_codex_active_turns().lock() {
|
||||
entries.register(key.clone(), client_turn_id, Arc::clone(&cancellation));
|
||||
}
|
||||
DirectCodexActiveTurnGuard { key, cancellation }
|
||||
}
|
||||
|
||||
/// 已向正在跑的回合发出中断:界面等这一轮自己的收尾复位。
|
||||
pub(crate) const DIRECT_TURN_CANCEL_OUTCOME_INTERRUPTED: &str = "interrupted";
|
||||
/// 这一轮已经没有人替它收尾,本地守卫已被兜底释放:界面必须自己复位。
|
||||
pub(crate) const DIRECT_TURN_CANCEL_OUTCOME_RELEASED: &str = "released";
|
||||
|
||||
/// `cancel_direct_codex_turn` 的返回值:界面据此决定是自己复位,还是等回合自己收尾。
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct DirectTurnCancelView {
|
||||
/// [`DIRECT_TURN_CANCEL_OUTCOME_INTERRUPTED`] 或
|
||||
/// [`DIRECT_TURN_CANCEL_OUTCOME_RELEASED`]。
|
||||
pub(crate) outcome: String,
|
||||
/// 给用户看的可读结果。
|
||||
pub(crate) message: String,
|
||||
/// 被终止 / 被释放的 clientTurnId。
|
||||
pub(crate) client_turn_id: String,
|
||||
}
|
||||
|
||||
/// "终止"这一步要作用在哪:发中断,还是走残留守卫兜底释放。
|
||||
enum DirectCodexTurnCancelTarget {
|
||||
/// app-server 侧还有活句柄:正常发 `turn/interrupt`。
|
||||
Interrupt(Arc<CodexTurnStartCancellation>),
|
||||
/// app-server 侧已经拿不到可中断的活句柄;带上是哪种情况。
|
||||
Stale(DirectTaonierStaleGuardReason),
|
||||
}
|
||||
|
||||
/// 终止当前项目正在运行的 Direct 回合。
|
||||
///
|
||||
/// 正常路径:只向正在跑的 Codex app-server 回合发 `turn/interrupt`(app-server 随后回
|
||||
/// `turn/completed status=interrupted`,正在 await 的那个回合命令会带着可读原因返回),
|
||||
/// 不动任何既有事件或命令语义。
|
||||
///
|
||||
/// 兜底路径:app-server 侧已经拿不到可中断的活句柄时,说明这一轮不会再有人替它收尾。
|
||||
/// 只发中断会让本地守卫(`DirectTaonierActiveInvocationGuard`)永远留在进程内,用户此后
|
||||
/// 每条消息都会被"已有另一条回合正在运行"拒绝——这正是"重进会话被堵死"的死锁形态。
|
||||
/// 这时显式释放这条守卫并把可读原因返回给界面。释放条件见
|
||||
/// [`release_stale_direct_taonier_active_invocation`] 的注释;"正在跑的是另一轮"仍然
|
||||
/// 保持原拒绝语义,什么都不释放。
|
||||
pub(crate) fn cancel_direct_codex_turn_at(
|
||||
root: &Path,
|
||||
client_turn_id: Option<&str>,
|
||||
) -> Result<DirectTurnCancelView, String> {
|
||||
let key = direct_codex_active_turn_key(root);
|
||||
let expected = client_turn_id
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
{
|
||||
let entries = direct_codex_active_turns()
|
||||
.lock()
|
||||
.map_err(|_| "Direct 回合中断表已损坏,无法终止".to_string())?;
|
||||
if let (Some(registered), Some(expected)) =
|
||||
(entries.registered_client_turn_id(&key), expected)
|
||||
{
|
||||
if registered != expected {
|
||||
return Err(DIRECT_CODEX_ANOTHER_TURN_RUNNING_MESSAGE.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
let target = {
|
||||
let entries = direct_codex_active_turns()
|
||||
.lock()
|
||||
.map_err(|_| "Direct 回合中断表已损坏,无法终止".to_string())?;
|
||||
match entries.select(&key, client_turn_id) {
|
||||
Ok((_, cancellation)) if cancellation.app_server_alive() => {
|
||||
DirectCodexTurnCancelTarget::Interrupt(Arc::clone(cancellation))
|
||||
}
|
||||
Ok(_) => {
|
||||
DirectCodexTurnCancelTarget::Stale(DirectTaonierStaleGuardReason::ExecutorExited)
|
||||
}
|
||||
Err(_) => DirectCodexTurnCancelTarget::Stale(
|
||||
DirectTaonierStaleGuardReason::NeverReachedExecutor,
|
||||
),
|
||||
}
|
||||
};
|
||||
match target {
|
||||
DirectCodexTurnCancelTarget::Interrupt(cancellation) => {
|
||||
cancellation.cancel();
|
||||
Ok(DirectTurnCancelView {
|
||||
outcome: DIRECT_TURN_CANCEL_OUTCOME_INTERRUPTED.to_string(),
|
||||
message: "已向正在运行的回合发出终止".to_string(),
|
||||
client_turn_id: client_turn_id
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
})
|
||||
}
|
||||
DirectCodexTurnCancelTarget::Stale(reason) => {
|
||||
let released =
|
||||
release_stale_direct_taonier_active_invocation(root, client_turn_id, reason)?;
|
||||
Ok(DirectTurnCancelView {
|
||||
outcome: DIRECT_TURN_CANCEL_OUTCOME_RELEASED.to_string(),
|
||||
message: format!(
|
||||
"{},已释放这一轮的占用,可以直接重新发送消息",
|
||||
reason.message()
|
||||
),
|
||||
client_turn_id: released,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CodexThreadLease {
|
||||
connection: CodexAppServerConnection,
|
||||
key: CodexNodeThreadKey,
|
||||
@@ -4100,6 +4454,52 @@ pub(crate) fn build_direct_codex_history_prompt(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 终止只作用在"当前项目正在跑的那一轮"上:没有活动回合 / clientTurnId 不匹配都要
|
||||
/// 返回可读原因,不能误伤别人;注销也只注销本回合自己的句柄。
|
||||
#[test]
|
||||
fn direct_codex_active_turn_table_selects_only_the_running_turn() {
|
||||
let mut table: DirectCodexActiveTurnTable<u8> = DirectCodexActiveTurnTable::new();
|
||||
let key = std::path::PathBuf::from("C:/projects/direct-turn-demo");
|
||||
assert_eq!(
|
||||
table.select(&key, None).expect_err("no active turn"),
|
||||
"当前项目没有正在运行的陶泥儿回合,无法终止"
|
||||
);
|
||||
|
||||
table.register(key.clone(), "turn-a", 1);
|
||||
assert_eq!(table.select(&key, None).expect("active turn").0, "turn-a");
|
||||
assert_eq!(table.select(&key, Some("turn-a")).expect("same turn").1, 1);
|
||||
assert_eq!(
|
||||
table
|
||||
.select(&key, Some("turn-b"))
|
||||
.expect_err("another running turn"),
|
||||
"正在运行的是另一个陶泥儿回合,已拒绝终止"
|
||||
);
|
||||
|
||||
// 句柄已被后来的回合替换:旧回合收尾不得注销新回合。
|
||||
table.register(key.clone(), "turn-b", 2);
|
||||
table.unregister(&key, |value| *value == 1);
|
||||
assert_eq!(table.select(&key, None).expect("newer turn").0, "turn-b");
|
||||
table.unregister(&key, |value| *value == 2);
|
||||
assert!(table.select(&key, None).is_err());
|
||||
}
|
||||
|
||||
/// 注册键:前端传的项目路径与回合注册时的路径必须归一化成同一个键(Windows 上
|
||||
/// `canonicalize` 会带 `\\?\` 前缀,去掉后两边才相等)。
|
||||
#[test]
|
||||
fn direct_codex_active_turn_key_normalizes_windows_prefix() {
|
||||
let root = tempfile::tempdir().expect("temp dir");
|
||||
let canonical = std::fs::canonicalize(root.path()).expect("canonical root");
|
||||
let expected = canonical
|
||||
.to_str()
|
||||
.and_then(|value| value.strip_prefix("\\\\?\\"))
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or(canonical);
|
||||
let key = direct_codex_active_turn_key(root.path());
|
||||
assert_eq!(key, expected);
|
||||
// 归一化后的键不再带 Windows 扩展长度前缀:前端传进来的普通路径才能命中同一个键。
|
||||
assert!(!key.to_string_lossy().starts_with("\\\\?\\"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_thread_item_projection_drops_full_app_server_payload() {
|
||||
let item = serde_json::json!({
|
||||
|
||||
@@ -5,6 +5,7 @@ use crate::project::{
|
||||
};
|
||||
use crate::{LocalConversationMessageRecord, LocalConversationResult};
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs::{self, File};
|
||||
use std::io::{BufRead, BufReader, Read, Seek, SeekFrom};
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -91,6 +92,10 @@ fn record(item: &Value) -> Result<String, String> {
|
||||
serde_json::to_string(&serde_json::json!({
|
||||
"type": DIRECT_PROJECT_HISTORY_RECORD_TYPE,
|
||||
"payload": item,
|
||||
"recordedAt": std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|duration| duration.as_millis() as u64)
|
||||
.unwrap_or(0),
|
||||
}))
|
||||
.map_err(|error| format!("序列化 DirectProject 历史失败:{error}"))
|
||||
}
|
||||
@@ -470,6 +475,13 @@ fn direct_project_message_item(role: &str, content: &str, message_id: Option<&st
|
||||
}
|
||||
|
||||
pub(crate) fn read_direct_project_history_items_at(root: &Path) -> Result<Vec<Value>, String> {
|
||||
Ok(read_direct_project_history_entries_at(root)?
|
||||
.into_iter()
|
||||
.map(|(item, _)| item)
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn read_direct_project_history_entries_at(root: &Path) -> Result<Vec<(Value, u64)>, String> {
|
||||
let path = history_path(root);
|
||||
if !prepare_game_creator_private_path_for_read(&path, false, "DirectProject 历史")? {
|
||||
return Ok(Vec::new());
|
||||
@@ -507,7 +519,13 @@ pub(crate) fn read_direct_project_history_items_at(root: &Path) -> Result<Vec<Va
|
||||
if is_direct_project_internal_context_item(&item) {
|
||||
continue;
|
||||
}
|
||||
items.push(item);
|
||||
items.push((
|
||||
item,
|
||||
parsed
|
||||
.get("recordedAt")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0),
|
||||
));
|
||||
}
|
||||
Ok(items)
|
||||
}
|
||||
@@ -516,18 +534,30 @@ pub(crate) fn read_direct_project_history_items_slice_at(
|
||||
root: &Path,
|
||||
before_item_id: Option<&str>,
|
||||
limit: usize,
|
||||
) -> Result<(Vec<Value>, bool), String> {
|
||||
let items = read_direct_project_history_items_at(root)?;
|
||||
) -> Result<(Vec<Value>, bool, BTreeMap<String, u64>), String> {
|
||||
let items = read_direct_project_history_entries_at(root)?;
|
||||
let end = match before_item_id {
|
||||
Some(item_id) => items
|
||||
.iter()
|
||||
.position(|item| item.get("id").and_then(Value::as_str) == Some(item_id))
|
||||
.position(|(item, _)| item.get("id").and_then(Value::as_str) == Some(item_id))
|
||||
.ok_or_else(|| format!("DirectProject 历史中不存在 item:{item_id}"))?,
|
||||
None => items.len(),
|
||||
};
|
||||
let bounded_limit = limit.clamp(1, 200);
|
||||
let start = end.saturating_sub(bounded_limit);
|
||||
Ok((items[start..end].to_vec(), start > 0))
|
||||
let slice = &items[start..end];
|
||||
let timestamps = slice
|
||||
.iter()
|
||||
.filter_map(|(item, at)| {
|
||||
let id = item.get("id").and_then(Value::as_str)?;
|
||||
(*at > 0).then(|| (id.to_string(), *at))
|
||||
})
|
||||
.collect();
|
||||
Ok((
|
||||
slice.iter().map(|(item, _)| item.clone()).collect(),
|
||||
start > 0,
|
||||
timestamps,
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn read_direct_project_last_item_id_at(root: &Path) -> Result<Option<String>, String> {
|
||||
@@ -546,10 +576,10 @@ pub(crate) fn read_direct_project_chat_history_at(
|
||||
root: &Path,
|
||||
) -> Result<LocalConversationResult, String> {
|
||||
let path = history_path(root);
|
||||
let items = read_direct_project_history_items_at(root)?;
|
||||
let items = read_direct_project_history_entries_at(root)?;
|
||||
let messages = items
|
||||
.into_iter()
|
||||
.filter_map(|item| {
|
||||
.filter_map(|(item, recorded_at)| {
|
||||
let role = item.get("role").and_then(Value::as_str)?;
|
||||
if !matches!(role, "user" | "assistant") {
|
||||
return None;
|
||||
@@ -571,7 +601,7 @@ pub(crate) fn read_direct_project_chat_history_at(
|
||||
content,
|
||||
agent_id: None,
|
||||
message_id: item.get("id").and_then(Value::as_str).map(str::to_string),
|
||||
updated_at: 0,
|
||||
updated_at: recorded_at,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -610,6 +640,40 @@ mod tests {
|
||||
const RESPONSE_ITEM_ROW: &str = r#"{"type":"response_item","payload":{"type":"message","role":"user","id":"codex-item-2","content":[{"type":"input_text","text":"再加一个按钮"}]}}"#;
|
||||
const RESPONSE_ASSISTANT_ROW: &str = r#"{"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"已完成"}]}}"#;
|
||||
|
||||
#[test]
|
||||
fn history_timestamps_survive_reload_and_idempotent_append_without_changing_raw_items() {
|
||||
let root = init_history_project("history-time");
|
||||
let item = json!({
|
||||
"type": "message", "role": "user", "id": "sent-message",
|
||||
"content": [{"type": "input_text", "text": "修改游戏"}],
|
||||
});
|
||||
append_direct_project_user_message_at(root.path(), &item).unwrap();
|
||||
let (items, _, timestamps) =
|
||||
super::read_direct_project_history_items_slice_at(root.path(), None, 20).unwrap();
|
||||
assert_eq!(items, vec![item.clone()]);
|
||||
assert!(timestamps["sent-message"] > 0);
|
||||
append_direct_project_user_message_at(root.path(), &item).unwrap();
|
||||
let (_, _, reloaded) =
|
||||
super::read_direct_project_history_items_slice_at(root.path(), None, 20).unwrap();
|
||||
assert_eq!(timestamps, reloaded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_history_without_envelope_time_stays_unknown() {
|
||||
let root = init_history_project("history-unknown-time");
|
||||
write_history_lines(root.path(), &[RESPONSE_ITEM_ROW]);
|
||||
let (_, _, timestamps) =
|
||||
super::read_direct_project_history_items_slice_at(root.path(), None, 20).unwrap();
|
||||
assert!(timestamps.is_empty());
|
||||
assert_eq!(
|
||||
read_direct_project_chat_history_at(root.path())
|
||||
.unwrap()
|
||||
.messages[0]
|
||||
.updated_at,
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
/// 判据:争用类失败会被"有界退避重试"真的吃掉,最终把条目落一行。
|
||||
///
|
||||
/// 注入标记是"让接下来 N 次单次尝试返回争用失败";退避表只补一次重试,所以注入 1 次
|
||||
|
||||
@@ -22,6 +22,14 @@ impl DirectProjectHistoryAccumulator {
|
||||
}
|
||||
}
|
||||
|
||||
/// 某个 assistant item 目前累计到的全文。
|
||||
///
|
||||
/// 回合流按 item 分段:同一个 item 的后续 delta 是同一段的增长,item 变了才是新的一段。
|
||||
/// 没有这条 item(非 DirectProject 工作区、或已经 complete)时返回 `None`。
|
||||
pub(crate) fn accumulated_text_for(&self, item_id: &str) -> Option<String> {
|
||||
self.text_by_item_id.get(item_id).cloned()
|
||||
}
|
||||
|
||||
fn take_partial_items(&mut self) -> impl Iterator<Item = Value> + '_ {
|
||||
std::mem::take(&mut self.text_by_item_id)
|
||||
.into_iter()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -31,7 +31,7 @@ pub(crate) fn normalize_direct_client_turn_id(
|
||||
pub(crate) async fn chat_with_game_creator_direct_codex(
|
||||
project_path: String,
|
||||
prompt: String,
|
||||
user_item: DirectCodexUserItem,
|
||||
mut user_item: DirectCodexUserItem,
|
||||
creation_type: Option<String>,
|
||||
client_turn_id: Option<String>,
|
||||
attachments: Option<Vec<DirectCodexTurnAttachment>>,
|
||||
@@ -50,6 +50,17 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
|
||||
attachments.as_deref().unwrap_or_default(),
|
||||
);
|
||||
let attachments = attachments.unwrap_or_default();
|
||||
if !attachments.is_empty() {
|
||||
let attachment_context =
|
||||
render_direct_codex_user_prompt("", &attachments).map_err(|error| {
|
||||
audit.finish(false);
|
||||
error
|
||||
})?;
|
||||
let DirectCodexUserItem::Message(message) = &mut user_item;
|
||||
message.content.push(DirectCodexUserContentPart::InputText {
|
||||
text: attachment_context,
|
||||
});
|
||||
}
|
||||
validate_direct_codex_user_item(root, &user_item).map_err(|error| {
|
||||
audit.finish(false);
|
||||
error
|
||||
@@ -81,6 +92,6 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
|
||||
}
|
||||
};
|
||||
audit.finish(true);
|
||||
turn_emitter.emit("completed", Some("none"), Some(reply.clone()));
|
||||
turn_emitter.emit("completed", Some("none"), Some(reply.clone()), None);
|
||||
Ok(reply)
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ pub(crate) struct DirectThreadConsumeResult {
|
||||
pub(crate) struct DirectThreadHistorySlice {
|
||||
pub(crate) items: Vec<Value>,
|
||||
pub(crate) has_more: bool,
|
||||
pub(crate) item_timestamps: std::collections::BTreeMap<String, u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
|
||||
@@ -19,6 +19,8 @@ const DIRECT_TOOL_BRIDGE_MAX_WRITE_CONTENT_BYTES: usize = 1_500_000;
|
||||
const DIRECT_TOOL_BRIDGE_MAX_ART_BRIEF_CHARS: usize = 4_000;
|
||||
const DIRECT_TOOL_BRIDGE_MAX_IMAGE_PROMPT_CHARS: usize = 32_000;
|
||||
const DIRECT_TOOL_BRIDGE_MAX_IMAGE_BYTES: u64 = 6 * 1024 * 1024;
|
||||
const DIRECT_TOOL_BRIDGE_IMAGE_PREVIEW_MAX_BYTES: usize = 256 * 1024;
|
||||
const DIRECT_TOOL_BRIDGE_IMAGE_PREVIEW_MAX_DIMENSION: u32 = 1024;
|
||||
const DIRECT_TOOL_BRIDGE_MAX_SEARCH_QUERY_CHARS: usize = 400;
|
||||
const DIRECT_TOOL_BRIDGE_MAX_SEARCH_RESULTS: usize = 5;
|
||||
const DIRECT_TOOL_BRIDGE_SEARCH_URL: &str = "https://www.bing.com/search?format=rss";
|
||||
@@ -694,14 +696,44 @@ fn direct_tool_bridge_state_with_search(
|
||||
})
|
||||
}
|
||||
|
||||
/// 将 MCP 图片 block 限制为可安全回显和持久化的预览。
|
||||
///
|
||||
/// 工具结果会被 Codex 原样写入 DirectProject 历史;这里保留小图的原始
|
||||
/// PNG,大图则缩放并转成 JPEG。项目文件中的原图不受影响,历史恢复仍有
|
||||
/// 可见证据,但不会把多张几 MiB 的截图永久复制进上下文。
|
||||
pub(crate) fn compact_mcp_image_data(data: &str) -> Option<(String, &'static str)> {
|
||||
let bytes = BASE64_STANDARD.decode(data).ok()?;
|
||||
if bytes.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if bytes.len() <= DIRECT_TOOL_BRIDGE_IMAGE_PREVIEW_MAX_BYTES {
|
||||
return Some((data.to_string(), "image/png"));
|
||||
}
|
||||
|
||||
let image = image::load_from_memory(&bytes).ok()?;
|
||||
let mut preview = image.thumbnail(
|
||||
DIRECT_TOOL_BRIDGE_IMAGE_PREVIEW_MAX_DIMENSION,
|
||||
DIRECT_TOOL_BRIDGE_IMAGE_PREVIEW_MAX_DIMENSION,
|
||||
);
|
||||
for (dimension, quality) in [(1024, 78), (768, 70), (512, 60), (384, 50)] {
|
||||
if preview.width() > dimension || preview.height() > dimension {
|
||||
preview = image.thumbnail(dimension, dimension);
|
||||
}
|
||||
let mut encoded = Vec::new();
|
||||
let mut encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut encoded, quality);
|
||||
preview.write_with_encoder(encoder).ok()?;
|
||||
if encoded.len() <= DIRECT_TOOL_BRIDGE_IMAGE_PREVIEW_MAX_BYTES {
|
||||
return Some((BASE64_STANDARD.encode(encoded), "image/jpeg"));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn bridge_tool_result(text: String, images: Vec<String>, is_error: bool) -> Value {
|
||||
let mut content = vec![json!({ "type": "text", "text": text })];
|
||||
content.extend(images.into_iter().map(|data| {
|
||||
json!({
|
||||
"type": "image",
|
||||
"data": data,
|
||||
"mimeType": "image/png"
|
||||
})
|
||||
content.extend(images.into_iter().filter_map(|data| {
|
||||
let (data, mime_type) = compact_mcp_image_data(&data).unwrap_or((data, "image/png"));
|
||||
Some(json!({ "type": "image", "data": data, "mimeType": mime_type }))
|
||||
}));
|
||||
json!({ "content": content, "isError": is_error })
|
||||
}
|
||||
@@ -2672,7 +2704,7 @@ pub(crate) async fn start_direct_tool_bridge(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::{Read, Write};
|
||||
use std::io::{Cursor, Read, Write};
|
||||
|
||||
#[tokio::test]
|
||||
async fn controlled_search_client_omits_agc_marker() {
|
||||
@@ -2766,6 +2798,35 @@ mod tests {
|
||||
assert!(bridge_search_max_results(&json!({ "maxResults": 6 })).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_mcp_images_are_reduced_to_bounded_jpeg_previews() {
|
||||
let image = image::RgbaImage::from_fn(1600, 1200, |x, y| {
|
||||
image::Rgba([
|
||||
(x % 251) as u8,
|
||||
(y % 251) as u8,
|
||||
((x.wrapping_mul(31) + y.wrapping_mul(17)) % 251) as u8,
|
||||
u8::MAX,
|
||||
])
|
||||
});
|
||||
let mut png = Cursor::new(Vec::new());
|
||||
image::DynamicImage::ImageRgba8(image)
|
||||
.write_to(&mut png, image::ImageFormat::Png)
|
||||
.expect("encode image fixture");
|
||||
assert!(png.get_ref().len() > DIRECT_TOOL_BRIDGE_IMAGE_PREVIEW_MAX_BYTES);
|
||||
|
||||
let (preview, mime_type) =
|
||||
compact_mcp_image_data(&BASE64_STANDARD.encode(png.into_inner()))
|
||||
.expect("large valid image should produce preview");
|
||||
assert_eq!(mime_type, "image/jpeg");
|
||||
assert!(
|
||||
BASE64_STANDARD
|
||||
.decode(preview)
|
||||
.expect("preview base64")
|
||||
.len()
|
||||
<= DIRECT_TOOL_BRIDGE_IMAGE_PREVIEW_MAX_BYTES
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_parser_accepts_only_bounded_public_https_results() {
|
||||
let body = r#"<rss><channel><item><title>Tauri & Rust</title><link>https://tauri.app/</link><description><b>Cross-platform apps</b></description></item><item><title>Private</title><link>http://127.0.0.1:8082/private</link><description>private</description></item><item><title>Credentials</title><link>https://user:pass@example.test/path</link><description>private</description></item><item><title>Loopback host</title><link>https://localhost/private</link><description>private</description></item><item><title>Local host</title><link>https://service.internal/private</link><description>private</description></item></channel></rss>"#;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,439 @@
|
||||
//! 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::{prepare_game_creator_private_path_for_read, 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());
|
||||
})
|
||||
}
|
||||
|
||||
/// 回读:文件缺失返回空数组;单行损坏跳过;按 `seq` 正序,最多最后 400 条。
|
||||
pub(crate) fn read_direct_turn_stream_at(root: &Path) -> Result<Vec<DirectTurnStreamItem>, String> {
|
||||
let path = turn_stream_path(root);
|
||||
if !prepare_game_creator_private_path_for_read(&path, false, "回合流历史")? {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
Ok(normalize_stream_items(read_stream_lines(&path)))
|
||||
}
|
||||
|
||||
/// 追加一段固定身份的文本段(失败说明等):位置排在当前流末尾。
|
||||
///
|
||||
/// 幂等:同一 `(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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -6519,6 +6519,7 @@ impl PlatformArtSliceContractRollback {
|
||||
|
||||
fn validate_strict_platform_art_spritesheet_contract(
|
||||
slices: &[PreparedPlatformArtAssetSlice],
|
||||
slice_warning: Option<&str>,
|
||||
canvas_context: &ExternalCanvasGenerationContext,
|
||||
canvas_project_id: Option<&str>,
|
||||
resource_id: Option<&str>,
|
||||
@@ -6532,7 +6533,11 @@ fn validate_strict_platform_art_spritesheet_contract(
|
||||
has_visible_pixels: bool,
|
||||
) -> Result<(), String> {
|
||||
if slices.is_empty() {
|
||||
return Err("spritesheet 图集至少需要一个独立切片".to_string());
|
||||
return Err(slice_warning
|
||||
.map(str::trim)
|
||||
.filter(|warning| !warning.is_empty())
|
||||
.map(|warning| format!("spritesheet 图集至少需要一个独立切片;原始切片告警:{warning}"))
|
||||
.unwrap_or_else(|| "spritesheet 图集至少需要一个独立切片".to_string()));
|
||||
}
|
||||
let resource_id = resource_id
|
||||
.map(str::trim)
|
||||
@@ -7337,6 +7342,7 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook(
|
||||
if require_complete_core_slices {
|
||||
validate_strict_platform_art_spritesheet_contract(
|
||||
&slices,
|
||||
slice_warning.as_deref(),
|
||||
&canvas_context,
|
||||
canvas_project_id.as_deref(),
|
||||
resource_id.as_deref(),
|
||||
@@ -9798,6 +9804,7 @@ mod canvas_generation_tests {
|
||||
.collect::<Vec<_>>();
|
||||
validate_strict_platform_art_spritesheet_contract(
|
||||
&slices,
|
||||
None,
|
||||
&canvas_context,
|
||||
Some("canvas-project"),
|
||||
Some("spritesheet-resource"),
|
||||
@@ -9813,6 +9820,35 @@ mod canvas_generation_tests {
|
||||
.expect("valid slice identities and pixel evidence do not require a fixed layout");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_spritesheet_contract_preserves_slice_warning_when_empty() {
|
||||
let canvas_context = ExternalCanvasGenerationContext {
|
||||
project_id: "canvas-project".to_string(),
|
||||
asset_folder_id: "asset-folder".to_string(),
|
||||
canvas_name: "empty-slice-warning".to_string(),
|
||||
};
|
||||
let error = validate_strict_platform_art_spritesheet_contract(
|
||||
&[],
|
||||
Some("识别出的素材数量超过输出上限:86,最多允许 256 个"),
|
||||
&canvas_context,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
"route",
|
||||
"kind",
|
||||
None,
|
||||
&[],
|
||||
false,
|
||||
false,
|
||||
)
|
||||
.expect_err("empty slices must expose the original platform warning");
|
||||
|
||||
assert!(error.contains("至少需要一个独立切片"));
|
||||
assert!(error.contains("原始切片告警"));
|
||||
assert!(error.contains("识别出的素材数量超过输出上限:86,最多允许 256 个"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_spritesheet_contract_rejects_an_opaque_slice() {
|
||||
let canvas_context = ExternalCanvasGenerationContext {
|
||||
@@ -9850,6 +9886,7 @@ mod canvas_generation_tests {
|
||||
|
||||
let error = validate_strict_platform_art_spritesheet_contract(
|
||||
&slices,
|
||||
None,
|
||||
&canvas_context,
|
||||
Some("canvas-project"),
|
||||
Some("spritesheet-resource"),
|
||||
@@ -12449,7 +12486,10 @@ mod canvas_generation_tests {
|
||||
.expect("init strict slice project");
|
||||
let path = root.join("assets/art-spritesheet.png");
|
||||
fs::write(&path, b"old-image").expect("write old spritesheet");
|
||||
let prepared = prepared_replacement(root, b"new-image");
|
||||
let mut prepared = prepared_replacement(root, b"new-image");
|
||||
prepared.slice_warning = Some(
|
||||
"图标 spritesheet 识别出的素材数量超过输出上限:86,最多允许 256 个。".to_string(),
|
||||
);
|
||||
|
||||
let error = commit_prepared_platform_art_asset_strict_slices_at(
|
||||
root,
|
||||
@@ -12460,6 +12500,8 @@ mod canvas_generation_tests {
|
||||
.expect_err("strict spritesheet commit must require at least one slice");
|
||||
|
||||
assert!(error.contains("至少需要一个独立切片"));
|
||||
assert!(error.contains("原始切片告警"));
|
||||
assert!(error.contains("识别出的素材数量超过输出上限:86,最多允许 256 个"));
|
||||
assert_eq!(fs::read(path).expect("read preserved sheet"), b"old-image");
|
||||
assert!(!root
|
||||
.join("assets/art-spritesheet-slices/manifest.json")
|
||||
|
||||
@@ -49,6 +49,61 @@ impl DirectGameCreatorTurnUpdateEmitter {
|
||||
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>,
|
||||
) {
|
||||
let status_is_allowed = matches!(
|
||||
status,
|
||||
@@ -100,6 +155,9 @@ impl DirectGameCreatorTurnUpdateEmitter {
|
||||
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,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1996,6 +1996,29 @@ pub(crate) fn write_game_creator_app_config(
|
||||
persist_game_creator_app_config(config, overlays, false)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn cancel_direct_codex_turn(
|
||||
project_path: String,
|
||||
client_turn_id: Option<String>,
|
||||
) -> Result<DirectTurnCancelView, String> {
|
||||
let root = Path::new(project_path.trim());
|
||||
enforce_project_permission_policy(root, "agent.kill")?;
|
||||
cancel_direct_codex_turn_at(root, client_turn_id.as_deref())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn select_game_creator_reasoning_effort(
|
||||
effort: String,
|
||||
) -> Result<GameCreatorAppConfigView, String> {
|
||||
let _guard = GAME_CREATOR_CONFIG_WRITE_LOCK
|
||||
.lock()
|
||||
.map_err(|_| "配置写入锁不可用")?;
|
||||
let effort = game_creator_llm_reasoning_effort_name(&effort, "llm.reasoningEffort")?;
|
||||
let (mut config, overlays) = load_game_creator_app_config_for_write()?;
|
||||
config.llm.reasoning_effort = effort;
|
||||
persist_game_creator_app_config(config, overlays, false)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn select_game_creator_model(
|
||||
model_id: String,
|
||||
@@ -5170,7 +5193,12 @@ pub(crate) fn create_game_creator_agent_session(
|
||||
let root = Path::new(project_path.trim());
|
||||
enforce_project_permission_policy(root, "conversation.read")?;
|
||||
enforce_project_permission_policy(root, "conversation.write")?;
|
||||
let _lock = acquire_project_write_lock(root, "conversation.write")?;
|
||||
// 首轮策划消息可能紧跟项目初始化写入到达;对话保存应等待这段短暂的
|
||||
// 项目锁竞争,避免把可恢复的初始化竞态直接显示成保存失败。
|
||||
let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
|
||||
root,
|
||||
"conversation.write",
|
||||
)?;
|
||||
create_game_creator_agent_session_at(root, agent_id.trim(), title.trim())
|
||||
}
|
||||
|
||||
@@ -5278,6 +5306,31 @@ pub(crate) async fn read_agent_runtime_error_detail(
|
||||
.await
|
||||
.map_err(|error| format!("读取统一错误诊断后台任务失败:{error}"))?
|
||||
}
|
||||
#[tauri::command]
|
||||
pub(crate) async fn read_direct_tool_calls(
|
||||
project_path: String,
|
||||
) -> Result<Vec<DirectToolCall>, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let root = Path::new(project_path.trim());
|
||||
enforce_project_permission_policy(root, "conversation.read")?;
|
||||
read_direct_tool_calls_at(root)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("读取工具调用历史后台任务失败:{error}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn read_direct_turn_stream(
|
||||
project_path: String,
|
||||
) -> Result<Vec<DirectTurnStreamItem>, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let root = Path::new(project_path.trim());
|
||||
enforce_project_permission_policy(root, "conversation.read")?;
|
||||
read_direct_turn_stream_at(root)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("读取回合流历史后台任务失败:{error}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn list_game_creator_direct_active_turns(
|
||||
@@ -5325,12 +5378,16 @@ pub(crate) async fn read_direct_project_history_slice(
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let root = Path::new(project_path.trim());
|
||||
enforce_project_permission_policy(root, "conversation.read")?;
|
||||
let (items, has_more) = read_direct_project_history_items_slice_at(
|
||||
let (items, has_more, item_timestamps) = read_direct_project_history_items_slice_at(
|
||||
root,
|
||||
before_item_id.as_deref(),
|
||||
limit.unwrap_or(20),
|
||||
)?;
|
||||
Ok(DirectThreadHistorySlice { items, has_more })
|
||||
Ok(DirectThreadHistorySlice {
|
||||
items,
|
||||
has_more,
|
||||
item_timestamps,
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("读取 DirectProject 历史切片后台任务失败:{error}"))?
|
||||
|
||||
@@ -1010,6 +1010,17 @@ struct GameCreatorDirectTurnUpdateEvent {
|
||||
status: String,
|
||||
activity: Option<String>,
|
||||
accumulated_text: Option<String>,
|
||||
/// 本回合内发生变化的结构化工具调用集合(只有变化时才带,老事件没有这个字段)。
|
||||
/// `skip_serializing_if`:字段缺席时前端拿到 `undefined`,行为与改造前一致。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tool_calls: Option<Vec<crate::DirectToolCall>>,
|
||||
/// 本回合当前累计的思考过程(流式整段替换);拿不到时字段缺席。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
reasoning_text: Option<String>,
|
||||
/// 本回合**顺序真相**里本次发生变化的那几条(文本段 / 工具位置标记)。
|
||||
/// `skip_serializing_if`:字段缺席时前端拿到 `undefined`,行为与改造前一致。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
stream_items: Option<Vec<crate::DirectTurnStreamItem>>,
|
||||
updated_at: u64,
|
||||
}
|
||||
|
||||
@@ -2666,6 +2677,8 @@ fn main() {
|
||||
chat_with_game_creator_role_agent,
|
||||
chat_with_game_creator_role_agent_stream,
|
||||
chat_with_game_creator_direct_codex,
|
||||
cancel_direct_codex_turn,
|
||||
select_game_creator_reasoning_effort,
|
||||
start_planning_session_v2,
|
||||
continue_planning_session_v2,
|
||||
decide_planning_artifact_v2,
|
||||
@@ -2769,6 +2782,8 @@ fn main() {
|
||||
archive_game_creator_agent_session,
|
||||
read_local_conversation,
|
||||
read_direct_project_conversation,
|
||||
read_direct_tool_calls,
|
||||
read_direct_turn_stream,
|
||||
read_agent_runtime_error_detail,
|
||||
list_game_creator_direct_active_turns,
|
||||
subscribe_direct_project_thread,
|
||||
|
||||
@@ -272,7 +272,7 @@ fn project_history_skips_direct_project_rows_but_keeps_other_broken_rows_failing
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_project_history_rows_stay_readable_from_both_sides() {
|
||||
fn mixed_project_history_rows_read_by_generic_chain_but_fail_closed_for_direct_project() {
|
||||
let root = unique_conversation_test_root();
|
||||
init_local_game_project_at(&root, "project-1", "写侧统一测试").expect("init project");
|
||||
write_project_history(&root, &[DIRECT_PROJECT_ROW]);
|
||||
@@ -309,19 +309,12 @@ fn mixed_project_history_rows_stay_readable_from_both_sides() {
|
||||
vec!["模式切换后由通用写入器补写的回复", "带 id 的旧格式回复"]
|
||||
);
|
||||
|
||||
// 反向:DirectProject 链把同一份文件里的两种行都读出来,混合文件不构成毒化。
|
||||
let direct_items = crate::agent::read_direct_project_history_items_at(&root)
|
||||
.expect("DirectProject must keep reading the mixed history");
|
||||
assert_eq!(
|
||||
direct_items
|
||||
.iter()
|
||||
.map(|item| item["content"][0]["text"].as_str().unwrap_or_default())
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
"再加一个按钮",
|
||||
"模式切换后由通用写入器补写的回复",
|
||||
"带 id 的旧格式回复",
|
||||
]
|
||||
// 反向:DirectProject 链只接受 response_item 信封,混合文件里的 legacy 行让它失败关闭。
|
||||
let error = crate::agent::read_direct_project_history_items_at(&root)
|
||||
.expect_err("DirectProject must fail closed on legacy rows");
|
||||
assert!(
|
||||
error.starts_with("DirectProject 历史记录类型无效"),
|
||||
"{error}"
|
||||
);
|
||||
|
||||
std::fs::remove_dir_all(root).ok();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1096,6 +1096,57 @@ export type GameCreatorDirectTurnActivity =
|
||||
| 'response-finalization'
|
||||
| 'none';
|
||||
|
||||
export type GameCreatorDirectToolCallKind =
|
||||
| 'command'
|
||||
| 'file_change'
|
||||
| 'mcp_tool'
|
||||
| 'web_search'
|
||||
| 'context_compaction'
|
||||
| 'other';
|
||||
|
||||
export type GameCreatorDirectToolCallStatus =
|
||||
| 'running'
|
||||
| 'completed'
|
||||
| 'failed';
|
||||
|
||||
export interface GameCreatorDirectToolCallChange {
|
||||
path: string;
|
||||
kind: 'add' | 'update' | 'delete' | string;
|
||||
}
|
||||
|
||||
export interface GameCreatorDirectToolCallDetail {
|
||||
command?: string;
|
||||
output?: string;
|
||||
changes?: GameCreatorDirectToolCallChange[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 一条工具调用(Codex item 的结构化投影)。
|
||||
*
|
||||
* 契约见 `docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md`:
|
||||
* 字段形状与 Rust 侧 `DirectToolCall`、独立历史文件
|
||||
* `.agent/conversations/tool-calls.jsonl` 的 payload 一致(这里少 `turnId` 的变体用于
|
||||
* 事件增量,见下面 `GameCreatorDirectTurnToolCall`)。
|
||||
*/
|
||||
export interface GameCreatorDirectToolCall {
|
||||
schemaVersion: string;
|
||||
id: string;
|
||||
turnId: string;
|
||||
kind: GameCreatorDirectToolCallKind;
|
||||
title: string;
|
||||
summary: string;
|
||||
status: GameCreatorDirectToolCallStatus;
|
||||
detail: GameCreatorDirectToolCallDetail;
|
||||
startedAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
/** 事件里下发的增量条目:与持久化同形,去掉 `turnId`(回合 id 在事件顶层)。 */
|
||||
export type GameCreatorDirectTurnToolCall = Omit<
|
||||
GameCreatorDirectToolCall,
|
||||
'turnId'
|
||||
>;
|
||||
|
||||
export interface GameCreatorDirectTurnUpdateEvent {
|
||||
projectPath: string;
|
||||
turnId: string;
|
||||
@@ -1103,9 +1154,69 @@ export interface GameCreatorDirectTurnUpdateEvent {
|
||||
status: GameCreatorDirectTurnUpdateStatus;
|
||||
activity?: GameCreatorDirectTurnActivity | null;
|
||||
accumulatedText?: string | null;
|
||||
/**
|
||||
* 本回合内**发生变化**的结构化工具调用(只有变化时才带,不是每个 heartbeat 都带全量)。
|
||||
* 可选:老版本事件没有这个字段,前端拿到 `undefined` 时必须与改造前行为一致。
|
||||
*/
|
||||
toolCalls?: GameCreatorDirectTurnToolCall[] | null;
|
||||
/**
|
||||
* 本回合当前累计的思考过程(流式,整段替换);拿不到时字段缺席。
|
||||
*/
|
||||
reasoningText?: string | null;
|
||||
/**
|
||||
* 「文本段 + 工具」的**顺序真相**里本次发生变化的那几条。
|
||||
*
|
||||
* 顺序由 `seq`(条目首次出现时钉死)决定,与落盘 `turn-stream.jsonl` 完全同一份数据,
|
||||
* 前端不再自己猜切点。可选:老版本事件没有这个字段。
|
||||
*/
|
||||
streamItems?: TurnStreamItem[] | null;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
/** 回合流里的一个 `text` 段;`text` 是该段当前累计全文(会随 delta 增长)。 */
|
||||
export interface TurnStreamTextItem extends TurnStreamItemBase {
|
||||
kind: 'text';
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** 回合流里的一个 `tool` 位置标记;工具正文在 `tool-calls.jsonl`(按 `callId` 关联)。 */
|
||||
export interface TurnStreamToolItem extends TurnStreamItemBase {
|
||||
kind: 'tool';
|
||||
callId: string;
|
||||
}
|
||||
|
||||
interface TurnStreamItemBase {
|
||||
schemaVersion: string;
|
||||
/** 幂等身份:文本段 `text:<turnId>:<itemId>`、工具 `tool:<turnId>:<callId>`。 */
|
||||
id: string;
|
||||
turnId: string;
|
||||
/** 首次出现的写入序号:**顺序真相**,按它升序渲染。 */
|
||||
seq: number;
|
||||
/** 条目首次出现的时刻(Unix 毫秒),同 `seq` 时用它排序。 */
|
||||
at: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 回合流条目(`read_direct_turn_stream` 的返回元素)。
|
||||
*
|
||||
* 与 Rust `DirectTurnStreamItem` 同形:`text` 段 ↔ `tool` 位置标记。
|
||||
*/
|
||||
export type TurnStreamItem = TurnStreamTextItem | TurnStreamToolItem;
|
||||
|
||||
/** `cancel_direct_codex_turn` 的返回值。 */
|
||||
export interface DirectTurnCancelView {
|
||||
/**
|
||||
* `interrupted` = 已向正在跑的回合发出中断,界面等这一轮自己的收尾复位;
|
||||
* `released` = app-server 侧已无句柄,本轮守卫被兜底释放,界面必须自己复位。
|
||||
*/
|
||||
outcome: string;
|
||||
/** 给用户看的可读结果。 */
|
||||
message: string;
|
||||
/** 被终止 / 被释放的 clientTurnId。 */
|
||||
clientTurnId: string;
|
||||
}
|
||||
|
||||
export interface AgentRunControlResult {
|
||||
runId: string;
|
||||
status: string;
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/* 只作用于共享 Markdown 渲染器,不改变普通正文及用户消息的字体颜色。 */
|
||||
.agc-markdown-code .hljs-comment,
|
||||
.agc-markdown-code .hljs-quote {
|
||||
color: #6a737d;
|
||||
}
|
||||
|
||||
.agc-markdown-code .hljs-keyword,
|
||||
.agc-markdown-code .hljs-name,
|
||||
.agc-markdown-code .hljs-selector-tag,
|
||||
.agc-markdown-code .hljs-literal,
|
||||
.agc-markdown-code .hljs-deletion {
|
||||
color: #a6264c;
|
||||
}
|
||||
|
||||
.agc-markdown-code .hljs-string,
|
||||
.agc-markdown-code .hljs-regexp,
|
||||
.agc-markdown-code .hljs-addition {
|
||||
color: #276438;
|
||||
}
|
||||
|
||||
.agc-markdown-code .hljs-number,
|
||||
.agc-markdown-code .hljs-attr,
|
||||
.agc-markdown-code .hljs-variable,
|
||||
.agc-markdown-code .hljs-built_in {
|
||||
color: #075a9c;
|
||||
}
|
||||
|
||||
.agc-markdown-code .hljs-title,
|
||||
.agc-markdown-code .hljs-type,
|
||||
.agc-markdown-code .hljs-section {
|
||||
color: #6f42a0;
|
||||
}
|
||||
|
||||
.agc-markdown-code .hljs-meta,
|
||||
.agc-markdown-code .hljs-symbol {
|
||||
color: #8a4c0a;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import './codeHighlight.css';
|
||||
|
||||
import type { ErrorInfo, ReactNode } from 'react';
|
||||
import {
|
||||
Children,
|
||||
@@ -7,14 +9,33 @@ import {
|
||||
useContext,
|
||||
} from 'react';
|
||||
import ReactMarkdown, { type Components } from 'react-markdown';
|
||||
import rehypeHighlight from 'rehype-highlight';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
export type ChatMarkdownMessageProps = {
|
||||
text: string;
|
||||
role: 'assistant' | 'user';
|
||||
streaming?: boolean;
|
||||
/** 文件预览不压缩正文空行,保留源码与文档的原始排版。 */
|
||||
preserveBlankLines?: boolean;
|
||||
};
|
||||
|
||||
const MAX_HIGHLIGHT_CHARACTERS = 100_000;
|
||||
const CodeBlockContext = createContext(false);
|
||||
|
||||
/** 只压缩普通 Markdown 正文里多余的空行;代码块中的换行必须原样保留。 */
|
||||
function normalizeMarkdownBlankLines(text: string) {
|
||||
return text
|
||||
.replace(/\r\n?/g, '\n')
|
||||
.split(/(```[\s\S]*?```)/g)
|
||||
.map((part, index) =>
|
||||
index % 2 === 1
|
||||
? part
|
||||
: part.replace(/[ \t]*\n(?:[ \t]*\n){2,}/g, '\n\n'),
|
||||
)
|
||||
.join('');
|
||||
}
|
||||
|
||||
type MarkdownErrorBoundaryProps = {
|
||||
fallbackText: string;
|
||||
children: ReactNode;
|
||||
@@ -66,7 +87,6 @@ export class MarkdownErrorBoundary extends Component<
|
||||
}
|
||||
|
||||
const ListDepthContext = createContext(0);
|
||||
const ListKindContext = createContext<'unordered' | 'ordered' | null>(null);
|
||||
type ListItemParagraphPosition = 'first' | 'continuation';
|
||||
|
||||
const ListItemContext = createContext<ListItemParagraphPosition | null>(null);
|
||||
@@ -75,15 +95,11 @@ function MarkdownUnorderedList({ children }: { children?: ReactNode }) {
|
||||
const depth = useContext(ListDepthContext);
|
||||
return (
|
||||
<ListDepthContext.Provider value={depth + 1}>
|
||||
<ListKindContext.Provider value="unordered">
|
||||
<ul
|
||||
className={`m-0 mt-2 list-none space-y-1 first:mt-0 ${
|
||||
depth > 0 ? 'pl-4' : 'pl-0'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</ul>
|
||||
</ListKindContext.Provider>
|
||||
{/* 用真正的列表标记(`list-disc`)而不是手写 `'- '` 文本:手写前缀既没有悬挂缩进
|
||||
(换行后的第二行会顶回最左边),也不算列表语义(读屏读成普通文本)。 */}
|
||||
<ul className="m-0 mt-2 list-disc space-y-1 pl-4 first:mt-0">
|
||||
{children}
|
||||
</ul>
|
||||
</ListDepthContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -98,14 +114,12 @@ function MarkdownOrderedList({
|
||||
const depth = useContext(ListDepthContext);
|
||||
return (
|
||||
<ListDepthContext.Provider value={depth + 1}>
|
||||
<ListKindContext.Provider value="ordered">
|
||||
<ol
|
||||
start={start}
|
||||
className="m-0 mt-2 list-decimal space-y-1 pl-5 first:mt-0"
|
||||
>
|
||||
{children}
|
||||
</ol>
|
||||
</ListKindContext.Provider>
|
||||
<ol
|
||||
start={start}
|
||||
className="m-0 mt-2 list-decimal space-y-1 pl-5 first:mt-0"
|
||||
>
|
||||
{children}
|
||||
</ol>
|
||||
</ListDepthContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -145,7 +159,6 @@ function StreamingMarkdownParagraph({ children }: { children?: ReactNode }) {
|
||||
}
|
||||
|
||||
function MarkdownListItem({ children }: { children?: ReactNode }) {
|
||||
const listKind = useContext(ListKindContext);
|
||||
let paragraphIndex = 0;
|
||||
const childrenWithParagraphContext = Children.map(
|
||||
children,
|
||||
@@ -168,7 +181,6 @@ function MarkdownListItem({ children }: { children?: ReactNode }) {
|
||||
);
|
||||
return (
|
||||
<li className="break-words whitespace-normal">
|
||||
{listKind === 'unordered' ? '- ' : null}
|
||||
{childrenWithParagraphContext}
|
||||
</li>
|
||||
);
|
||||
@@ -179,22 +191,50 @@ const markdownComponents: Components = {
|
||||
a: ({ children }) => children,
|
||||
img: ({ alt }) => (alt?.trim() ? `图片:${alt}` : '图片已省略'),
|
||||
h1: ({ children }) => (
|
||||
<h1 className="m-0 mt-4 !text-xl font-bold first:mt-0">{children}</h1>
|
||||
<h1
|
||||
className="m-0 mt-4 font-bold first:mt-0"
|
||||
style={{ fontSize: 'var(--agent-message-heading-size, 1.25rem)' }}
|
||||
>
|
||||
{children}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2 className="m-0 mt-4 !text-lg font-bold first:mt-0">{children}</h2>
|
||||
<h2
|
||||
className="m-0 mt-4 font-bold first:mt-0"
|
||||
style={{ fontSize: 'var(--agent-message-heading-size, 1.125rem)' }}
|
||||
>
|
||||
{children}
|
||||
</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3 className="m-0 mt-3 !text-base font-semibold first:mt-0">{children}</h3>
|
||||
<h3
|
||||
className="m-0 mt-3 font-semibold first:mt-0"
|
||||
style={{ fontSize: 'var(--agent-message-heading-size, 1rem)' }}
|
||||
>
|
||||
{children}
|
||||
</h3>
|
||||
),
|
||||
h4: ({ children }) => (
|
||||
<h4 className="m-0 mt-3 !text-sm font-semibold first:mt-0">{children}</h4>
|
||||
<h4
|
||||
className="m-0 mt-3 font-semibold first:mt-0"
|
||||
style={{ fontSize: 'var(--agent-message-heading-size, 0.875rem)' }}
|
||||
>
|
||||
{children}
|
||||
</h4>
|
||||
),
|
||||
h5: ({ children }) => (
|
||||
<h5 className="m-0 mt-2 !text-sm font-medium first:mt-0">{children}</h5>
|
||||
<h5
|
||||
className="m-0 mt-2 font-medium first:mt-0"
|
||||
style={{ fontSize: 'var(--agent-message-heading-size, 0.875rem)' }}
|
||||
>
|
||||
{children}
|
||||
</h5>
|
||||
),
|
||||
h6: ({ children }) => (
|
||||
<h6 className="m-0 mt-2 !text-xs font-medium uppercase tracking-wide first:mt-0">
|
||||
<h6
|
||||
className="m-0 mt-2 font-medium uppercase tracking-wide first:mt-0"
|
||||
style={{ fontSize: 'var(--agent-message-heading-size, 0.75rem)' }}
|
||||
>
|
||||
{children}
|
||||
</h6>
|
||||
),
|
||||
@@ -209,15 +249,18 @@ const markdownComponents: Components = {
|
||||
),
|
||||
pre: ({ children }) => (
|
||||
<pre className="m-0 mt-2 max-w-full overflow-x-auto rounded-lg bg-black/6 p-3 text-xs leading-5 first:mt-0">
|
||||
{children}
|
||||
<CodeBlockContext.Provider value={true}>
|
||||
{children}
|
||||
</CodeBlockContext.Provider>
|
||||
</pre>
|
||||
),
|
||||
code: ({ className, children, node: _node, ...props }) => {
|
||||
const isBlock =
|
||||
Boolean(className?.includes('language-')) ||
|
||||
String(children).includes('\n');
|
||||
code: function MarkdownCode({ className, children, node: _node, ...props }) {
|
||||
const isBlock = useContext(CodeBlockContext);
|
||||
return isBlock ? (
|
||||
<code {...props} className="font-mono whitespace-pre">
|
||||
<code
|
||||
{...props}
|
||||
className={`agc-markdown-code font-mono whitespace-pre ${className ?? ''}`}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
) : (
|
||||
@@ -231,7 +274,10 @@ const markdownComponents: Components = {
|
||||
},
|
||||
table: ({ children }) => (
|
||||
<div className="mt-2 max-w-full overflow-x-auto first:mt-0">
|
||||
<table className="min-w-full border-collapse text-left text-sm">
|
||||
<table
|
||||
className="min-w-full border-collapse text-left"
|
||||
style={{ fontSize: 'var(--agent-message-table-size, 0.875rem)' }}
|
||||
>
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
@@ -260,6 +306,7 @@ export function ChatMarkdownMessage({
|
||||
text,
|
||||
role,
|
||||
streaming = false,
|
||||
preserveBlankLines = false,
|
||||
}: ChatMarkdownMessageProps) {
|
||||
if (role === 'user') {
|
||||
return <span className="whitespace-pre-wrap break-words">{text}</span>;
|
||||
@@ -270,11 +317,14 @@ export function ChatMarkdownMessage({
|
||||
<ReactMarkdown
|
||||
skipHtml
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={
|
||||
text.length <= MAX_HIGHLIGHT_CHARACTERS ? [rehypeHighlight] : []
|
||||
}
|
||||
components={
|
||||
streaming ? streamingMarkdownComponents : markdownComponents
|
||||
}
|
||||
>
|
||||
{text}
|
||||
{preserveBlankLines ? text : normalizeMarkdownBlankLines(text)}
|
||||
</ReactMarkdown>
|
||||
</MarkdownErrorBoundary>
|
||||
);
|
||||
|
||||
@@ -3,10 +3,12 @@ import { Copy, Minus, Square, X } from 'lucide-react';
|
||||
import { type ReactNode, useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import brandIcon from '../../../../packages/shared/src/icons/taonier-product-ip.png';
|
||||
import { ActiveProjectRunsPanel } from '../features/app-shell/ActiveProjectRunsPanel';
|
||||
import { subscribeTauriEvent } from '../services/tauriEventSubscription';
|
||||
import { AppUpdateNotice } from './AppUpdateNotice';
|
||||
import {
|
||||
WINDOW_CHROME_DEFAULT_TITLE,
|
||||
type WindowChromeActiveProjectRuns,
|
||||
WindowChromeContext,
|
||||
type WindowChromeContextValue,
|
||||
} from './windowChromeContext';
|
||||
@@ -39,6 +41,8 @@ function getNativeWindow() {
|
||||
export function WindowChrome({ children }: WindowChromeProps) {
|
||||
const [title, setTitleState] = useState(WINDOW_CHROME_DEFAULT_TITLE);
|
||||
const [walletSlot, setWalletSlot] = useState<HTMLDivElement | null>(null);
|
||||
const [activeProjectRuns, setActiveProjectRuns] =
|
||||
useState<WindowChromeActiveProjectRuns | null>(null);
|
||||
|
||||
const setTitle = useCallback((nextTitle: string | null | undefined) => {
|
||||
const normalizedTitle = nextTitle?.trim();
|
||||
@@ -50,6 +54,8 @@ export function WindowChrome({ children }: WindowChromeProps) {
|
||||
title,
|
||||
setTitle,
|
||||
walletSlot,
|
||||
activeProjectRuns,
|
||||
setActiveProjectRuns,
|
||||
};
|
||||
|
||||
const [isMaximized, setIsMaximized] = useState(false);
|
||||
@@ -142,19 +148,33 @@ export function WindowChrome({ children }: WindowChromeProps) {
|
||||
</div>
|
||||
|
||||
<div className="window-chrome__drag-region" data-tauri-drag-region>
|
||||
<span className="window-chrome__title-wrap">
|
||||
<span
|
||||
className="window-chrome__workspace-dot"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
className="window-chrome__title"
|
||||
title={title}
|
||||
aria-label={`当前工作区:${title}`}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
</span>
|
||||
<div className="window-chrome__title-wrap">
|
||||
{activeProjectRuns &&
|
||||
(activeProjectRuns.activeTurns.length > 0 ||
|
||||
activeProjectRuns.readFailed) ? (
|
||||
<ActiveProjectRunsPanel
|
||||
activeTurns={activeProjectRuns.activeTurns}
|
||||
currentProjectPath={activeProjectRuns.currentProjectPath}
|
||||
readFailed={activeProjectRuns.readFailed}
|
||||
onOpenProject={activeProjectRuns.onOpenProject}
|
||||
placement="titlebar"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<span
|
||||
className="window-chrome__workspace-dot"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
className="window-chrome__title"
|
||||
title={title}
|
||||
aria-label={`当前工作区:${title}`}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="window-chrome__trailing">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user