Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ed3369a494 | |||
| fdc48fe725 | |||
| 2748468d12 | |||
| 09ad0073fe | |||
| 42b702d362 | |||
| 656c89e4b2 | |||
| 0ec1179bf1 | |||
| 2938a49cac | |||
| ae0f9376c9 | |||
| 29d0cbb4df | |||
| 721e45f01b |
@@ -7,10 +7,6 @@ on:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: project-ci-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
|
||||
@@ -1,4 +1 @@
|
||||
# Git 在链接工作树里执行 Hook 时会注入 GIT_DIR 等仓库定位变量,优先级高于 cwd;
|
||||
# 子进程(npm、lint-staged、测试夹具)会继承它们并写到真实仓库,故在入口统一清除。
|
||||
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_COMMON_DIR GIT_PREFIX GIT_CONFIG_PARAMETERS GIT_CEILING_DIRECTORIES
|
||||
npm run format:staged
|
||||
|
||||
@@ -1,4 +1 @@
|
||||
# Git 在链接工作树里执行 Hook 时会注入 GIT_DIR 等仓库定位变量,优先级高于 cwd;
|
||||
# 钩子链(npm → check:repository-ci → 测试夹具)会继承它们并写到真实仓库,故在入口统一清除。
|
||||
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_COMMON_DIR GIT_PREFIX GIT_CONFIG_PARAMETERS GIT_CEILING_DIRECTORIES
|
||||
npm run check:pre-push-master -- "$@"
|
||||
|
||||
+16
@@ -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 项目开发对话只保留 **运行态事件**。
|
||||
- “哪些消息可显示”曾可能由后端历史分页判断;已解析为可见性判断属于 **聊天投影**,后端只按原始条目分页,前端负责跳过不可显示条目并推进分页锚点。
|
||||
|
||||
@@ -38,7 +38,6 @@ vi.mock('../api/adminApiClient', () => ({
|
||||
|
||||
interface MockIntersectionObserverController {
|
||||
enter: (target: Element) => void;
|
||||
enterAll: (targets: Element[]) => void;
|
||||
isObserved: (target: Element) => boolean;
|
||||
}
|
||||
|
||||
@@ -107,25 +106,6 @@ function installIntersectionObserverMock(): MockIntersectionObserverController {
|
||||
);
|
||||
});
|
||||
},
|
||||
enterAll(targets) {
|
||||
act(() => {
|
||||
for (const target of targets) {
|
||||
const record = observed.get(target);
|
||||
if (!record) {
|
||||
throw new Error('目标缩略图尚未进入 IntersectionObserver');
|
||||
}
|
||||
record.callback(
|
||||
[
|
||||
{
|
||||
isIntersecting: true,
|
||||
target,
|
||||
} as IntersectionObserverEntry,
|
||||
],
|
||||
record.observer,
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
isObserved(target) {
|
||||
return observed.has(target);
|
||||
},
|
||||
@@ -773,10 +753,10 @@ test('后台素材查询为大量同时可见的缩略图持续错峰换签', as
|
||||
const thumbnails = entries.map((entry) =>
|
||||
thumbnailElementForLabel(entry.label),
|
||||
);
|
||||
for (const thumbnail of thumbnails) {
|
||||
thumbnails.forEach((thumbnail) => {
|
||||
expect(observer.isObserved(thumbnail)).toBe(true);
|
||||
}
|
||||
observer.enterAll(thumbnails);
|
||||
observer.enter(thumbnail);
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
@@ -796,7 +776,7 @@ test('后台素材查询为大量同时可见的缩略图持续错峰换签', as
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
});
|
||||
expect(getAdminAssetReadUrl).toHaveBeenCalledTimes(105);
|
||||
}, 10_000);
|
||||
});
|
||||
|
||||
test('后台素材查询读取更多后为新进入可视区域的素材换签', async () => {
|
||||
const observer = installIntersectionObserverMock();
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
"schemaVersion": "game-creator-config.v2",
|
||||
"agentMode": "codex_app_server",
|
||||
"llm": {
|
||||
"customEnabled": false,
|
||||
"visibleModels": [],
|
||||
"apiKey": "",
|
||||
"baseUrl": "https://dev.genarrative.world/gpt/v1",
|
||||
"model": "gpt-6-astra",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@genarrative/ai-game-creator-shell",
|
||||
"private": true,
|
||||
"version": "0.1.47",
|
||||
"version": "0.1.29",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "node scripts/start-tauri-dev.mjs",
|
||||
|
||||
+1
-1
@@ -1725,7 +1725,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "genarrative-ai-game-creator-shell"
|
||||
version = "0.1.47"
|
||||
version = "0.1.29"
|
||||
dependencies = [
|
||||
"agent-runtime-core",
|
||||
"axum",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "genarrative-ai-game-creator-shell"
|
||||
version = "0.1.47"
|
||||
version = "0.1.29"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{"type":"function","function":{"name":"get_workflow_status","description":"读取当前策划工作流状态,只返回阶段列表、当前阶段、已批准阶段和待审批阶段;不推进阶段、不提交审批、不修改文件。","parameters":{"type":"object","properties":{},"additionalProperties":false}}},
|
||||
{"type":"function","function":{"name":"list_resources","description":"列出固定资源的逻辑目录、资源 ID、标题和简介。资源是只读的随包文档;不要猜测物理路径。","parameters":{"type":"object","properties":{},"additionalProperties":false}}},
|
||||
{"type":"function","function":{"name":"read_resource","description":"读取一份固定资源文档全文。每次读取一个 resource_id;资源只读。读到未实现占位文档时由你自行判断和处理。","parameters":{"type":"object","properties":{"resource_id":{"type":"string"}},"required":["resource_id"],"additionalProperties":false}}},
|
||||
{"type":"function","function":{"name":"patch_file","description":"局部修改 UTF-8 文件。使用 old_text/new_text,或使用 edits 一次进行多个独立替换;每个 old_text 必须非空且在原文件中唯一。所有 edit 会一次性校验;任何失败都不修改文件,错误会列出各失败项及可唯一匹配的其余项。path 使用相对路径。","parameters":{"type":"object","properties":{"path":{"type":"string"},"old_text":{"type":"string"},"new_text":{"type":"string"},"edits":{"type":"array","items":{"type":"object","properties":{"old_text":{"type":"string"},"new_text":{"type":"string"}},"required":["old_text","new_text"],"additionalProperties":false}}},"required":["path"],"additionalProperties":false}}},
|
||||
{"type":"function","function":{"name":"patch_file","description":"局部修改 UTF-8 文件。使用 old_text/new_text,或使用 edits 一次进行多个独立替换;每个 old_text 必须非空且在原文件中唯一,匹配失败、重复或范围重叠时不修改文件。path 使用相对路径。","parameters":{"type":"object","properties":{"path":{"type":"string"},"old_text":{"type":"string"},"new_text":{"type":"string"},"edits":{"type":"array","items":{"type":"object","properties":{"old_text":{"type":"string"},"new_text":{"type":"string"}},"required":["old_text","new_text"],"additionalProperties":false}}},"required":["path"],"additionalProperties":false}}},
|
||||
{"type":"function","function":{"name":"delete_path","description":"谨慎使用;永久删除工作区内的文件或目录;目录会连同全部内容递归删除,不备份。先确认目标及删除范围。path 使用相对路径,不能删除工作区根目录,也不能经过链接。","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"],"additionalProperties":false}}},
|
||||
{"type":"function","function":{"name":"list_dir","description":"列出工作目录内的文件和目录。path 使用相对路径。","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"],"additionalProperties":false}}},
|
||||
{"type":"function","function":{"name":"read_file","description":"读取工作目录内的 UTF-8 文本文件。path 使用相对路径。","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"],"additionalProperties":false}}},
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ Let the client derive projections from real disk changes and trusted tool result
|
||||
3. Keep read scopes separate: `asset.list` is the current project manifest, `asset.library.list` is the signed-in account library, and the web project's canvas resource read model is the authoritative canvas list. The account library is not the complete canvas list.
|
||||
4. Use `canvas.asset_import` for safe account/canvas asset IDs or project-relative local paths. The client rechecks ownership and validates bytes; host absolute paths require native UI file-picker authorization.
|
||||
5. When the user explicitly asks to create or derive video, character animation, sound effect, or background music, call `agc_create_or_derive_resource`. Use `create` only for video/audio without a source and `derive` with a registered `sourceLocalAssetId`; character animation is always derived from an image.
|
||||
6. When the user explicitly asks to remove an image background, call `agc_remove_background` with a registered image `sourceLocalAssetId` and `assetName`. Optional `backgroundMode` is `complex` (semantic foreground segmentation; default) or `flat` (solid-colour background removal). Prefer `flat` when the background is known to be solid. Only `flat` accepts optional `screenColor`: `auto`, `#RRGGBB`, or omitted for automatic detection by the service. Do not select a colour on behalf of `auto`. The client requires the signed-in account, owns canvas/folder context and task identity, and returns only bounded queue state.
|
||||
6. When the user explicitly asks to remove an image background, call `agc_remove_background` with a registered image `sourceLocalAssetId` and an output name. The client requires the signed-in account, owns canvas/folder context and task identity, and returns only bounded queue state.
|
||||
7. Preserve existing relative paths when a small edit is sufficient so client resource identities remain stable.
|
||||
8. Do not edit `.agent/manifest.json`, revision counters, version records, resource IDs, canvas identities, source provenance, generation ledgers, or browser receipts by hand.
|
||||
9. Do not create a version when no game file changed. The client compares content fingerprints and advances revision only after an actual source change.
|
||||
|
||||
+1
-1
@@ -14,4 +14,4 @@ Read scopes remain separate: `asset.list` is the current project's local manifes
|
||||
|
||||
`agc_create_or_derive_resource` accepts only semantic intent. The client resolves `sourceLocalAssetId`, creates stable request identities, recovers matching pending operations, serializes paid submissions, writes supported media into the current canvas and same-name asset folder, validates downloaded bytes, commits the local manifest transaction, and returns redacted warnings. A tool error or timeout is not permission to generate again with a new identity.
|
||||
|
||||
`agc_remove_background` accepts a registered image `sourceLocalAssetId`, `assetName`, and optional `backgroundMode` and `screenColor`. `complex` uses semantic segmentation to identify the foreground; `flat` removes a solid-colour background. Prefer `flat` when the background is known to be solid; omitting the mode selects `complex`. Only `flat` accepts a colour: `auto`, `#RRGGBB`, or omitted for automatic service detection. Never infer a concrete colour for `auto`. Empty or invalid values and colour without `flat` are rejected. The client resolves the formal source resource, canvas/folder context, stable operation identity, idempotency key, and authenticated External v1 `/api/external/v1/editor/images/background-removals` call. Mode and colour are part of request identity. Its result is bounded queue state; Codex must not poll internal workers, construct source URLs, or retry with a new identity after an uncertain response.
|
||||
`agc_remove_background` is the semantic image post-processing path. It accepts only a registered image `sourceLocalAssetId` and output name; the client resolves the formal source resource, canvas/folder context, stable operation identity, idempotency key, and authenticated External v1 `/api/external/v1/editor/images/background-removals` call. Its result is bounded queue state; Codex must not poll internal workers, construct source URLs, or retry with a new identity after an uncertain response.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schemaVersion": "agc-skill-pack.v1",
|
||||
"version": "2026-08-26.17",
|
||||
"version": "2026-08-26.16",
|
||||
"skills": [
|
||||
{
|
||||
"name": "agc-game-production-workflow",
|
||||
@@ -123,7 +123,7 @@
|
||||
"agents/openai.yaml",
|
||||
"references/projection-contract.md"
|
||||
],
|
||||
"sha256": "a929c27bc5b2b0bee0b7935e5c7b04ddbab1eb1804fe196f8c2537ad040ca5b1"
|
||||
"sha256": "96b5bf9e2ed150bbe934a888867c1bb500b214a131f8b36c4830f51ca30267b6"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -55,6 +56,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::*;
|
||||
|
||||
@@ -131,6 +131,7 @@ impl CodexAppServerCredential {
|
||||
) -> Option<(&'a str, &'a str)> {
|
||||
match self {
|
||||
Self::PlatformSession { .. } => None,
|
||||
#[cfg(test)]
|
||||
Self::AppDataKey { .. } => (!llm.api_key.trim().is_empty())
|
||||
.then_some((llm.base_url.trim_end_matches('/'), llm.api_key.trim())),
|
||||
#[cfg(test)]
|
||||
@@ -138,7 +139,7 @@ impl CodexAppServerCredential {
|
||||
.as_deref()
|
||||
.map(|api_key| (GAME_CREATOR_CODEX_AUTH_BRIDGE_API_BASE_URL, api_key)),
|
||||
#[cfg(not(test))]
|
||||
Self::AuthBridge { .. } => None,
|
||||
Self::AppDataKey { .. } | Self::AuthBridge { .. } => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -195,20 +196,6 @@ impl CodexAppServerStderrSummary {
|
||||
}
|
||||
}
|
||||
|
||||
/// 派发 app-server 的收尾与中断任务。
|
||||
///
|
||||
/// 这个入口会被**没有 tokio runtime 上下文的线程**调用:`cancel_direct_codex_turn`
|
||||
/// 是同步 Tauri 命令,直接跑在 IPC 回调线程(Windows 上是 WebView2 的 UI 线程);
|
||||
/// [`CodexThreadLease`] 与 [`CodexTurnGuard`] 的 `Drop` 也在调用方线程上执行。
|
||||
/// `tokio::spawn` 在那样的线程上会经 `Handle::current()` panic("there is no reactor
|
||||
/// running"),而 panic 跨不过 Tauri 的 IPC 回调边界,整个进程会以 `0xC0000409`
|
||||
/// (FAST_FAIL_FATAL_APP_EXIT)abort——现场就是"点终止,App 闪退"(2026-09-16 的 WER
|
||||
/// 记录:`genarrative-ai-game-creator-shell.exe`,异常代码 `0xc0000409`,fail-fast
|
||||
/// 参数 `7`)。一律走 Tauri 的全局异步 runtime:`main` 已把深栈 runtime 装进去。
|
||||
fn spawn_codex_app_server_task(task: impl std::future::Future<Output = ()> + Send + 'static) {
|
||||
tauri::async_runtime::spawn(task);
|
||||
}
|
||||
|
||||
struct CodexTurnStartCancellation {
|
||||
inner: Weak<CodexAppServerInner>,
|
||||
thread_id: String,
|
||||
@@ -270,7 +257,7 @@ impl CodexTurnStartCancellation {
|
||||
};
|
||||
let connection = CodexAppServerConnection { inner };
|
||||
let thread_id = self.thread_id.clone();
|
||||
spawn_codex_app_server_task(async move {
|
||||
tokio::spawn(async move {
|
||||
let _ = connection
|
||||
.request(
|
||||
"turn/interrupt",
|
||||
@@ -577,6 +564,17 @@ enum CodexTurnEvent {
|
||||
item_id: String,
|
||||
delta: String,
|
||||
},
|
||||
/// 思考正文增量:app-server `item/reasoning/summaryTextDelta` 的明文思考文本。
|
||||
///
|
||||
/// `item/reasoning/summaryTextDelta`(core `ReasoningContentDelta`)与
|
||||
/// `item/reasoning/textDelta`(core `ReasoningRawContentDelta`)都进这条通道:前者是
|
||||
/// reasoning item 的 `summary`,后者是它的 `content`,两段文本都随 `item/completed`
|
||||
/// 落进 `project.jsonl`、此前也已经在完成时展示给用户。plan 文本与命令输出仍然只降级为
|
||||
/// 活动状态,不下发正文。
|
||||
ReasoningDelta {
|
||||
item_id: String,
|
||||
delta: String,
|
||||
},
|
||||
IntermediateText(String),
|
||||
Activity(&'static str),
|
||||
Item {
|
||||
@@ -584,7 +582,7 @@ enum CodexTurnEvent {
|
||||
params: serde_json::Value,
|
||||
},
|
||||
Request {
|
||||
event_type: &'static str,
|
||||
kind: DirectThreadRequestKind,
|
||||
params: serde_json::Value,
|
||||
},
|
||||
RawItem(serde_json::Value),
|
||||
@@ -754,23 +752,14 @@ fn direct_codex_safe_activity_for_item_value(item: &serde_json::Value) -> &'stat
|
||||
direct_codex_safe_activity_for_item(item_type)
|
||||
}
|
||||
|
||||
/// Project an app-server item into the small public payload carried by the
|
||||
/// DirectProject event queue. Full item contents are persisted in JSONL and
|
||||
/// must not be forwarded through the runtime event stream.
|
||||
fn direct_thread_item_started_payload(item: &serde_json::Value) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"itemType": item
|
||||
.get("type")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or("unknown"),
|
||||
})
|
||||
}
|
||||
|
||||
fn direct_thread_item_id(item: &serde_json::Value) -> Option<String> {
|
||||
item.get("id")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
/// 运行态事件载荷:与历史切片同形的脱敏原始条目;拿不到身份或类型就整条跳过。
|
||||
///
|
||||
/// 这里不生成工具卡片形状:标题、折叠摘要和可见性都是前端投影的职责。
|
||||
fn direct_thread_event_item(
|
||||
root: &std::path::Path,
|
||||
item: &serde_json::Value,
|
||||
) -> Option<DirectThreadItem> {
|
||||
direct_thread_item_from_value(root, item, direct_tool_call_now_ms())
|
||||
}
|
||||
|
||||
fn direct_codex_command_is_game_verification(command: &str) -> bool {
|
||||
@@ -987,19 +976,21 @@ fn direct_codex_safe_activity_for_notification(method: &str) -> Option<&'static
|
||||
}
|
||||
}
|
||||
|
||||
fn direct_codex_request_event_type(method: &str) -> Option<&'static str> {
|
||||
fn direct_codex_request_event_type(method: &str) -> Option<DirectThreadRequestKind> {
|
||||
match method {
|
||||
"item/fileChange/requestApproval"
|
||||
| "item/commandExecution/requestApproval"
|
||||
| "item/permissions/requestApproval" => Some("approval.requested"),
|
||||
"item/tool/requestUserInput" | "item/mcpToolCall/requestUserInput" => Some("ask.requested"),
|
||||
| "item/permissions/requestApproval" => Some(DirectThreadRequestKind::ApprovalRequested),
|
||||
"item/tool/requestUserInput" | "item/mcpToolCall/requestUserInput" => {
|
||||
Some(DirectThreadRequestKind::AskRequested)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn direct_codex_resolution_event_type(method: &str) -> Option<&'static str> {
|
||||
fn direct_codex_resolution_event_type(method: &str) -> Option<DirectThreadRequestKind> {
|
||||
match method {
|
||||
"serverRequest/resolved" => Some("request.resolved"),
|
||||
"serverRequest/resolved" => Some(DirectThreadRequestKind::RequestResolved),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -1072,6 +1063,33 @@ fn direct_codex_notification_event(
|
||||
intermediate_text: Option<String>,
|
||||
safe_activity: Option<&'static str>,
|
||||
) -> Option<CodexTurnEvent> {
|
||||
// 思考正文走独立通道,交给 DirectProject 的运行态事件;它不因为
|
||||
// "preparing 活动" 的降级规则被丢掉,否则界面只能等 item/completed 才看到思考。
|
||||
//
|
||||
// 两条通知都下发正文,不下发活动文本:
|
||||
// - `item/reasoning/summaryTextDelta`(core `ReasoningContentDelta`)→ reasoning item 的 `summary`;
|
||||
// - `item/reasoning/textDelta`(core `ReasoningRawContentDelta`)→ reasoning item 的 `content`,
|
||||
// 正是 `project.jsonl` 里保存、并在此前 `item/completed` 已经展示给用户的同一段文本。
|
||||
// 因此这里只是把"完成时才看到"提前为"边生成边看到",没有放宽可见文本的范围;
|
||||
// 未识别的 plan 文本与命令输出仍然只降级为活动状态,不下发正文。
|
||||
if matches!(
|
||||
method,
|
||||
"item/reasoning/summaryTextDelta" | "item/reasoning/textDelta"
|
||||
) {
|
||||
return params
|
||||
.get("delta")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|delta| CodexTurnEvent::ReasoningDelta {
|
||||
item_id: params
|
||||
.get("itemId")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| "direct-missing-item".to_string()),
|
||||
delta: delta.to_string(),
|
||||
});
|
||||
}
|
||||
let (activity, intermediate_text) = match (&intermediate_text, safe_activity) {
|
||||
(Some(_), Some(activity)) if activity == "preparing" => (Some(activity), None),
|
||||
_ => (safe_activity, intermediate_text),
|
||||
@@ -2031,13 +2049,7 @@ impl CodexAppServerConnection {
|
||||
let codex_cli_version = game_creator_codex_cli_version_identity()
|
||||
.map_err(platform_llm::LlmError::InvalidConfig)?;
|
||||
let mut effective_llm = llm.clone();
|
||||
let credential = if llm.custom_enabled {
|
||||
crate::config::validate_custom_llm_connection(llm)
|
||||
.map_err(platform_llm::LlmError::InvalidConfig)?;
|
||||
CodexAppServerCredential::AppDataKey {
|
||||
fingerprint: format!("custom-key:{:x}", Sha256::digest(llm.api_key.as_bytes())),
|
||||
}
|
||||
} else if game_creator_official_llm_route_locked() {
|
||||
let credential = if game_creator_official_llm_route_locked() {
|
||||
let session = current_platform_session().ok_or_else(|| {
|
||||
platform_llm::LlmError::InvalidConfig(
|
||||
"authentication-required: 请先登录陶泥儿账号".to_string(),
|
||||
@@ -2205,8 +2217,7 @@ impl CodexAppServerConnection {
|
||||
true,
|
||||
),
|
||||
_ => (
|
||||
(llm.custom_enabled
|
||||
|| workspace_mode == CodexAppServerWorkspaceMode::DirectProject)
|
||||
(workspace_mode == CodexAppServerWorkspaceMode::DirectProject)
|
||||
.then(|| credential.direct_provider_route(llm))
|
||||
.flatten()
|
||||
.map(|(base_url, api_key)| (base_url.to_string(), api_key.to_string())),
|
||||
@@ -2946,18 +2957,7 @@ impl CodexAppServerConnection {
|
||||
turn_start_guard.armed = false;
|
||||
let direct_thread_id = history_root.to_string_lossy().into_owned();
|
||||
if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
|
||||
append_direct_thread_event(
|
||||
&direct_thread_id,
|
||||
DirectThreadRawEventDraft {
|
||||
event_type: "turn.started".to_string(),
|
||||
turn_id: turn_id.clone(),
|
||||
item_id: None,
|
||||
payload: serde_json::json!({
|
||||
"threadId": thread_id,
|
||||
"turnId": turn_id,
|
||||
}),
|
||||
},
|
||||
);
|
||||
append_direct_thread_event(&direct_thread_id, DirectThreadEvent::turn_started());
|
||||
}
|
||||
let mut receiver = self.register_turn(&turn_id).await;
|
||||
let mut direct_project_history = DirectProjectHistoryAccumulator::default();
|
||||
@@ -3012,12 +3012,13 @@ impl CodexAppServerConnection {
|
||||
direct_project_history.observe_delta(&item_id, &delta);
|
||||
append_direct_thread_event(
|
||||
&direct_thread_id,
|
||||
DirectThreadRawEventDraft {
|
||||
event_type: "item.delta".to_string(),
|
||||
turn_id: turn_id.clone(),
|
||||
item_id: Some(item_id.clone()),
|
||||
payload: serde_json::json!({ "delta": delta.clone() }),
|
||||
},
|
||||
// 事件自足:增量自带 item 身份与正文类别(正文 / 思考),
|
||||
// 前端 reducer 不允许靠猜 itemId 的来源决定 kind。
|
||||
DirectThreadEvent::item_delta(
|
||||
item_id.clone(),
|
||||
DirectThreadDeltaKind::Message,
|
||||
delta.clone(),
|
||||
),
|
||||
);
|
||||
}
|
||||
streamed_text.push_str(&delta);
|
||||
@@ -3048,6 +3049,18 @@ impl CodexAppServerConnection {
|
||||
});
|
||||
}
|
||||
}
|
||||
Some(CodexTurnEvent::ReasoningDelta { item_id, delta }) => {
|
||||
if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
|
||||
append_direct_thread_event(
|
||||
&direct_thread_id,
|
||||
DirectThreadEvent::item_delta(
|
||||
item_id,
|
||||
DirectThreadDeltaKind::Reasoning,
|
||||
delta,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Some(CodexTurnEvent::IntermediateText(text)) => {
|
||||
if let Some(observer) = direct_observer.as_deref_mut() {
|
||||
observer(DirectCodexTurnObservation::IntermediateText(text));
|
||||
@@ -3060,6 +3073,7 @@ impl CodexAppServerConnection {
|
||||
"rawResponseItem/completed 缺少 item".to_string(),
|
||||
));
|
||||
}
|
||||
let entry_item = direct_thread_event_item(history_root, &item);
|
||||
let history_root = history_root.to_path_buf();
|
||||
let history_item = item.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
@@ -3073,19 +3087,15 @@ impl CodexAppServerConnection {
|
||||
})?
|
||||
.map_err(platform_llm::LlmError::InvalidRequest)?;
|
||||
direct_project_history.complete_item(&item);
|
||||
let item_id = direct_thread_item_id(&item);
|
||||
append_direct_thread_event(
|
||||
&direct_thread_id,
|
||||
DirectThreadRawEventDraft {
|
||||
event_type: "item.completed".to_string(),
|
||||
turn_id: turn_id.clone(),
|
||||
item_id,
|
||||
payload: serde_json::json!({}),
|
||||
},
|
||||
);
|
||||
if let Some(entry_item) = entry_item {
|
||||
append_direct_thread_event(
|
||||
&direct_thread_id,
|
||||
DirectThreadEvent::item_completed(entry_item),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(CodexTurnEvent::Request { event_type, params }) => {
|
||||
Some(CodexTurnEvent::Request { kind, params }) => {
|
||||
if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
|
||||
let request_id = params
|
||||
.get("requestId")
|
||||
@@ -3095,14 +3105,7 @@ impl CodexAppServerConnection {
|
||||
.map(str::to_string);
|
||||
append_direct_thread_event(
|
||||
&direct_thread_id,
|
||||
DirectThreadRawEventDraft {
|
||||
event_type: event_type.to_string(),
|
||||
turn_id: turn_id.clone(),
|
||||
item_id: None,
|
||||
payload: request_id
|
||||
.map(|id| serde_json::json!({ "requestId": id }))
|
||||
.unwrap_or_else(|| serde_json::json!({})),
|
||||
},
|
||||
DirectThreadEvent::request(kind, request_id),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3213,16 +3216,14 @@ impl CodexAppServerConnection {
|
||||
&& self.inner.workspace_mode
|
||||
== CodexAppServerWorkspaceMode::DirectProject
|
||||
{
|
||||
let item_id = direct_thread_item_id(item);
|
||||
append_direct_thread_event(
|
||||
&direct_thread_id,
|
||||
DirectThreadRawEventDraft {
|
||||
event_type: "item.started".to_string(),
|
||||
turn_id: turn_id.clone(),
|
||||
item_id,
|
||||
payload: direct_thread_item_started_payload(item),
|
||||
},
|
||||
);
|
||||
if let Some(entry_item) =
|
||||
direct_thread_event_item(history_root, item)
|
||||
{
|
||||
append_direct_thread_event(
|
||||
&direct_thread_id,
|
||||
DirectThreadEvent::item_started(entry_item),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3264,12 +3265,7 @@ impl CodexAppServerConnection {
|
||||
{
|
||||
append_direct_thread_event(
|
||||
&direct_thread_id,
|
||||
DirectThreadRawEventDraft {
|
||||
event_type: "turn.completed".to_string(),
|
||||
turn_id: turn_id.clone(),
|
||||
item_id: None,
|
||||
payload: serde_json::json!({ "status": status }),
|
||||
},
|
||||
DirectThreadEvent::turn_completed(status.to_string()),
|
||||
);
|
||||
}
|
||||
match status {
|
||||
@@ -3580,7 +3576,7 @@ impl Drop for CodexThreadLease {
|
||||
let connection = self.connection.clone();
|
||||
let key = self.key.clone();
|
||||
let thread_id = self.thread_id.clone();
|
||||
spawn_codex_app_server_task(async move {
|
||||
tokio::spawn(async move {
|
||||
let mut threads = connection.inner.threads.lock().await;
|
||||
if let Some(entry) = threads.get_mut(&key) {
|
||||
if entry.thread_id == thread_id {
|
||||
@@ -3607,7 +3603,7 @@ impl Drop for CodexTurnGuard {
|
||||
let connection = self.connection.clone();
|
||||
let thread_id = self.thread_id.clone();
|
||||
let turn_id = self.turn_id.clone();
|
||||
spawn_codex_app_server_task(async move {
|
||||
tokio::spawn(async move {
|
||||
connection.inner.turns.lock().await.remove(&turn_id);
|
||||
connection.inner.turn_backlog.lock().await.remove(&turn_id);
|
||||
let _ = connection
|
||||
@@ -3964,8 +3960,8 @@ async fn read_game_creator_codex_app_server_stdout(
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let event = if let Some(event_type) = direct_codex_resolution_event_type(method) {
|
||||
CodexTurnEvent::Request { event_type, params }
|
||||
let event = if let Some(kind) = direct_codex_resolution_event_type(method) {
|
||||
CodexTurnEvent::Request { kind, params }
|
||||
} else if let Some(activity) = safe_activity {
|
||||
// Preparing notifications may carry private plan/reasoning text;
|
||||
// expose only the safe activity category. Other categories may
|
||||
@@ -4017,8 +4013,8 @@ async fn read_game_creator_codex_app_server_stdout(
|
||||
),
|
||||
method if direct_codex_request_event_type(method).is_some() => {
|
||||
CodexTurnEvent::Request {
|
||||
event_type: direct_codex_request_event_type(method)
|
||||
.expect("request event type checked above"),
|
||||
kind: direct_codex_request_event_type(method)
|
||||
.expect("request kind checked above"),
|
||||
params,
|
||||
}
|
||||
}
|
||||
@@ -4503,19 +4499,6 @@ mod tests {
|
||||
assert!(table.select(&key, None).is_err());
|
||||
}
|
||||
|
||||
/// 终止路径会从同步命令线程和 `Drop` 里派发 app-server 任务:那些线程没有 tokio
|
||||
/// runtime 上下文。`tokio::spawn` 在那里 panic,panic 跨不过 IPC 回调边界就把整个
|
||||
/// 进程 abort(0xC0000409,"点终止就闪退")。这条用例把派发入口钉在没有 runtime
|
||||
/// 上下文的线程上,回退到 `tokio::spawn` 时它会失败。
|
||||
#[test]
|
||||
fn codex_app_server_task_dispatch_needs_no_tokio_runtime_context() {
|
||||
let joined = std::thread::spawn(|| spawn_codex_app_server_task(async {}));
|
||||
assert!(
|
||||
joined.join().is_ok(),
|
||||
"没有 tokio runtime 上下文的线程也必须能派发 app-server 收尾任务"
|
||||
);
|
||||
}
|
||||
|
||||
/// 注册键:前端传的项目路径与回合注册时的路径必须归一化成同一个键(Windows 上
|
||||
/// `canonicalize` 会带 `\\?\` 前缀,去掉后两边才相等)。
|
||||
#[test]
|
||||
@@ -4542,11 +4525,29 @@ mod tests {
|
||||
"arguments": { "path": "game/index.html", "token": "secret" },
|
||||
"result": { "content": "large output" }
|
||||
});
|
||||
assert_eq!(direct_thread_item_id(&item).as_deref(), Some("item-1"));
|
||||
// 运行态事件必须自足:载荷是脱敏原始条目,前端不需要再按 itemId 取快照。
|
||||
let projected = direct_thread_event_item(std::path::Path::new("."), &item).expect("item");
|
||||
assert_eq!(projected.item_id(), "item-1");
|
||||
let payload = serde_json::to_value(&projected).expect("payload");
|
||||
assert_eq!(
|
||||
direct_thread_item_started_payload(&item),
|
||||
serde_json::json!({ "itemType": "mcpToolCall" })
|
||||
payload.get("itemType").and_then(serde_json::Value::as_str),
|
||||
Some("mcpToolCall")
|
||||
);
|
||||
assert_eq!(
|
||||
payload.get("itemId").and_then(serde_json::Value::as_str),
|
||||
Some("item-1")
|
||||
);
|
||||
// 卡片标题 / 折叠摘要 / kind 属于前端投影:载荷里不得出现这些 UI 语义。
|
||||
assert!(payload.get("toolCall").is_none(), "{payload}");
|
||||
assert!(payload.get("title").is_none(), "{payload}");
|
||||
assert!(payload.get("summary").is_none(), "{payload}");
|
||||
assert!(payload.get("kind").is_none(), "{payload}");
|
||||
// 参数里的密钥不得随载荷下发(脱敏占位符可以保留,明文不行)。
|
||||
let arguments = payload
|
||||
.get("arguments")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or_default();
|
||||
assert!(!arguments.contains("\"secret\""), "{payload}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4776,8 +4777,6 @@ mod tests {
|
||||
|
||||
fn test_llm() -> GameCreatorLlmConfig {
|
||||
GameCreatorLlmConfig {
|
||||
custom_enabled: false,
|
||||
visible_models: Vec::new(),
|
||||
api_key: "fixture-secret".to_string(),
|
||||
base_url: "https://example.invalid/v1".to_string(),
|
||||
model: "fixture-model".to_string(),
|
||||
@@ -5563,59 +5562,6 @@ mod tests {
|
||||
assert_ne!(command_token, provider_key);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn custom_llm_broker_uses_configured_route_without_exposing_upstream_key() {
|
||||
let mut llm = test_llm();
|
||||
llm.custom_enabled = true;
|
||||
llm.api_key = "custom-upstream-fixture-secret".into();
|
||||
llm.base_url = "http://127.0.0.1:9/v1".into();
|
||||
llm.model = "vendor/model.v1:latest".into();
|
||||
llm.visible_models = vec![llm.model.clone()];
|
||||
let credential = CodexAppServerCredential::AppDataKey {
|
||||
fingerprint: "custom-fixture".into(),
|
||||
};
|
||||
let (base, key) = credential
|
||||
.direct_provider_route(&llm)
|
||||
.expect("custom route");
|
||||
assert_eq!(base, llm.base_url);
|
||||
assert_eq!(key, llm.api_key);
|
||||
let proxy = start_codex_provider_proxy(base, key, false).await.unwrap();
|
||||
for mode in [
|
||||
CodexAppServerWorkspaceMode::DirectProject,
|
||||
CodexAppServerWorkspaceMode::ToolHost,
|
||||
] {
|
||||
let mut command = tokio::process::Command::new("fixture");
|
||||
configure_game_creator_codex_app_server_command_for_mode(
|
||||
&mut command,
|
||||
&llm,
|
||||
mode,
|
||||
Some(&proxy),
|
||||
None,
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
let arguments = command
|
||||
.as_std()
|
||||
.get_args()
|
||||
.map(|arg| arg.to_string_lossy())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let params = codex_app_server_thread_start_params(
|
||||
&llm.model,
|
||||
std::path::Path::new("fixture-workspace"),
|
||||
mode,
|
||||
String::new(),
|
||||
true,
|
||||
);
|
||||
assert_eq!(params["model"], "vendor/model.v1:latest");
|
||||
assert!(!arguments.contains(&llm.api_key));
|
||||
assert!(!arguments.contains("/api/llm"));
|
||||
for (_, value) in command.as_std().get_envs() {
|
||||
assert!(!value.is_some_and(|value| value.to_string_lossy().contains(&llm.api_key)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn direct_project_spawn_restores_broker_token_after_environment_isolation() {
|
||||
|
||||
@@ -527,6 +527,10 @@ fn process_design_batch(
|
||||
let result = if uncertain {
|
||||
Err("进程在工具执行期间中断,执行结果未保存。未重复执行;请读取实际工作区确认结果后再决定下一步。".to_string())
|
||||
} else {
|
||||
let _write = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
|
||||
root,
|
||||
"design.tool",
|
||||
)?;
|
||||
execute_design_tool(root, resources, session, &call)
|
||||
};
|
||||
let error = result
|
||||
@@ -1022,15 +1026,6 @@ pub(crate) async fn continue_design_agent_at(
|
||||
finish_design_command(root, resources, session, active, run, emit).await
|
||||
}
|
||||
|
||||
async fn recover_uncertain_design_batch(
|
||||
root: &Path,
|
||||
resources: &DesignResources,
|
||||
session: DesignSession,
|
||||
active: File,
|
||||
) -> Result<DesignView, String> {
|
||||
finish_design_command(root, resources, session, active, true, |_| {}).await
|
||||
}
|
||||
|
||||
pub(crate) async fn decide_design_phase_at(
|
||||
root: &Path,
|
||||
resources: &DesignResources,
|
||||
@@ -1063,8 +1058,7 @@ fn ensure_design_runtime_active(root: &Path) -> Result<(), String> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn hydrate_design_agent_session(
|
||||
app: tauri::AppHandle,
|
||||
pub(crate) fn hydrate_design_agent_session(
|
||||
project_path: String,
|
||||
) -> Result<Option<DesignView>, String> {
|
||||
let root = Path::new(project_path.trim());
|
||||
@@ -1090,33 +1084,8 @@ pub(crate) async fn hydrate_design_agent_session(
|
||||
if session.project_id != project_id {
|
||||
return Err("策划会话与当前项目不匹配".into());
|
||||
}
|
||||
let Some(active) =
|
||||
try_open_game_creator_agent_runtime_task_lock_file(root, DESIGN_ACTIVE_LOCK)?
|
||||
else {
|
||||
return Ok(Some(design_view(&session, true)));
|
||||
};
|
||||
if design_session_has_uncertain_batch(&session) {
|
||||
let resources = DesignResources::new(resolve_design_resources_root(&app)?)?;
|
||||
let view = recover_uncertain_design_batch(root, &resources, session, active).await?;
|
||||
return Ok(Some(view));
|
||||
}
|
||||
drop(active);
|
||||
Ok(Some(design_view(&session, false)))
|
||||
}
|
||||
|
||||
fn design_session_has_uncertain_batch(session: &DesignSession) -> bool {
|
||||
let Some(batch) = session.pending_batch.as_ref() else {
|
||||
return false;
|
||||
};
|
||||
if !batch.executing || batch.cursor >= batch.calls.len() {
|
||||
return false;
|
||||
}
|
||||
let call_id = batch.calls[batch.cursor].id.as_str();
|
||||
session.turn.as_ref().is_some_and(|turn| turn.pending)
|
||||
&& !session.history.iter().any(|item| {
|
||||
item.get("type").and_then(Value::as_str) == Some("function_call_output")
|
||||
&& item.get("call_id").and_then(Value::as_str) == Some(call_id)
|
||||
})
|
||||
let active = try_open_game_creator_agent_runtime_task_lock_file(root, DESIGN_ACTIVE_LOCK)?;
|
||||
Ok(Some(design_view(&session, active.is_none())))
|
||||
}
|
||||
|
||||
fn design_session_error_is_recoverable(error: &str) -> bool {
|
||||
@@ -1989,94 +1958,4 @@ mod tests {
|
||||
.any(|message| message.text.contains("重试后继续")));
|
||||
assert!(next.session.last_error.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn uncertain_batch_hydrate_continues_the_original_turn_without_replaying_file_tools() {
|
||||
let (_temp, root, resources) = init_design_project();
|
||||
execute_design_file_tool(
|
||||
&root,
|
||||
"write_file",
|
||||
&json!({"path":"project/00_concept/design.md","content":"概念"}),
|
||||
)
|
||||
.expect("write concept");
|
||||
let mut session = new_design_session("design-fake", "quality");
|
||||
let call = platform_llm::LlmToolCall {
|
||||
id: "interrupted-call".into(),
|
||||
name: "patch_file".into(),
|
||||
arguments: json!({
|
||||
"path":"project/00_concept/design.md",
|
||||
"old_text":"概念",
|
||||
"new_text":"概念设计"
|
||||
})
|
||||
.to_string(),
|
||||
};
|
||||
session.history.push(json!({
|
||||
"type":"function_call",
|
||||
"call_id":call.id,
|
||||
"name":call.name,
|
||||
"arguments":call.arguments,
|
||||
}));
|
||||
session.messages = vec![DesignMessage {
|
||||
id: "turn:user".into(),
|
||||
role: "user".into(),
|
||||
text: "继续".into(),
|
||||
}];
|
||||
session.turn = Some(DesignTurn {
|
||||
id: "turn-recovery".into(),
|
||||
pending: true,
|
||||
request_index: 0,
|
||||
attempt: 0,
|
||||
});
|
||||
session.pending_batch = Some(DesignToolBatch {
|
||||
calls: vec![call],
|
||||
cursor: 0,
|
||||
executing: true,
|
||||
});
|
||||
assert!(design_session_has_uncertain_batch(&session));
|
||||
write_design_session(&root, &session).expect("write interrupted session");
|
||||
|
||||
let _fake = fake_provider::install(
|
||||
vec![Ok(fake_response(
|
||||
"recovered-after-uncertain-tool",
|
||||
"已读取文件并确认。",
|
||||
Vec::new(),
|
||||
))],
|
||||
0,
|
||||
);
|
||||
let view = recover_uncertain_design_batch(&root, &resources, session, {
|
||||
try_open_game_creator_agent_runtime_task_lock_file(
|
||||
&root,
|
||||
".agent/design-agent/active.lock",
|
||||
)
|
||||
.expect("open active lock")
|
||||
.expect("active lock is free")
|
||||
})
|
||||
.await
|
||||
.expect("recover uncertain batch");
|
||||
|
||||
assert!(!view.running);
|
||||
assert!(view.session.last_error.is_none());
|
||||
let restored = read_design_session(&root)
|
||||
.expect("read restored")
|
||||
.expect("session");
|
||||
assert!(restored.pending_batch.is_none());
|
||||
assert!(!restored.turn.expect("turn").pending);
|
||||
assert!(restored.history.iter().any(|item| {
|
||||
item.get("type").and_then(Value::as_str) == Some("function_call_output")
|
||||
&& item.get("call_id").and_then(Value::as_str) == Some("interrupted-call")
|
||||
&& item
|
||||
.get("output")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|output| output.contains("执行结果未保存"))
|
||||
}));
|
||||
assert!(restored.history.iter().any(|item| {
|
||||
item.get("role").and_then(Value::as_str) == Some("assistant")
|
||||
&& item.get("content").is_some()
|
||||
}));
|
||||
assert!(
|
||||
fs::read_to_string(root.join("design_artifacts/project/00_concept/design.md"))
|
||||
.expect("read target")
|
||||
== "概念"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,31 +321,15 @@ pub(crate) fn execute_design_file_tool(
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut matches = Vec::new();
|
||||
let mut edit_errors = Vec::new();
|
||||
let mut valid_edits = 0;
|
||||
for (index, (old, new)) in normalized.iter().enumerate() {
|
||||
if old == new {
|
||||
edit_errors.push(format!(
|
||||
"edits[{index}] new_text 与 old_text 相同,不会产生修改"
|
||||
));
|
||||
continue;
|
||||
}
|
||||
let count = content.matches(old).count();
|
||||
if count == 0 {
|
||||
edit_errors.push(format!(
|
||||
"edits[{index}] 原文未找到:{}{}",
|
||||
display,
|
||||
design_patch_location_hint(&content, old)
|
||||
));
|
||||
continue;
|
||||
return Err(format!("edits[{index}] 原文未找到:{display}"));
|
||||
}
|
||||
if count != 1 {
|
||||
let start = content.find(old).expect("count checked");
|
||||
let line = design_patch_line_number(&content, start);
|
||||
edit_errors.push(format!(
|
||||
"edits[{index}] 原文匹配 {count} 处,必须唯一;首次位于第 {line} 行"
|
||||
return Err(format!(
|
||||
"edits[{index}] 原文匹配 {count} 处,必须唯一:{display}"
|
||||
));
|
||||
continue;
|
||||
}
|
||||
let start = content.find(old).expect("count checked");
|
||||
let end = start + old.len();
|
||||
@@ -353,33 +337,13 @@ pub(crate) fn execute_design_file_tool(
|
||||
.iter()
|
||||
.find(|(_, other_start, other_end)| start < *other_end && *other_start < end)
|
||||
{
|
||||
edit_errors.push(format!(
|
||||
"edits[{index}] 与 edits[{other_index}] 修改范围重叠;请合并为一个 edit 或缩短 old_text"
|
||||
return Err(format!(
|
||||
"edits[{index}] 与 edits[{other_index}] 修改范围重叠:{display}"
|
||||
));
|
||||
continue;
|
||||
}
|
||||
matches.push((index, start, end));
|
||||
valid_edits += 1;
|
||||
let _ = new;
|
||||
}
|
||||
if !edit_errors.is_empty() {
|
||||
let shown = edit_errors.len().min(4);
|
||||
let mut details = edit_errors[..shown].to_vec();
|
||||
if shown < edit_errors.len() {
|
||||
details.push(format!(
|
||||
"另有 {} 个 edit 校验失败(详情省略)",
|
||||
edit_errors.len() - shown
|
||||
));
|
||||
}
|
||||
if valid_edits > 0 {
|
||||
details.push(format!(
|
||||
"其余 {valid_edits} 个 edit 当前可唯一匹配;本次未写入文件"
|
||||
));
|
||||
} else {
|
||||
details.push("本次未写入文件".to_string());
|
||||
}
|
||||
return Err(details.join("\n"));
|
||||
}
|
||||
let mut updated = content.clone();
|
||||
for (index, start, end) in matches.into_iter().rev() {
|
||||
let (_, new) = &normalized[index];
|
||||
@@ -432,60 +396,6 @@ pub(crate) fn execute_design_file_tool(
|
||||
}
|
||||
}
|
||||
|
||||
fn design_patch_line_number(content: &str, start: usize) -> usize {
|
||||
1 + content[..start]
|
||||
.bytes()
|
||||
.filter(|byte| *byte == b'\n')
|
||||
.count()
|
||||
}
|
||||
|
||||
fn design_patch_visible_line(line: &str) -> String {
|
||||
line.replace('\t', "\\t").chars().take(180).collect()
|
||||
}
|
||||
|
||||
fn design_patch_location_hint(content: &str, old: &str) -> String {
|
||||
let Some(anchor) = old.lines().map(str::trim).find(|line| !line.is_empty()) else {
|
||||
return String::new();
|
||||
};
|
||||
|
||||
let mut candidates = content
|
||||
.lines()
|
||||
.enumerate()
|
||||
.filter(|(_, line)| line.trim() == anchor)
|
||||
.map(|(index, line)| (index + 1, line))
|
||||
.collect::<Vec<_>>();
|
||||
if candidates.is_empty() {
|
||||
let token = anchor.split_whitespace().find(|token| token.len() >= 3);
|
||||
if let Some(token) = token {
|
||||
candidates = content
|
||||
.lines()
|
||||
.enumerate()
|
||||
.filter(|(_, line)| line.trim().contains(token))
|
||||
.map(|(index, line)| (index + 1, line))
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
if candidates.is_empty() {
|
||||
return format!(
|
||||
";未找到与 old_text 首个非空行相似的行(当前文件约 {} 行)",
|
||||
content.lines().count()
|
||||
);
|
||||
}
|
||||
|
||||
let details = candidates
|
||||
.iter()
|
||||
.take(2)
|
||||
.map(|(line, text)| format!("第 {line} 行:{}", design_patch_visible_line(text)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(";");
|
||||
let suffix = if candidates.len() > 2 {
|
||||
format!("等 {} 处", candidates.len())
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
format!(";old_text 首个非空行可能对应 {details}{suffix}(tab 显示为 \\t)")
|
||||
}
|
||||
|
||||
pub(crate) fn list_design_workspace_files(
|
||||
root: &Path,
|
||||
) -> Result<Vec<DesignWorkspaceEntry>, String> {
|
||||
@@ -783,22 +693,6 @@ mod tests {
|
||||
)
|
||||
.expect_err("escape");
|
||||
assert!(escaped.contains("路径"));
|
||||
let mismatch = execute_design_file_tool(
|
||||
root,
|
||||
"patch_file",
|
||||
&json!({
|
||||
"path":"notes/design.md",
|
||||
"edits":[
|
||||
{"old_text":" 游戏设计","new_text":"游戏概念"},
|
||||
{"old_text":"设计","new_text":"方案"}
|
||||
]
|
||||
}),
|
||||
)
|
||||
.expect_err("report all patch failures");
|
||||
assert!(mismatch.contains("edits[0] 原文未找到"));
|
||||
assert!(mismatch.contains("第 1 行:游戏设计"));
|
||||
assert!(mismatch.contains("其余 1 个 edit 当前可唯一匹配"));
|
||||
assert!(mismatch.contains("本次未写入文件"));
|
||||
let patched = execute_design_file_tool(
|
||||
root,
|
||||
"patch_file",
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1887,7 +1887,7 @@ async fn bridge_create_or_derive_resource(
|
||||
|
||||
async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Value) -> Value {
|
||||
let result = async {
|
||||
super::direct_tools_mcp::validate_remove_background_arguments(arguments)?;
|
||||
bridge_reject_unknown_fields(arguments, &["sourceLocalAssetId", "assetName"])?;
|
||||
enforce_project_permission_policy(&state.root, "canvas.asset_generate")?;
|
||||
enforce_project_permission_policy(&state.root, "asset.register")?;
|
||||
let source_asset_id = bridge_bounded_string(arguments, "sourceLocalAssetId", 80)?;
|
||||
@@ -1896,8 +1896,6 @@ async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Val
|
||||
"assetName",
|
||||
DIRECT_TOOL_BRIDGE_MAX_RESOURCE_NAME_CHARS,
|
||||
)?;
|
||||
let background_mode = arguments.get("backgroundMode").and_then(Value::as_str);
|
||||
let screen_color = arguments.get("screenColor").and_then(Value::as_str);
|
||||
let manifest = read_existing_manifest_for_project(&state.root)?;
|
||||
let source_asset = manifest
|
||||
.assets
|
||||
@@ -1922,34 +1920,22 @@ async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Val
|
||||
.map_err(|_| "创建抠图服务连接失败".to_string())?;
|
||||
let context =
|
||||
prepare_external_canvas_generation_context(&state.root, &client, &access).await?;
|
||||
let fingerprint = background_removal_request_fingerprint(
|
||||
&source_asset_id,
|
||||
&asset_name,
|
||||
background_mode,
|
||||
screen_color,
|
||||
);
|
||||
let fingerprint = format!("{}\0{}", source_asset_id, asset_name);
|
||||
let (_operation_id, idempotency_key) = state.resource_request_ids(&fingerprint)?;
|
||||
let route = "/api/external/v1/editor/images/background-removals";
|
||||
let mut request_body = json!({
|
||||
"sourceImageSrc": source_resource_id,
|
||||
"projectId": manifest.project_id,
|
||||
"assetKind": source_asset.kind,
|
||||
"assetFolderId": context.asset_folder_id,
|
||||
"assetLabel": asset_name,
|
||||
"sourceResourceId": source_resource_id,
|
||||
});
|
||||
if background_mode == Some("flat") {
|
||||
request_body["backgroundMode"] = json!("flat");
|
||||
}
|
||||
if let Some(color) = screen_color {
|
||||
request_body["screenColor"] = json!(color);
|
||||
}
|
||||
let response = crate::http_client::with_agc_main_site_marker(
|
||||
client
|
||||
.post(format!("{}{}", api_base_url, route))
|
||||
.bearer_auth(api_key)
|
||||
.header("Idempotency-Key", idempotency_key)
|
||||
.json(&request_body),
|
||||
.json(&json!({
|
||||
"sourceImageSrc": source_resource_id,
|
||||
"projectId": manifest.project_id,
|
||||
"assetKind": source_asset.kind,
|
||||
"assetFolderId": context.asset_folder_id,
|
||||
"assetLabel": asset_name,
|
||||
"sourceResourceId": source_resource_id,
|
||||
})),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
@@ -1986,20 +1972,6 @@ async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Val
|
||||
}
|
||||
}
|
||||
|
||||
fn background_removal_request_fingerprint(
|
||||
source: &str,
|
||||
name: &str,
|
||||
mode: Option<&str>,
|
||||
color: Option<&str>,
|
||||
) -> String {
|
||||
let mode = mode.unwrap_or("complex");
|
||||
if mode == "complex" && color.is_none() {
|
||||
format!("{source}\0{name}")
|
||||
} else {
|
||||
format!("{source}\0{name}\0{mode}\0{}", color.unwrap_or(""))
|
||||
}
|
||||
}
|
||||
|
||||
fn bridge_safe_queue_state(value: Value) -> Value {
|
||||
let object = value.as_object();
|
||||
json!({
|
||||
@@ -2731,29 +2703,6 @@ pub(crate) async fn start_direct_tool_bridge(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn remove_background_identity_preserves_default_and_distinguishes_options() {
|
||||
let legacy = "asset-1\0透明图";
|
||||
assert_eq!(
|
||||
background_removal_request_fingerprint("asset-1", "透明图", None, None),
|
||||
legacy
|
||||
);
|
||||
assert_eq!(
|
||||
background_removal_request_fingerprint("asset-1", "透明图", Some("complex"), None),
|
||||
legacy
|
||||
);
|
||||
let mut identities = std::collections::HashSet::new();
|
||||
identities.insert(legacy.to_string());
|
||||
for color in [None, Some("auto"), Some("#CFEFFF"), Some("#112233")] {
|
||||
let id =
|
||||
background_removal_request_fingerprint("asset-1", "透明图", Some("flat"), color);
|
||||
assert_eq!(
|
||||
id,
|
||||
background_removal_request_fingerprint("asset-1", "透明图", Some("flat"), color)
|
||||
);
|
||||
assert!(identities.insert(id));
|
||||
}
|
||||
}
|
||||
use super::*;
|
||||
use std::io::{Cursor, Read, Write};
|
||||
|
||||
|
||||
@@ -8,11 +8,9 @@
|
||||
//! 为什么不复用 `project.jsonl`:那条链路的回读只投影 `role ∈ {user, assistant}` 的
|
||||
//! 文本条目,而且会被注入 Codex 上下文。往里面塞新形状既装不下,又有污染模型上下文的风险。
|
||||
|
||||
use crate::agent::redact_secret_tokens;
|
||||
use crate::agent::sanitize_error_context;
|
||||
use super::direct_thread_wire::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 crate::redact_absolute_path_tokens;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
@@ -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 {
|
||||
|
||||
@@ -49,7 +49,7 @@ struct ExternalMcpHttpState {
|
||||
root: PathBuf,
|
||||
token: String,
|
||||
session_user_id: String,
|
||||
session_identity_generation: u64,
|
||||
session_generation: u64,
|
||||
}
|
||||
|
||||
pub(crate) fn direct_tools_mcp_mode_requested(args: &[String]) -> bool {
|
||||
@@ -435,7 +435,7 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab
|
||||
}),
|
||||
json!({
|
||||
"name": "agc_remove_background",
|
||||
"description": "为当前项目已登记的图片资源去除背景。complex 通过语义分割识别前景;flat 用于纯色背景抠图,确定背景为纯色时优先选择 flat。提供资源身份、结果名称及可选模式和背景色;客户端管理登录、项目画布和素材目录,不返回 Token、内部路由、宿主路径或临时签名 URL。",
|
||||
"description": "为当前项目已登记的图片资源去除背景。客户端使用当前登录账号的抠图服务、项目画布和素材目录,模型只能提供已登记资源身份与结果名称;不会返回 Token、内部路由、宿主路径或临时签名 URL。",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -449,16 +449,6 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": DIRECT_TOOLS_MCP_MAX_RESOURCE_NAME_CHARS
|
||||
},
|
||||
"backgroundMode": {
|
||||
"type": "string",
|
||||
"enum": ["complex", "flat"],
|
||||
"description": "可选抠图模式:complex 用语义分割识别前景,flat 用纯色背景抠图;确定背景为纯色时优先使用 flat。省略时使用 complex"
|
||||
},
|
||||
"screenColor": {
|
||||
"type": "string",
|
||||
"pattern": "^(auto|#[0-9A-Fa-f]{6})$",
|
||||
"description": "flat 模式可选背景色;传 auto 或 #RRGGBB,省略时由服务自动检测"
|
||||
}
|
||||
},
|
||||
"required": ["sourceLocalAssetId", "assetName"],
|
||||
@@ -891,46 +881,14 @@ fn validate_resource_generation_arguments(arguments: &Value) -> Result<(), Strin
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn validate_remove_background_arguments(arguments: &Value) -> Result<(), String> {
|
||||
validate_tool_object_fields(
|
||||
arguments,
|
||||
&[
|
||||
"sourceLocalAssetId",
|
||||
"assetName",
|
||||
"backgroundMode",
|
||||
"screenColor",
|
||||
],
|
||||
)?;
|
||||
fn validate_remove_background_arguments(arguments: &Value) -> Result<(), String> {
|
||||
validate_tool_object_fields(arguments, &["sourceLocalAssetId", "assetName"])?;
|
||||
bounded_tool_string(arguments, "sourceLocalAssetId", 80)?;
|
||||
bounded_tool_string(
|
||||
arguments,
|
||||
"assetName",
|
||||
DIRECT_TOOLS_MCP_MAX_RESOURCE_NAME_CHARS,
|
||||
)?;
|
||||
if let Some(mode) = arguments.get("backgroundMode") {
|
||||
let mode = mode
|
||||
.as_str()
|
||||
.ok_or_else(|| "backgroundMode 必须是 complex 或 flat".to_string())?;
|
||||
if mode != "complex" && mode != "flat" {
|
||||
return Err("backgroundMode 必须是 complex 或 flat".to_string());
|
||||
}
|
||||
}
|
||||
if let Some(color) = arguments.get("screenColor") {
|
||||
let color = color
|
||||
.as_str()
|
||||
.ok_or_else(|| "screenColor 必须是 auto 或 #RRGGBB".to_string())?;
|
||||
let valid_hex = color.len() == 7
|
||||
&& color.starts_with('#')
|
||||
&& color[1..]
|
||||
.chars()
|
||||
.all(|character| character.is_ascii_hexdigit());
|
||||
if color != "auto" && !valid_hex {
|
||||
return Err("screenColor 必须是 auto 或 #RRGGBB".to_string());
|
||||
}
|
||||
if arguments.get("backgroundMode").and_then(Value::as_str) != Some("flat") {
|
||||
return Err("complex 模式不能传 screenColor".to_string());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1283,8 +1241,7 @@ fn external_mcp_session_id(root: &Path) -> String {
|
||||
material.push('\0');
|
||||
material.push_str(&session.user_id);
|
||||
material.push('\0');
|
||||
// 用身份代次而不是 token:同一账号续期不得让 MCP 会话身份漂移。
|
||||
material.push_str(&session.identity_generation.to_string());
|
||||
material.push_str(&session.generation.to_string());
|
||||
}
|
||||
format!("mcp-{:x}", Sha256::digest(material.as_bytes()))
|
||||
}
|
||||
@@ -1802,9 +1759,7 @@ async fn handle_external_mcp_http_request(
|
||||
let Some(session) = current_platform_session() else {
|
||||
return Err(StatusCode::UNAUTHORIZED);
|
||||
};
|
||||
if session.user_id != state.session_user_id
|
||||
|| session.identity_generation != state.session_identity_generation
|
||||
{
|
||||
if session.user_id != state.session_user_id || session.generation != state.session_generation {
|
||||
return Err(StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
let response = EXTERNAL_MCP_BRIDGE_URL
|
||||
@@ -1839,7 +1794,7 @@ pub(crate) async fn start_external_mcp_loopback(
|
||||
root,
|
||||
token: token.clone(),
|
||||
session_user_id: session.user_id,
|
||||
session_identity_generation: session.identity_generation,
|
||||
session_generation: session.generation,
|
||||
};
|
||||
let app = Router::new()
|
||||
.route(&route, post(handle_external_mcp_http_request))
|
||||
@@ -1891,51 +1846,6 @@ pub(crate) fn stop_game_creator_external_mcp() -> Result<(), String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn remove_background_arguments_enforce_mode_color_contract() {
|
||||
for fields in [
|
||||
json!({}),
|
||||
json!({"backgroundMode":"complex"}),
|
||||
json!({"backgroundMode":"flat"}),
|
||||
json!({"backgroundMode":"flat","screenColor":"auto"}),
|
||||
json!({"backgroundMode":"flat","screenColor":"#Ab12EF"}),
|
||||
] {
|
||||
let mut arguments = json!({"sourceLocalAssetId":"asset-1","assetName":"透明图"});
|
||||
arguments
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.extend(fields.as_object().unwrap().clone());
|
||||
assert!(
|
||||
validate_remove_background_arguments(&arguments).is_ok(),
|
||||
"{fields}"
|
||||
);
|
||||
}
|
||||
for fields in [
|
||||
json!({"screenColor":"auto"}),
|
||||
json!({"backgroundMode":"complex","screenColor":"auto"}),
|
||||
json!({"backgroundMode":"flat","screenColor":""}),
|
||||
json!({"backgroundMode":"flat","screenColor":" auto "}),
|
||||
json!({"backgroundMode":"flat","screenColor":"AUTO"}),
|
||||
json!({"backgroundMode":"flat","screenColor":"#GGGGGG"}),
|
||||
json!({"backgroundMode":"flat","screenColor":null}),
|
||||
json!({"backgroundMode":"flat","screenColor":12}),
|
||||
json!({"backgroundMode":""}),
|
||||
json!({"backgroundMode":"FLAT"}),
|
||||
json!({"backgroundMode":" flat "}),
|
||||
json!({"backgroundMode":null}),
|
||||
] {
|
||||
let mut arguments = json!({"sourceLocalAssetId":"asset-1","assetName":"透明图"});
|
||||
arguments
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.extend(fields.as_object().unwrap().clone());
|
||||
assert!(
|
||||
validate_remove_background_arguments(&arguments).is_err(),
|
||||
"{fields}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(windows, feature = "cocos-editor-execute"))]
|
||||
#[test]
|
||||
fn builtin_mcp_process_probe() {
|
||||
|
||||
@@ -11,10 +11,6 @@ use super::external_generation_state::{
|
||||
retain_platform_art_generation_runtime_accepted_result, PlatformArtGenerationRuntimeState,
|
||||
};
|
||||
use super::*;
|
||||
use crate::platform_session::{
|
||||
acquire_platform_session_identity_lease, validate_platform_session_identity,
|
||||
PlatformSessionIdentity,
|
||||
};
|
||||
use reqwest::multipart::{Form, Part};
|
||||
|
||||
const EXTERNAL_GENERATION_POLL_TIMEOUT: Duration = Duration::from_secs(35 * 60);
|
||||
@@ -1514,7 +1510,10 @@ struct PreparedPlatformArtAssetSlice {
|
||||
|
||||
#[derive(Clone)]
|
||||
struct PreparedPlatformSessionFence {
|
||||
identity: PlatformSessionIdentity,
|
||||
user_id: String,
|
||||
api_base_url: String,
|
||||
generation: u64,
|
||||
access_token_sha256: String,
|
||||
}
|
||||
|
||||
impl PreparedPlatformSessionFence {
|
||||
@@ -1522,17 +1521,41 @@ impl PreparedPlatformSessionFence {
|
||||
access
|
||||
.frozen_platform_session()
|
||||
.map(|session| PreparedPlatformSessionFence {
|
||||
identity: session.identity(),
|
||||
user_id: session.user_id.clone(),
|
||||
api_base_url: session.api_base_url.clone(),
|
||||
generation: session.generation,
|
||||
access_token_sha256: format!(
|
||||
"{:x}",
|
||||
Sha256::digest(session.access_token.as_bytes())
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
fn validate(&self) -> Result<(), String> {
|
||||
// 只比较身份:同一账号的 access token 轮换不得让在途生成 operation 失败。
|
||||
validate_platform_session_identity(&self.identity)
|
||||
let matches = current_platform_session().is_some_and(|session| {
|
||||
session.user_id == self.user_id
|
||||
&& session.api_base_url == self.api_base_url
|
||||
&& session.generation == self.generation
|
||||
&& format!("{:x}", Sha256::digest(session.access_token.as_bytes()))
|
||||
== self.access_token_sha256
|
||||
});
|
||||
if matches {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(
|
||||
"authentication-required: 陶泥儿登录态已变化,旧账号请求已停止,请使用当前账号重试"
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn acquire_lease(&self) -> Result<ValidatedPlatformSessionLease, String> {
|
||||
acquire_platform_session_identity_lease(&self.identity)
|
||||
acquire_validated_platform_session_fingerprint(
|
||||
&self.user_id,
|
||||
&self.api_base_url,
|
||||
self.generation,
|
||||
&self.access_token_sha256,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10613,7 +10636,7 @@ mod canvas_generation_tests {
|
||||
}
|
||||
drop(owner_a_access);
|
||||
drop(frozen_owner_a);
|
||||
install_platform_session("durable-owner-b", "durable-owner-b-token", &base_url, 2, 2)
|
||||
install_platform_session("durable-owner-b", "durable-owner-b-token", &base_url, 2)
|
||||
.expect("switch to owner B");
|
||||
|
||||
let error = match request_platform_art_asset_with_runtime_options_at(
|
||||
@@ -10738,14 +10761,8 @@ mod canvas_generation_tests {
|
||||
.recv_timeout(Duration::from_secs(3))
|
||||
.expect("wait for accepted response");
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
install_platform_session(
|
||||
"post-202-user-b",
|
||||
"post-202-token-b",
|
||||
&switch_base_url,
|
||||
2,
|
||||
2,
|
||||
)
|
||||
.expect("switch platform account after accepted response");
|
||||
install_platform_session("post-202-user-b", "post-202-token-b", &switch_base_url, 2)
|
||||
.expect("switch platform account after accepted response");
|
||||
});
|
||||
let runtime_context = PlatformArtGenerationRuntimeContext {
|
||||
agent_id: "art-director".to_string(),
|
||||
|
||||
+1
-2
@@ -1431,13 +1431,12 @@ mod external_generation_state_tests {
|
||||
base_url,
|
||||
);
|
||||
let frozen_a = current_platform_session().expect("freeze owner A");
|
||||
validate_frozen_platform_session(&frozen_a).expect("owner A is current before switch");
|
||||
validate_platform_session_snapshot(&frozen_a).expect("owner A is current before switch");
|
||||
replace_platform_session_for_gui_owner(
|
||||
"fingerprint-owner-b",
|
||||
"fingerprint-token-b",
|
||||
base_url,
|
||||
2,
|
||||
2,
|
||||
)
|
||||
.expect("switch global session to owner B");
|
||||
let current_b = current_platform_session().expect("owner B is current after switch");
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user