合并主线对话状态与抠图背景色更新
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 4m56s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Failing after 6m9s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 5m28s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m33s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m23s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 4m39s
Project CI / Repository checks (pull_request) Successful in 4m50s
Project CI / Backend tests (pull_request) Successful in 8m38s
Project CI / Frontend tests (pull_request) Successful in 8m2s
Project CI / AI game creator shell web tests (pull_request) Successful in 8m11s
Project CI / Native shell tests (pull_request) Successful in 15m27s

合入 master a2e6f205d,保留远端功能分支已有合并历史。
融合生成选项的参考素材、目标栏目与抠图背景色字段。
保留主线统一线程状态与重试策略,完成前端及原生定向验证。
This commit is contained in:
2026-09-18 10:31:55 +08:00
70 changed files with 6221 additions and 4099 deletions
+16
View File
@@ -172,6 +172,20 @@ _Avoid_: 多步骤向导、完整规则编辑器、拖拽编辑器
Bark Battle 平台作品闭环按契约与领域规则、后端存储/API、最小前端纵切、投影体验、收口验证的顺序推进。
_Avoid_: mock 先行堆积、前后端各自发散、先做排行榜 UI
## 项目开发对话(DirectProject
**项目对话历史**:
AGC 本地项目内 Codex 原始对话条目的持久集合,是聊天展示、工具卡片和线程恢复注入的唯一持久事实源。
_Avoid_: 会话缓存、展示态历史、按 UI 需要另存的对话副本
**运行态事件**:
Thread Manager 向订阅者推送的当前回合原始事件流,只服务运行期间与短期断线恢复,不替代项目对话历史。
_Avoid_: 进度通知、快照轮询、第二套历史
**聊天投影**:
把项目对话历史条目与运行态事件转换成消息气泡和工具卡片的读取期转换;不持久化,也不构成事实源。
_Avoid_: 投影缓存文件、已脱敏卡片库、第二套 reducer
## Relationships
- 一个 **汪汪声浪大作战** 单局包含多个 **有效声浪触发**
@@ -206,3 +220,5 @@ _Avoid_: mock 先行堆积、前后端各自发散、先做排行榜 UI
- “入口闭环”曾可能只指内部 demo 或单个详情 CTA;已解析为 **正式作品入口闭环**,不新增独立专区或活动页。
- “创作编辑”曾可能指多步骤向导或完整编辑器;已解析为 **轻配置编辑流程**,使用单页表单 + 预览卡片完成保存草稿、发布和发布后跳转作品详情。
- “实施顺序”曾可能按 UI 或功能并行发散;已解析为契约/领域规则先行,再做后端存储/API,随后打通最小前端纵切,最后补投影体验与收口验证。
- “回合进度事件”曾同时指 Direct turn update 与 Thread Manager 运行态事件;已解析为 AGC 项目开发对话只保留 **运行态事件**
- “哪些消息可显示”曾可能由后端历史分页判断;已解析为可见性判断属于 **聊天投影**,后端只按原始条目分页,前端负责跳过不可显示条目并推进分页锚点。
@@ -1,6 +1,6 @@
{
"schemaVersion": "agc-skill-pack.v1",
"version": "2026-08-26.23",
"version": "2026-08-26.24",
"skills": [
{
"name": "agc-game-production-workflow",
@@ -63,7 +63,7 @@
"agents/openai.yaml",
"references/platform-art-contract.md"
],
"sha256": "c6329c6a3cbd17a237d042349d7fd8adcf240287ef56d23b49329923e976d534"
"sha256": "47ac742d9b88e5d6cd9833484ab212152578e58ae27f7add312fd1d78183385c"
},
{
"name": "agc-web-game-development",
@@ -19,6 +19,12 @@ image, UI design image, or publication material; use `agc_edit_image` for an
edit of an existing registered image; use `taonier_prepare_game_art` only for
the complete game-art package and its canonical slices.
With `agc_generate_image`, `kind="character"` and `kind="art-spritesheet"`
generate the subject on a solid-colour background and automatically matte it
away afterwards, producing transparent-background results; write the prompt
for the subject only, never for a scene. `kind="image"` keeps the rendered
frame without extra processing.
When `agc_generate_image` is used with `kind="art-spritesheet"`, `sliceMode` is
required and has no default, so decide it explicitly:
@@ -21,6 +21,7 @@ mod direct_project_history;
mod direct_project_turn_history;
mod direct_runtime;
mod direct_thread_manager;
mod direct_thread_wire;
mod direct_tool_bridge;
mod direct_tool_calls;
mod direct_tools_mcp;
@@ -40,7 +41,8 @@ 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, DirectTurnCancelView,
direct_game_creator_codex_chat_at, direct_game_creator_home_codex_chat,
direct_thread_id_for_project, DirectTurnCancelView,
};
use codex_cli::*;
pub(crate) use codex_cli::{
@@ -55,6 +57,7 @@ pub(crate) use direct_project_history::*;
pub(crate) use direct_project_turn_history::*;
pub(crate) use direct_runtime::*;
pub(crate) use direct_thread_manager::*;
pub(crate) use direct_thread_wire::*;
pub(crate) use direct_tool_bridge::*;
pub(crate) use direct_tool_calls::*;
pub(crate) use direct_tools_mcp::*;
@@ -20,6 +20,29 @@ pub(crate) fn direct_codex_canonical_project_identity(
))
}
/// 项目根目录在 Thread Manager 里的线程身份。
///
/// 订阅入口、回合事件写入和"回合被兜底释放"三处必须算出同一个字符串,否则前端会订阅到
/// 一个永不产生事件的空线程。这个字符串**只取决于路径**:能归一就用 canonical 路径,只有
/// 归一本身失败(路径不存在 / 不是目录 / 无法安全解析)才退回调用方给的字符串。
///
/// 这里刻意不读 `.agent/manifest.json`:那次读取是"项目权威身份"(连接池摘要,见
/// `direct_codex_canonical_project_identity`)的要求,而线程 id 只是一个路径 key。把
/// manifest 的瞬时抖动混进线程 id,会让同一项目在"订阅那一刻"与"跑回合那一刻"算出两个
/// 字符串(例如调用方给的是符号链接路径),订阅就绑到一条永远不会有事件的空线程上。
pub(crate) fn direct_thread_id_for_project(root: &std::path::Path) -> String {
let Ok((canonical_root, _)) = resolve_direct_codex_project_authority(root) else {
return root.to_string_lossy().into_owned();
};
canonical_root
.to_str()
.and_then(|value| value.strip_prefix(r"\\?\"))
.map(std::path::Path::new)
.unwrap_or(canonical_root.as_path())
.to_string_lossy()
.into_owned()
}
pub(super) fn direct_codex_os_path_identity_bytes(path: &std::path::Path) -> Vec<u8> {
#[cfg(unix)]
{
File diff suppressed because it is too large Load Diff
@@ -550,6 +550,8 @@ fn extract_mcp_arguments(root: &Path, tool: &str, arguments: &Value) -> Value {
"agc_generate_image" => {
copy_string(object, "kind", &mut out);
copy_string(object, "sliceMode", &mut out);
copy_number(object, "sliceCount", &mut out);
copy_string(object, "screenColor", &mut out);
copy_string(object, "aspectRatio", &mut out);
copy_string(object, "imageSize", &mut out);
copy_string(object, "assetName", &mut out);
@@ -1194,7 +1196,13 @@ mod tests {
"item": {
"type": "mcpToolCall",
"tool": "agc_generate_image",
"arguments": { "prompt": prompt, "kind": "icon-spec" }
"arguments": {
"prompt": prompt,
"kind": "art-spritesheet",
"sliceMode": "connected-components",
"sliceCount": 8,
"screenColor": "#CFEFFF"
}
}
}));
audit.finish(true);
@@ -1205,6 +1213,8 @@ mod tests {
let stored = item["arguments"]["prompt"].as_str().expect("prompt");
assert_eq!(stored.chars().count(), DIRECT_CODEX_AUDIT_BRIEF_CHARS);
assert_eq!(item["arguments"]["promptChars"], json!(5000));
assert_eq!(item["arguments"]["sliceCount"], json!(8));
assert_eq!(item["arguments"]["screenColor"], json!("#CFEFFF"));
assert_eq!(
item["arguments"]["promptSha256"],
json!(sha256_hex("".repeat(5000).as_bytes()))
@@ -5,8 +5,8 @@ mod validation;
mod wire;
pub(crate) use model::{
DirectCodexUserContentPart, DirectCodexUserItem, DirectCodexUserMessageEnvelope,
DirectCodexUserMessageItem, DirectCodexUserRole, DirectCodexUserRuntimeRegionPart,
DirectCodexUserContentPart, DirectCodexUserItem, DirectCodexUserMessageItem,
DirectCodexUserRole, DirectCodexUserRuntimeRegionPart,
};
pub(crate) use validation::validate_direct_codex_user_item;
pub(crate) use wire::{
@@ -61,13 +61,6 @@ pub(crate) struct DirectCodexUserRuntimeRegionPart {
pub(crate) resource_ids: Vec<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))]
pub(crate) struct DirectCodexUserMessageEnvelope {
pub(crate) item: DirectCodexUserItem,
}
#[cfg(test)]
mod tests {
use super::*;
File diff suppressed because it is too large Load Diff
@@ -3401,6 +3401,7 @@ async fn generate_direct_taonier_art_asset_at(
grid_y: None,
reference_asset_ids: Vec::new(),
target_category: None,
screen_color: None,
};
let runtime_context =
direct_taonier_art_generation_runtime_context(root, output_path, asset_kind)?;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -2224,12 +2224,48 @@ fn validate_generate_image_slice_declaration(
}
if slice_mode.is_some() || grid_x.is_some() || grid_y.is_some() || slice_count.is_some() {
return Err(format!(
"工具参数 sliceMode/gridX/gridY 仅对 kind=art-spritesheet 生效,当前 kind={kind}"
"工具参数 sliceMode/gridX/gridY/sliceCount 仅对 kind=art-spritesheet 生效,当前 kind={kind}"
));
}
Ok(())
}
/// 抠图纯色背景只服务 character 与 art-spritesheet 链路;格式校验收口为
/// `auto` 或 `#RRGGBB`(服务端另有支持色板,客户端不复制),`auto`/空串归一为
/// None(服务端自动决策),hex 统一大写后透传。其它 kind 携带该字段直接拒绝,
/// 避免服务端静默忽略造成“已生效”的误解。
fn normalize_generate_image_screen_color(
arguments: &Value,
kind: &str,
) -> Result<Option<String>, String> {
let Some(value) = arguments.get("screenColor") else {
return Ok(None);
};
if value.is_null() {
return Ok(None);
}
if !matches!(kind, "character" | "art-spritesheet") {
return Err(format!(
"工具参数 screenColor 仅对 kind=character 和 kind=art-spritesheet 生效,当前 kind={kind}"
));
}
let raw = value
.as_str()
.ok_or_else(|| "工具参数 screenColor 必须是 auto 或 #RRGGBB".to_string())?
.trim();
if raw.is_empty() || raw.eq_ignore_ascii_case("auto") {
return Ok(None);
}
let normalized = raw.to_ascii_uppercase();
let valid = normalized.len() == 7
&& normalized.starts_with('#')
&& normalized[1..].chars().all(|c| c.is_ascii_hexdigit());
if !valid {
return Err("工具参数 screenColor 必须是 auto 或 #RRGGBB".to_string());
}
Ok(Some(normalized))
}
async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value) -> Value {
let result = async {
bridge_reject_unknown_fields(
@@ -2244,6 +2280,8 @@ async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value)
"sliceMode",
"gridX",
"gridY",
"sliceCount",
"screenColor",
],
)?;
enforce_project_permission_policy(&state.root, "canvas.asset_generate")?;
@@ -2323,13 +2361,25 @@ async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value)
{
return Err("工具参数 gridX/gridY 必须在 1 到 32 之间".to_string());
}
let slice_count = arguments
.get("sliceCount")
.filter(|value| !value.is_null())
.map(|value| {
value
.as_u64()
.filter(|count| (1..=256).contains(count))
.map(|count| count as usize)
.ok_or_else(|| "工具参数 sliceCount 必须是 1 到 256 的整数".to_string())
})
.transpose()?;
validate_generate_image_slice_declaration(
kind.as_str(),
slice_mode.as_deref(),
grid_x,
grid_y,
None,
slice_count,
)?;
let screen_color = normalize_generate_image_screen_color(arguments, kind.as_str())?;
let options = PlatformArtAssetGenerationOptions {
output_path,
aspect_ratio,
@@ -2337,12 +2387,13 @@ async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value)
asset_kind: kind.clone(),
asset_label: asset_name.clone(),
replace_existing: false,
slice_count: None,
slice_count,
slice_mode,
grid_x,
grid_y,
reference_asset_ids: Vec::new(),
target_category: None,
screen_color,
};
let _generation_guard = state.image_generation_gate.lock().await;
let generated = with_direct_editor_api_credentials(
@@ -2866,9 +2917,71 @@ mod tests {
wrong_kind.contains("仅对 kind=art-spritesheet 生效"),
"{wrong_kind}"
);
let wrong_kind_count =
validate_generate_image_slice_declaration("image", None, None, None, Some(8))
.expect_err("sliceCount-only violation must be rejected");
assert!(
wrong_kind_count.contains("sliceCount"),
"sliceCount-only violation must name sliceCount: {wrong_kind_count}"
);
assert!(validate_generate_image_slice_declaration("image", None, None, None, None).is_ok());
}
#[test]
fn generate_image_screen_color_is_normalized_and_kind_gated() {
// 省略与显式 null 等价,且不触发 kind 门禁。
assert_eq!(
normalize_generate_image_screen_color(&json!({}), "image").expect("omitted"),
None
);
assert_eq!(
normalize_generate_image_screen_color(&json!({"screenColor": null}), "image")
.expect("null"),
None
);
// auto 家族归一为 None(服务端自动决策),大小写与空白不敏感。
for raw in ["auto", "AUTO", " auto ", ""] {
assert_eq!(
normalize_generate_image_screen_color(&json!({"screenColor": raw}), "character")
.expect("auto variants"),
None,
"{raw}"
);
}
// hex 统一大写透传;色板白名单由服务端权威校验,客户端只守格式。
assert_eq!(
normalize_generate_image_screen_color(&json!({"screenColor": "#cfefff"}), "character")
.expect("lowercase hex"),
Some("#CFEFFF".to_string())
);
assert_eq!(
normalize_generate_image_screen_color(
&json!({"screenColor": " #A0BBA0 "}),
"art-spritesheet"
)
.expect("padded hex"),
Some("#A0BBA0".to_string())
);
// 非 auto/非 hex、非字符串一律拒绝。
for bad in [json!("green"), json!("#GGGGGG"), json!("#FFF"), json!(12)] {
assert!(
normalize_generate_image_screen_color(&json!({"screenColor": bad}), "character")
.is_err(),
"{bad}"
);
}
// 其它 kind 携带该字段直接拒绝,即使取值合法。
let gated =
normalize_generate_image_screen_color(&json!({"screenColor": "#CFEFFF"}), "image")
.expect_err("screenColor must stay scoped to character/art-spritesheet");
assert!(gated.contains("kind=character"), "{gated}");
assert!(normalize_generate_image_screen_color(
&json!({"screenColor": "auto"}),
"ui-prototype"
)
.is_err());
}
#[test]
fn remove_background_identity_preserves_default_and_distinguishes_options() {
let legacy = "asset-1\0透明图";
@@ -1,4 +1,4 @@
//! GameAgent 对话「工具调用卡片」的采集持久化与回读
//! GameAgent 对话「工具调用卡片」的采集持久化。
//!
//! 契约见 `docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md`
//! Codex app-server 的 `item/started` / `item/completed` 里带着完整的命令 / 文件变更
@@ -8,11 +8,9 @@
//! 为什么不复用 `project.jsonl`:那条链路的回读只投影 `role ∈ {user, assistant}` 的
//! 文本条目,而且会被注入 Codex 上下文。往里面塞新形状既装不下,又有污染模型上下文的风险。
use crate::agent::redact_secret_tokens;
use crate::agent::sanitize_error_context;
use crate::config::{prepare_game_creator_private_path_for_read, write_game_creator_private_file};
use super::direct_thread_wire::sanitize_detail_text;
use crate::config::write_game_creator_private_file;
use crate::project::{enforce_project_permission_policy, project_append_lock_for};
use crate::redact_absolute_path_tokens;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::BTreeMap;
@@ -24,7 +22,7 @@ use std::path::{Path, PathBuf};
pub(crate) const DIRECT_TOOL_CALL_RECORD_TYPE: &str = "tool_call_item";
/// 条目 schema 版本。
pub(crate) const DIRECT_TOOL_CALL_SCHEMA_VERSION: &str = "agc-tool-call.v1";
/// 回读上限:只保留最近这么多条(按 `updatedAt` / `startedAt` 取最新)。
/// 落盘上限:只保留最近这么多条(按 `updatedAt` / `startedAt` 取最新)。
pub(crate) const DIRECT_TOOL_CALL_LIMIT: usize = 200;
/// `detail.command` / `detail.output` 的字符上限。
const DIRECT_TOOL_CALL_DETAIL_MAX_CHARS: usize = 4000;
@@ -86,134 +84,6 @@ fn tool_calls_path(root: &Path) -> PathBuf {
root.join(".agent/conversations/tool-calls.jsonl")
}
/// 项目根目录之后的路径 token:分隔符统一成 `/`,返回 `(消费到的下标, 项目相对路径)`。
fn project_relative_path_segment(value: &str, start: usize) -> (usize, String) {
let mut index = start;
let mut relative = String::new();
while index < value.len() {
let character = value[index..].chars().next().unwrap_or_default();
if matches!(character, '/' | '\\') {
if !relative.is_empty() {
relative.push('/');
}
index += character.len_utf8();
continue;
}
if character.is_whitespace()
|| matches!(
character,
'\'' | '"'
| '`'
| ','
| ';'
| '|'
| '&'
| '('
| ')'
| '['
| ']'
| '{'
| '}'
| '<'
| '>'
| ':'
)
{
break;
}
relative.push(character);
index += character.len_utf8();
}
while relative.ends_with('/') {
relative.pop();
}
(index, relative)
}
/// 把项目根目录前缀换成**项目相对路径**`<root>/game/src/x.ts` → `game/src/x.ts`)。
///
/// 必须排在 `redact_absolute_path_tokens` 之前:后者会把整个绝对路径抹成
/// `<absolute-path>`,之后就再也认不出哪些路径在项目内了。
/// Windows 上同时匹配 `\` 与 `/` 两种分隔符写法,并按大小写不敏感比较(盘符大小写会变)。
fn relativize_project_root_paths(root: &Path, value: &str) -> String {
let root_text = root.to_string_lossy();
let root_text = root_text.trim_end_matches(['/', '\\']);
if root_text.is_empty() {
return value.to_string();
}
let mut needles = [
root_text.to_string(),
root_text.replace('\\', "/"),
root_text.replace('/', "\\"),
]
.into_iter()
.map(|needle| needle.to_ascii_lowercase())
.filter(|needle| !needle.is_empty())
.collect::<Vec<_>>();
needles.sort();
needles.dedup();
let lower = value.to_ascii_lowercase();
let mut output = String::with_capacity(value.len());
let mut cursor = 0usize;
while cursor < value.len() {
let mut hit: Option<(usize, usize)> = None;
for needle in &needles {
let mut search = cursor;
while let Some(relative) = lower[search..].find(needle.as_str()) {
let start = search + relative;
let end = start + needle.len();
let left_is_boundary = start == 0
|| lower[..start].chars().next_back().is_some_and(|character| {
!character.is_alphanumeric() && character != '_' && character != '-'
});
if left_is_boundary && value[end..].starts_with(['/', '\\']) {
if hit.is_none_or(|(best_start, _)| start < best_start) {
hit = Some((start, end));
}
break;
}
search = end;
}
}
let Some((start, end)) = hit else {
break;
};
output.push_str(&value[cursor..start]);
let (consumed, relative) = project_relative_path_segment(value, end);
if relative.is_empty() {
// 只写了项目根目录本身(没有后续路径段):按占位形状处理。
output.push_str("<absolute-path>");
} else {
output.push_str(&relative);
}
cursor = consumed;
}
output.push_str(&value[cursor..]);
output
}
/// 脱敏:项目内绝对路径先归一化成项目相对路径,再依次做绝对路径、密钥前缀与
/// 错误上下文脱敏。
///
/// 顺序不能反:先抹密钥会把 `sk-…` 之类的 token 换成占位符,但绝对路径里的用户名目录
/// 仍然会留下;这里先归一化路径 token,再处理密钥。
///
/// 复用既有 `agent/generation/prompt_context.rs` 的脱敏组合:`sanitize_error_context`
/// 就是 `redact_secret_tokens` + `redact_error_sensitive_assignments` +
/// `redact_error_bearer_values` + `redact_error_config_names` 的既有组合用法,覆盖
/// `Authorization: Bearer …`、`Cookie: …`、`api_key=…`、`client_secret=…` 这类键值凭据;
/// 含 `--password` / `--token` / `--secret` 这类敏感 CLI 标志的行按既有 fail-closed
/// 约定整行替换成 `[redacted sensitive context]`(与 `sanitize_agent_runtime_text` 一致)。
///
/// `pub(crate)`:回合流(`direct_turn_stream`)的文本段复用同一套脱敏,避免两处口径分叉。
pub(crate) fn sanitize_detail_text(root: &Path, value: &str) -> String {
let without_project_root = relativize_project_root_paths(root, value);
let without_absolute = redact_absolute_path_tokens(&without_project_root);
let without_secret = redact_secret_tokens(&without_absolute);
sanitize_error_context(&without_secret)
}
/// 按字符数截断(不切坏 UTF-8),并在真正截断时补省略号。
fn bounded_chars(value: &str, max_chars: usize) -> String {
if value.chars().count() <= max_chars {
@@ -527,15 +397,6 @@ fn normalize_tool_calls(calls: Vec<DirectToolCall>) -> Vec<DirectToolCall> {
normalized
}
/// 回读:文件缺失返回空数组;单行损坏跳过;按时间正序,最多最近 200 条。
pub(crate) fn read_direct_tool_calls_at(root: &Path) -> Result<Vec<DirectToolCall>, String> {
let path = tool_calls_path(root);
if !prepare_game_creator_private_path_for_read(&path, false, "工具调用历史")? {
return Ok(Vec::new());
}
Ok(normalize_tool_calls(read_tool_call_lines(&path)))
}
/// 状态的「确定性」排序:终态(`completed` / `failed`)优先于 `running`。
fn status_certainty(status: &str) -> u8 {
match status {
@@ -684,10 +545,22 @@ mod tests {
use super::{
direct_tool_call_from_item, direct_tool_call_now_ms, direct_tool_call_status,
direct_tool_call_status_changed, persist_direct_tool_call_at, persist_direct_tool_calls_at,
read_direct_tool_calls_at, sanitize_detail_text, tool_calls_path, DirectToolCall,
DirectToolCallDetail, DIRECT_TOOL_CALL_LIMIT, DIRECT_TOOL_CALL_SCHEMA_VERSION,
read_tool_call_lines, sanitize_detail_text, tool_call_from_line, tool_calls_path,
DirectToolCall, DirectToolCallDetail, DIRECT_TOOL_CALL_LIMIT,
DIRECT_TOOL_CALL_SCHEMA_VERSION,
};
use serde_json::json;
use std::path::Path;
/// 写侧用例直接读文件:回读命令退役后不再经过 `normalize_tool_calls` 的合并与裁剪,
/// 断言因此落在「磁盘上到底写了什么」这一层。
fn persisted_tool_calls(root: &Path) -> Vec<DirectToolCall> {
std::fs::read_to_string(tool_calls_path(root))
.unwrap_or_default()
.lines()
.filter_map(tool_call_from_line)
.collect()
}
/// 一行合法的落盘信封(回读用例的夹具)。
fn tool_call_row(id: &str, started_at: u64, updated_at: u64) -> String {
@@ -812,7 +685,7 @@ mod tests {
.expect("completed tool call");
persist_direct_tool_call_at(root.path(), &completed).expect("persist completed");
let calls = read_direct_tool_calls_at(root.path()).expect("read tool calls");
let calls = persisted_tool_calls(root.path());
assert_eq!(calls.len(), 1, "同一 id 只能有一行");
assert_eq!(calls[0].status, "completed");
assert_eq!(calls[0].started_at, 1000, "startedAt 不被 completed 覆盖");
@@ -912,85 +785,6 @@ mod tests {
);
}
/// 判据:单行损坏只跳过该行,不整体失败;缺文件返回空数组。
#[test]
fn tool_call_read_skips_corrupted_lines() {
let root = init_tool_call_project("tool-call-corrupt");
let path = tool_calls_path(root.path());
std::fs::create_dir_all(path.parent().expect("parent")).expect("create dir");
let good = serde_json::to_string(&json!({
"type": "tool_call_item",
"payload": {
"schemaVersion": "agc-tool-call.v1",
"id": "item-good",
"turnId": "turn-1",
"kind": "command",
"title": "执行命令",
"summary": "npm run build",
"status": "completed",
"detail": {"command": "npm run build"},
"startedAt": 1,
"updatedAt": 2
}
}))
.expect("serialize good row");
std::fs::write(
&path,
format!("{good}\n{{ not json\n{{\"type\":\"other\",\"payload\":{{}}}}\n{good}\n"),
)
.expect("write fixture");
let missing = tempfile::tempdir().expect("missing dir");
assert!(
read_direct_tool_calls_at(missing.path())
.expect("missing file is empty")
.is_empty(),
"历史文件缺失必须返回空数组"
);
let calls = read_direct_tool_calls_at(root.path()).expect("read with corrupted lines");
assert_eq!(calls.len(), 1, "坏行被跳过,同 id 归并成一条");
assert_eq!(calls[0].id, "item-good");
}
/// 判据:回读按时间正序,且超出上限时保留最新。
#[test]
fn tool_call_read_is_ordered_and_capped() {
let root = init_tool_call_project("tool-call-cap");
let total = DIRECT_TOOL_CALL_LIMIT + 5;
let calls = (0..total)
.map(|index| {
direct_tool_call_from_item(
root.path(),
&json!({
"id": format!("item-{index:04}"),
"type": "commandExecution",
"command": format!("run {index}"),
"startedAtMs": 1000 + index as u64,
}),
"turn-1",
false,
1000 + index as u64,
)
.expect("tool call")
})
.collect::<Vec<_>>();
persist_direct_tool_calls_at(root.path(), &calls).expect("persist batch");
let read = read_direct_tool_calls_at(root.path()).expect("read capped");
assert_eq!(read.len(), DIRECT_TOOL_CALL_LIMIT, "超出上限保留最新 N 条");
assert_eq!(
read.first().expect("first").id,
format!("item-{:04}", total - DIRECT_TOOL_CALL_LIMIT),
"最早被裁掉的是最旧的条目"
);
assert!(
read.windows(2)
.all(|pair| pair[0].timestamp() <= pair[1].timestamp()),
"回读必须按时间正序"
);
}
/// 判据:fileChange 的标题按去重后的变更数量,摘要取首个变更路径。
#[test]
fn tool_call_file_change_title_counts_unique_paths() {
@@ -1189,7 +983,7 @@ mod tests {
persist_direct_tool_call_at(root.path(), &completed).expect("persist completed first");
persist_direct_tool_call_at(root.path(), &running).expect("persist stale running");
let calls = read_direct_tool_calls_at(root.path()).expect("read after stale single write");
let calls = persisted_tool_calls(root.path());
assert_eq!(calls.len(), 1, "同一 id 只能有一行");
assert_eq!(
calls[0].status, "completed",
@@ -1201,7 +995,7 @@ mod tests {
// 回合末整批落盘那条路径同样不得回退。
persist_direct_tool_calls_at(root.path(), std::slice::from_ref(&running))
.expect("persist stale running batch");
let calls = read_direct_tool_calls_at(root.path()).expect("read after stale batch write");
let calls = persisted_tool_calls(root.path());
assert_eq!(
calls[0].status, "completed",
"整批落盘路径同样不得把 completed 打回 running"
@@ -1209,28 +1003,6 @@ mod tests {
assert_eq!(calls[0].updated_at, 2000, "整批落盘不得回退 updatedAt");
}
/// 判据:读回时同 id 的重复行也按 `updatedAt` 单调合并(磁盘上留有旧快照不得回退状态)。
#[test]
fn tool_call_read_merges_duplicate_rows_monotonically() {
let root = init_tool_call_project("tool-call-read-monotonic");
let path = tool_calls_path(root.path());
std::fs::create_dir_all(path.parent().expect("parent")).expect("create dir");
let completed = tool_call_row("item-1", 1000, 2000);
let stale_running = tool_call_row("item-1", 1000, 1000)
.replace("\"status\":\"completed\"", "\"status\":\"running\"");
assert!(stale_running.contains("\"status\":\"running\""));
std::fs::write(&path, format!("{completed}\n{stale_running}\n")).expect("write fixture");
let calls = read_direct_tool_calls_at(root.path()).expect("read duplicate rows");
assert_eq!(calls.len(), 1, "同 id 归并成一条");
assert_eq!(
calls[0].status, "completed",
"磁盘上更旧的快照不得把状态打回 running"
);
assert_eq!(calls[0].updated_at, 2000, "归并保留更新的 updatedAt");
assert_eq!(calls[0].started_at, 1000);
}
/// 判据:项目内绝对路径落成项目相对路径,项目外绝对路径保持既有占位形状。
#[test]
fn tool_call_paths_become_project_relative() {
@@ -1283,9 +1055,10 @@ mod tests {
);
}
/// 判据:单行损坏(含非法 UTF-8 字节)只跳过损坏行,后续合法记录必须继续读回
/// 判据:写前读取时单行损坏(含非法 UTF-8 字节)只跳过损坏行,后续合法记录必须继续读回
/// 否则一次截断写入会把整份工具卡片从后续重写里抹掉。
#[test]
fn tool_call_read_skips_invalid_utf8_line() {
fn tool_call_pre_read_skips_invalid_utf8_line() {
let root = init_tool_call_project("tool-call-invalid-utf8");
let path = tool_calls_path(root.path());
std::fs::create_dir_all(path.parent().expect("parent")).expect("create dir");
@@ -1298,7 +1071,7 @@ mod tests {
bytes.extend_from_slice(tool_call_row("item-b", 2000, 2000).as_bytes());
bytes.push(b'\n');
std::fs::write(&path, &bytes).expect("write invalid utf8 fixture");
let calls = read_direct_tool_calls_at(root.path()).expect("read with invalid utf8");
let calls = read_tool_call_lines(&path);
assert_eq!(
calls.len(),
2,
@@ -1318,7 +1091,7 @@ mod tests {
bytes.extend_from_slice(tool_call_row("item-c", 3000, 3000).as_bytes());
bytes.push(b'\n');
std::fs::write(&path, &bytes).expect("write truncated utf8 fixture");
let calls = read_direct_tool_calls_at(root.path()).expect("read with truncated line");
let calls = read_tool_call_lines(&path);
assert_eq!(
calls.len(),
2,
@@ -1326,10 +1099,16 @@ mod tests {
);
assert_eq!(calls[0].id, "item-a");
assert_eq!(calls[1].id, "item-c");
let missing = tempfile::tempdir().expect("missing dir");
assert!(
read_tool_call_lines(&tool_calls_path(missing.path())).is_empty(),
"历史文件缺失时写前读取必须返回空表"
);
}
/// 判据:200 条上限「按时间保留最新 200 条」,超出时更早回合的卡片会被静默丢弃
/// (契约内行为,不是缺陷)。本用例只钉住现状与时间正序。
/// 判据:落到磁盘上的行同样受 200 条上限约束——「按时间保留最新 200 条」,更早回合的
/// 卡片会被静默丢弃(契约内行为,不是缺陷)。本用例只钉住现状与时间正序。
#[test]
fn tool_call_cap_drops_oldest_turn_cards() {
let root = init_tool_call_project("tool-call-cap-oldest");
@@ -1366,7 +1145,7 @@ mod tests {
.expect("newest tool call");
persist_direct_tool_call_at(root.path(), &newest).expect("persist newest");
let read = read_direct_tool_calls_at(root.path()).expect("read capped");
let read = persisted_tool_calls(root.path());
assert_eq!(read.len(), DIRECT_TOOL_CALL_LIMIT, "上限仍是 200 条");
assert_eq!(
read.last().expect("last").id,
@@ -1381,7 +1160,7 @@ mod tests {
assert!(
read.windows(2)
.all(|pair| pair[0].timestamp() <= pair[1].timestamp()),
"回读必须按时间正序"
"落盘顺序必须按时间正序"
);
}
@@ -233,7 +233,7 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab
}),
json!({
"name": "agc_generate_image",
"description": "按原网站图片画布能力生成一张新图片:普通插画、角色立绘、统一视觉规范图、游戏 UI 设计图或透明游戏素材图集都可使用。仅在用户明确要求生成新图时调用;游戏美术包是另一个专用工具,不是本工具的限制。客户端负责登录态授权、计费、幂等账本、下载校验、manifest/revision 登记和本地预览,不需要用户提供 API Key、Token、URL 或 .env",
"description": "按原网站图片画布能力生成一张新图片:普通插画、角色立绘、统一视觉规范图、游戏 UI 设计图或透明游戏素材图集都可使用。仅在用户明确要求生成新图时调用。",
"inputSchema": {
"type": "object",
"properties": {
@@ -247,7 +247,7 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab
"type": "string",
"enum": PLATFORM_ART_ASSET_GENERATION_KINDS,
"default": "image",
"description": "image=普通新图,character=角色图,spec/icon-spec=统一视觉规范图(spec 是服务端同义词,客户端统一登记为 icon-spec),ui-prototype=完整 UI 设计图,art-spritesheet=透明游戏素材图集(项目须已有 icon-spec 规范图),publication-material=发布宣传图"
"description": "image=普通新图(不做额外处理)character=角色图(纯色底生成后自动抠图,产出透明背景立绘,prompt 只描述角色主体)spec/icon-spec=统一视觉规范图(spec 是服务端同义词,客户端统一登记为 icon-spec),ui-prototype=完整 UI 设计图,art-spritesheet=透明游戏素材图集(纯色底生成后自动抠图并切片,项目须已有 icon-spec 规范图),publication-material=发布宣传图"
},
"aspectRatio": {
"type": "string",
@@ -286,6 +286,16 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab
"minimum": 1,
"maximum": 32,
"description": "grid 模式纵向网格数量,只能与 sliceMode=grid 同时提供"
},
"sliceCount": {
"type": "integer",
"minimum": 1,
"maximum": 256,
"description": "只与 kind=art-spritesheet 且 sliceMode=connected-components 同时提供,用于约束目标素材张数;省略时按图像内容自动识别"
},
"screenColor": {
"type": "string",
"description": "抠图纯色背景,仅 kind=character(角色形象)和 kind=art-spritesheet(图标素材)生效,其它 kind 携带会被拒绝。生成时把主体置于该纯色背景上,回图后据此抠除背景。取值只能是 auto 或下列色板 hex 之一,传值只填 hex 本身、不要附带色名:#CFEFFF(浅雾蓝)、#B0C2E0(浅钢蓝)、#FFD6C2(暖浅桃色)、#E6D8FF(淡薰衣草紫)、#F4D8E8(浅粉灰)、#7FB3FF(中度天蓝)、#FFF2A8(浅柠黄)、#CFFFE1(淡薄荷绿)、#D8DEE8(浅中性灰)、#D8D2E8(淡灰紫)、#A8F7F0(高对比浅青)、#A0BBA0(灰竹绿);auto 时由服务端自动选色。手动指定时不能与角色或素材本体的颜色接近"
}
},
"required": ["prompt"],
@@ -1131,6 +1141,8 @@ async fn call_agc_generate_image(arguments: &Value) -> Value {
"sliceMode",
"gridX",
"gridY",
"sliceCount",
"screenColor",
],
) {
return mcp_tool_result(error, Vec::new(), true);
@@ -1159,6 +1171,7 @@ async fn call_agc_generate_image(arguments: &Value) -> Value {
("assetName", DIRECT_TOOLS_MCP_MAX_RESOURCE_NAME_CHARS),
("outputPath", 512),
("sliceMode", 32),
("screenColor", 16),
] {
if arguments.get(field).is_some() {
if let Err(error) = bounded_tool_string(arguments, field, max_chars) {
@@ -2391,11 +2404,35 @@ mod tests {
assert_eq!(image_tool["inputSchema"]["required"], json!(["prompt"]));
assert!(image_tool["description"]
.as_str()
.is_some_and(|description| description.contains("不是本工具的限制")));
.is_some_and(|description| description.contains("仅在用户明确要求生成新图时调用")));
assert!(
image_tool["inputSchema"]["properties"]["kind"]["description"]
.as_str()
.is_some_and(|description| description.contains("自动抠图")
&& description.contains("prompt 只描述角色主体")
&& description.contains("不做额外处理")),
"kind description must carry the auto-matting semantics"
);
assert_eq!(
image_tool["inputSchema"]["properties"]["sliceMode"]["enum"],
json!(["connected-components", "grid"])
);
assert_eq!(
image_tool["inputSchema"]["properties"]["sliceCount"]["minimum"],
json!(1)
);
assert_eq!(
image_tool["inputSchema"]["properties"]["sliceCount"]["maximum"],
json!(256)
);
assert!(
image_tool["inputSchema"]["properties"]["screenColor"]["description"]
.as_str()
.is_some_and(|description| description.contains("抠图纯色背景")
&& description.contains("kind=character")
&& description.contains("不要附带色名")),
"screenColor description must carry the matting-background semantics"
);
assert!(
image_tool["inputSchema"]["properties"]["sliceMode"]
.get("default")
@@ -1,4 +1,4 @@
//! GameAgent 对话「回合流」的采集持久化与回读
//! GameAgent 对话「回合流」的采集持久化。
//!
//! 顺序真相放在一处:`<projectRoot>/.agent/conversations/turn-stream.jsonl` 按**出现顺序**
//! 记录一个回合里的文本段与工具调用。工具条目只记位置标记(`callId`),工具本身的正文
@@ -11,7 +11,7 @@
//! `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::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;
@@ -339,15 +339,6 @@ pub(crate) fn upsert_direct_turn_stream_item_at(
})
}
/// 回读:文件缺失返回空数组;单行损坏跳过;按 `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`(回合重放 / 重复收尾
@@ -442,6 +442,9 @@ pub(crate) struct PlatformArtAssetGenerationOptions {
/// 改材料会让升级时在途的账本换槽并重复 POST;分类只影响本地 manifest 落盘,不影响远端
/// 请求正文。同任务的幂等恢复由调用方继续用同一个栏目提交(与 `reference_asset_ids` 同口径)。
pub(crate) target_category: Option<String>,
/// 抠图纯色背景(auto/省略已归一为 None;Some 时是规范化后的大写 #RRGGBB)。
/// 仅 character 与 art-spritesheet 链路透传给服务端。
pub(crate) screen_color: Option<String>,
}
impl Default for PlatformArtAssetGenerationOptions {
@@ -459,6 +462,7 @@ impl Default for PlatformArtAssetGenerationOptions {
grid_y: None,
reference_asset_ids: Vec::new(),
target_category: None,
screen_color: None,
}
}
}
@@ -2529,7 +2533,8 @@ pub(crate) async fn generate_platform_art_asset_with_required_slices_at(
/// 动作,必须各自独立成槽,才能在同一项目里同时在途。
///
/// 升级前遗留账本仍由旧材料函数定位;新请求把显式切分模式纳入身份,避免同一图集
/// 请求在网格与连通域之间误复用。`slice_count` 继续保持历史兼容语义,不进身份。
/// 请求在网格与连通域之间误复用。`slice_count` 与 `screen_color` 同样改变付费产出,
/// 一并进入身份;None 时跳过序列化,未使用这些字段的请求身份与旧版逐字节一致。
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct StandalonePlatformArtGenerationFingerprintMaterial<'a> {
@@ -2544,6 +2549,10 @@ struct StandalonePlatformArtGenerationFingerprintMaterial<'a> {
slice_mode: Option<&'a str>,
grid_x: Option<u32>,
grid_y: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
slice_count: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
screen_color: Option<&'a str>,
}
/// 把输出路径收口成稳定的旧槽材料:空路径与未指定路径都落到 `(automatic-output)`
@@ -2583,6 +2592,8 @@ fn standalone_platform_art_generation_runtime_context(
slice_mode: options.slice_mode.as_deref(),
grid_x: options.grid_x,
grid_y: options.grid_y,
slice_count: options.slice_count,
screen_color: options.screen_color.as_deref(),
})
.map_err(|error| format!("序列化 standalone 图片生成动作身份失败:{error}"))?;
let action_fingerprint = format!("{:x}", Sha256::digest(&identity_bytes));
@@ -3201,7 +3212,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at
"sliceMode": options.slice_mode,
"gridX": options.grid_x,
"gridY": options.grid_y,
"screenColor": "auto",
"screenColor": options.screen_color.as_deref().unwrap_or("auto"),
"aspectRatio": options.aspect_ratio,
"imageSize": options.image_size,
"assetLabel": options.asset_label,
@@ -3249,6 +3260,14 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at
if options.asset_kind == "image" {
object.remove("kind");
}
if options.asset_kind == "character" {
if let Some(screen_color) = options.screen_color.as_deref() {
object.insert(
"screenColor".to_string(),
serde_json::Value::String(screen_color.to_string()),
);
}
}
}
request_body
} else {
@@ -8801,6 +8820,7 @@ mod canvas_generation_tests {
grid_y: None,
reference_asset_ids: Vec::new(),
target_category: None,
screen_color: None,
};
let ordinary =
standalone_platform_art_generation_runtime_context("完整生成提示词", &options, false)
@@ -8867,6 +8887,12 @@ mod canvas_generation_tests {
let mut changed = options.clone();
changed.replace_existing = false;
changed_options.push(changed);
let mut changed = options.clone();
changed.screen_color = Some("#CFEFFF".to_string());
changed_options.push(changed);
let mut changed = options.clone();
changed.slice_count = Some(8);
changed_options.push(changed);
for changed in changed_options {
let context = standalone_platform_art_generation_runtime_context(
"完整生成提示词",
@@ -10847,6 +10873,7 @@ mod canvas_generation_tests {
grid_y: None,
reference_asset_ids: Vec::new(),
target_category: None,
screen_color: None,
};
let prompt = "生成同一套整包美术";
let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options);
@@ -11753,6 +11780,7 @@ mod canvas_generation_tests {
grid_y: None,
reference_asset_ids: Vec::new(),
target_category: None,
screen_color: None,
};
let prompt = "保持同一个生成提示词";
let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options);
@@ -12219,6 +12247,7 @@ mod canvas_generation_tests {
grid_y: None,
reference_asset_ids: Vec::new(),
target_category: None,
screen_color: None,
};
let prompt = "恢复已受理视觉规范图";
let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options);
@@ -13148,6 +13177,7 @@ mod canvas_generation_tests {
grid_y: None,
reference_asset_ids: Vec::new(),
target_category: None,
screen_color: None,
}
}
@@ -579,6 +579,7 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio
grid_y,
reference_asset_ids: Vec::new(),
target_category: None,
screen_color: None,
};
if let Some(pending) = pending_action {
match recover_persisted_visual_generation_options(
@@ -633,6 +634,7 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio
reference_asset_ids: requested_options.reference_asset_ids,
// Agent 运行时不会指定完成登记的目标栏目,保持调用方给的值(默认 `None`)。
target_category: requested_options.target_category,
screen_color: requested_options.screen_color,
}
};
options.replace_existing = replace_existing;
@@ -1,7 +1,7 @@
use super::*;
use crate::agent::{
direct_codex_canonical_project_identity, read_direct_project_chat_history_at,
read_direct_project_last_item_id_at,
read_direct_project_chat_history_at, read_direct_project_last_item_id_at,
DirectProjectHistoryAnchor,
};
use crate::ui_editor::resource::font::FontAsset;
use sha2::{Digest, Sha256};
@@ -4946,6 +4946,7 @@ pub(crate) fn prepare_local_project_asset_generation(
grid_y: None,
reference_asset_ids,
target_category,
screen_color: None,
},
})
}
@@ -5799,31 +5800,6 @@ 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(
@@ -5838,13 +5814,7 @@ pub(crate) async fn subscribe_direct_project_thread(
tauri::async_runtime::spawn_blocking(move || {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "conversation.read")?;
let (canonical_root, _) = direct_codex_canonical_project_identity(root)?;
let thread_root = canonical_root
.to_str()
.and_then(|value| value.strip_prefix("\\\\?\\"))
.map(Path::new)
.unwrap_or(canonical_root.as_path());
let thread_id = thread_root.to_string_lossy().into_owned();
let thread_id = direct_thread_id_for_project(root);
let mut bootstrap = subscribe_direct_thread(&thread_id);
if bootstrap.last_completed_item_id.is_none() {
bootstrap.last_completed_item_id = read_direct_project_last_item_id_at(root)?;
@@ -5862,34 +5832,43 @@ pub(crate) fn consume_direct_project_thread(
consume_direct_thread(subscription_id.trim())
}
/// 读一屏项目对话历史。
///
/// 窗口两端各由一个锚点给出,两者互斥(都传会报错):`before_item_id` 是**旧端**边界(不含
/// 该条,向后翻页用),`through_item_id` 是**新端**边界(含该条,取 `subscribe` 回执里的
/// `lastCompletedItemId`,比它更新的条目只从运行态事件来);都不传就是文件尾最近的一屏。
#[tauri::command]
pub(crate) async fn read_direct_project_history_slice(
project_path: String,
before_item_id: Option<String>,
through_item_id: Option<String>,
limit: Option<usize>,
messages_only: Option<bool>,
) -> Result<DirectThreadHistorySlice, String> {
tauri::async_runtime::spawn_blocking(move || {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "conversation.read")?;
let read_slice = if messages_only.unwrap_or(false) {
read_direct_project_chat_items_slice_at
} else {
read_direct_project_history_items_slice_at
let anchor = match (before_item_id.as_deref(), through_item_id.as_deref()) {
(Some(_), Some(_)) => {
return Err(
"DirectProject 历史切片只接受一个锚点(beforeItemId / throughItemId"
.to_string(),
)
}
(Some(before), None) => DirectProjectHistoryAnchor::Before(before),
(None, Some(through)) => DirectProjectHistoryAnchor::Through(through),
(None, None) => DirectProjectHistoryAnchor::Newest,
};
let (items, has_more, item_timestamps) =
read_slice(root, before_item_id.as_deref(), limit.unwrap_or(20))?;
let oldest_item_id = items
.first()
.and_then(|item| item.get("id"))
.and_then(serde_json::Value::as_str)
.filter(|id| !id.is_empty())
.map(str::to_string);
let (raw_items, has_more, recorded_at_ms, first_item_id) =
read_direct_project_history_items_slice_at(root, anchor, limit.unwrap_or(20))?;
let items = direct_thread_items_from_history(root, &raw_items, |item| {
direct_thread_item_identity(item)
.and_then(|identity| recorded_at_ms.get(&identity).copied())
.unwrap_or_default()
});
Ok(DirectThreadHistorySlice {
items,
has_more,
item_timestamps,
oldest_item_id,
first_item_id,
})
})
.await
@@ -2648,8 +2648,6 @@ 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,
@@ -1576,6 +1576,7 @@ pub(crate) fn spawn_mock_llm_server_responses(response_contents: Vec<String>) ->
pub(crate) fn spawn_mock_llm_tool_plan_then_invalid_final_reply(
planning_response: String,
final_reply_requests: usize,
) -> String {
let listener = bind_test_tcp_listener("mock invalid final reply bind");
let base_url = format!("http://{}", listener.local_addr().expect("mock llm addr"));
@@ -1617,11 +1618,7 @@ pub(crate) fn spawn_mock_llm_tool_plan_then_invalid_final_reply(
.write_all(planning_response.as_bytes())
.expect("mock tool plan response");
// Autonomous runs enforce a 12-retry floor. Return the same malformed
// response for the initial final-reply request and every retry so this
// fixture tests deserialize exhaustion rather than an accidental
// connection-refused fallback after the first malformed response.
for _ in 0..=12 {
for _ in 0..final_reply_requests.max(1) {
let (mut final_stream, _) = listener.accept().expect("mock final reply accept");
drop(read_mock_http_request(&mut final_stream));
let invalid_body = "{invalid-json";
@@ -5934,7 +5931,7 @@ async fn background_agent_runtime_marks_response_plan_step_failed_when_final_rep
"response": ""
})
.to_string();
let base_url = spawn_mock_llm_tool_plan_then_invalid_final_reply(plan_json);
let base_url = spawn_mock_llm_tool_plan_then_invalid_final_reply(plan_json, 1);
let _config_guard = write_test_local_config(format!(
r#"{{
"agentLlm": {{
@@ -5943,6 +5940,7 @@ async fn background_agent_runtime_marks_response_plan_step_failed_when_final_rep
"baseUrl": {base_url:?},
"model": "design-runtime-model",
"apiKind": "openai_responses",
"maxRetries": 0,
"retryBackoffMs": 1
}}
}}
@@ -1175,6 +1175,7 @@ async fn canonical_art_spec_and_ui_requests_use_the_shared_reference_chain() {
grid_y: None,
reference_asset_ids: Vec::new(),
target_category: None,
screen_color: None,
},
)
.await;
@@ -6134,6 +6135,7 @@ fn ui_prototype_generation_uses_dedicated_prompt_and_art_spec() {
grid_y: None,
reference_asset_ids: Vec::new(),
target_category: None,
screen_color: None,
};
let prompt = build_platform_art_asset_prompt(
"原创网格贪吃蛇:分数与状态 HUD、四类不同分值食物、开始、方向键/WASD、触控方向键、失败与重开",
@@ -1147,7 +1147,7 @@ async fn background_final_reply_failure_keeps_private_conversation_and_hashes_pu
"response": ""
})
.to_string();
let base_url = spawn_mock_llm_tool_plan_then_invalid_final_reply(planning_response);
let base_url = spawn_mock_llm_tool_plan_then_invalid_final_reply(planning_response, 1);
replace_test_local_config(
&config_path,
format!(
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More