Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b6ed8a4cf | |||
| 16deb0ad61 | |||
| 13f56e644d | |||
| e08b031761 | |||
| 70ad794b28 | |||
| ca761e093c | |||
| a17367112f | |||
| d7a0c3cffd | |||
| fa64819b6e | |||
| f748d36d21 | |||
| 79b153e3fa | |||
| 362edcc49d | |||
| 1e434e0cb5 | |||
| cec971c438 | |||
| 70157673b6 | |||
| 492e9e63af | |||
| 57fae7037a | |||
| 2bcfa10647 | |||
| e777236817 | |||
| d11c74212d | |||
| 82b2f853e7 | |||
| 31f83a751a | |||
| a1cafde7e9 | |||
| 0594a90bdd | |||
| aee862532c | |||
| 42be8ea060 | |||
| b48293fb1f | |||
| e08171fd2e | |||
| 479120d368 | |||
| 4182979a19 | |||
| 737a2266b9 | |||
| 262deaf9b7 | |||
| c36a5170f8 | |||
| 9663bbf911 | |||
| b3e9d0a906 | |||
| a60328623d | |||
| 9e63b76991 | |||
| a98ebcf68f | |||
| 576ff07a5e | |||
| 5aa616134c | |||
| 187b66c3b1 |
@@ -1 +1,4 @@
|
|||||||
|
# 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
|
npm run format:staged
|
||||||
|
|||||||
@@ -1 +1,4 @@
|
|||||||
|
# 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 -- "$@"
|
npm run check:pre-push-master -- "$@"
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ vi.mock('../api/adminApiClient', () => ({
|
|||||||
|
|
||||||
interface MockIntersectionObserverController {
|
interface MockIntersectionObserverController {
|
||||||
enter: (target: Element) => void;
|
enter: (target: Element) => void;
|
||||||
|
enterAll: (targets: Element[]) => void;
|
||||||
isObserved: (target: Element) => boolean;
|
isObserved: (target: Element) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,6 +107,25 @@ 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) {
|
isObserved(target) {
|
||||||
return observed.has(target);
|
return observed.has(target);
|
||||||
},
|
},
|
||||||
@@ -753,10 +773,10 @@ test('后台素材查询为大量同时可见的缩略图持续错峰换签', as
|
|||||||
const thumbnails = entries.map((entry) =>
|
const thumbnails = entries.map((entry) =>
|
||||||
thumbnailElementForLabel(entry.label),
|
thumbnailElementForLabel(entry.label),
|
||||||
);
|
);
|
||||||
thumbnails.forEach((thumbnail) => {
|
for (const thumbnail of thumbnails) {
|
||||||
expect(observer.isObserved(thumbnail)).toBe(true);
|
expect(observer.isObserved(thumbnail)).toBe(true);
|
||||||
observer.enter(thumbnail);
|
}
|
||||||
});
|
observer.enterAll(thumbnails);
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
@@ -776,7 +796,7 @@ test('后台素材查询为大量同时可见的缩略图持续错峰换签', as
|
|||||||
await vi.advanceTimersByTimeAsync(200);
|
await vi.advanceTimersByTimeAsync(200);
|
||||||
});
|
});
|
||||||
expect(getAdminAssetReadUrl).toHaveBeenCalledTimes(105);
|
expect(getAdminAssetReadUrl).toHaveBeenCalledTimes(105);
|
||||||
});
|
}, 10_000);
|
||||||
|
|
||||||
test('后台素材查询读取更多后为新进入可视区域的素材换签', async () => {
|
test('后台素材查询读取更多后为新进入可视区域的素材换签', async () => {
|
||||||
const observer = installIntersectionObserverMock();
|
const observer = installIntersectionObserverMock();
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
"schemaVersion": "game-creator-config.v2",
|
"schemaVersion": "game-creator-config.v2",
|
||||||
"agentMode": "codex_app_server",
|
"agentMode": "codex_app_server",
|
||||||
"llm": {
|
"llm": {
|
||||||
|
"customEnabled": false,
|
||||||
|
"visibleModels": [],
|
||||||
"apiKey": "",
|
"apiKey": "",
|
||||||
"baseUrl": "https://dev.genarrative.world/gpt/v1",
|
"baseUrl": "https://dev.genarrative.world/gpt/v1",
|
||||||
"model": "gpt-6-astra",
|
"model": "gpt-6-astra",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@genarrative/ai-game-creator-shell",
|
"name": "@genarrative/ai-game-creator-shell",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.29",
|
"version": "0.1.47",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "node scripts/start-tauri-dev.mjs",
|
"dev": "node scripts/start-tauri-dev.mjs",
|
||||||
|
|||||||
+1
-1
@@ -1725,7 +1725,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "genarrative-ai-game-creator-shell"
|
name = "genarrative-ai-game-creator-shell"
|
||||||
version = "0.1.29"
|
version = "0.1.47"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"agent-runtime-core",
|
"agent-runtime-core",
|
||||||
"axum",
|
"axum",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "genarrative-ai-game-creator-shell"
|
name = "genarrative-ai-game-creator-shell"
|
||||||
version = "0.1.29"
|
version = "0.1.47"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
{"type":"function","function":{"name":"get_workflow_status","description":"读取当前策划工作流状态,只返回阶段列表、当前阶段、已批准阶段和待审批阶段;不推进阶段、不提交审批、不修改文件。","parameters":{"type":"object","properties":{},"additionalProperties":false}}},
|
{"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":"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":"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 必须非空且在原文件中唯一,匹配失败、重复或范围重叠时不修改文件。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 必须非空且在原文件中唯一。所有 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":"delete_path","description":"谨慎使用;永久删除工作区内的文件或目录;目录会连同全部内容递归删除,不备份。先确认目标及删除范围。path 使用相对路径,不能删除工作区根目录,也不能经过链接。","parameters":{"type":"object","properties":{"path":{"type":"string"}},"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":"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}}},
|
{"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.
|
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.
|
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.
|
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 an output name. 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 `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.
|
||||||
7. Preserve existing relative paths when a small edit is sufficient so client resource identities remain stable.
|
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.
|
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.
|
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_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` 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.
|
`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.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"schemaVersion": "agc-skill-pack.v1",
|
"schemaVersion": "agc-skill-pack.v1",
|
||||||
"version": "2026-08-26.16",
|
"version": "2026-08-26.17",
|
||||||
"skills": [
|
"skills": [
|
||||||
{
|
{
|
||||||
"name": "agc-game-production-workflow",
|
"name": "agc-game-production-workflow",
|
||||||
@@ -123,7 +123,7 @@
|
|||||||
"agents/openai.yaml",
|
"agents/openai.yaml",
|
||||||
"references/projection-contract.md"
|
"references/projection-contract.md"
|
||||||
],
|
],
|
||||||
"sha256": "96b5bf9e2ed150bbe934a888867c1bb500b214a131f8b36c4830f51ca30267b6"
|
"sha256": "a929c27bc5b2b0bee0b7935e5c7b04ddbab1eb1804fe196f8c2537ad040ca5b1"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,7 +131,6 @@ impl CodexAppServerCredential {
|
|||||||
) -> Option<(&'a str, &'a str)> {
|
) -> Option<(&'a str, &'a str)> {
|
||||||
match self {
|
match self {
|
||||||
Self::PlatformSession { .. } => None,
|
Self::PlatformSession { .. } => None,
|
||||||
#[cfg(test)]
|
|
||||||
Self::AppDataKey { .. } => (!llm.api_key.trim().is_empty())
|
Self::AppDataKey { .. } => (!llm.api_key.trim().is_empty())
|
||||||
.then_some((llm.base_url.trim_end_matches('/'), llm.api_key.trim())),
|
.then_some((llm.base_url.trim_end_matches('/'), llm.api_key.trim())),
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -139,7 +138,7 @@ impl CodexAppServerCredential {
|
|||||||
.as_deref()
|
.as_deref()
|
||||||
.map(|api_key| (GAME_CREATOR_CODEX_AUTH_BRIDGE_API_BASE_URL, api_key)),
|
.map(|api_key| (GAME_CREATOR_CODEX_AUTH_BRIDGE_API_BASE_URL, api_key)),
|
||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
Self::AppDataKey { .. } | Self::AuthBridge { .. } => None,
|
Self::AuthBridge { .. } => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -196,6 +195,20 @@ 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 {
|
struct CodexTurnStartCancellation {
|
||||||
inner: Weak<CodexAppServerInner>,
|
inner: Weak<CodexAppServerInner>,
|
||||||
thread_id: String,
|
thread_id: String,
|
||||||
@@ -257,7 +270,7 @@ impl CodexTurnStartCancellation {
|
|||||||
};
|
};
|
||||||
let connection = CodexAppServerConnection { inner };
|
let connection = CodexAppServerConnection { inner };
|
||||||
let thread_id = self.thread_id.clone();
|
let thread_id = self.thread_id.clone();
|
||||||
tokio::spawn(async move {
|
spawn_codex_app_server_task(async move {
|
||||||
let _ = connection
|
let _ = connection
|
||||||
.request(
|
.request(
|
||||||
"turn/interrupt",
|
"turn/interrupt",
|
||||||
@@ -2018,7 +2031,13 @@ impl CodexAppServerConnection {
|
|||||||
let codex_cli_version = game_creator_codex_cli_version_identity()
|
let codex_cli_version = game_creator_codex_cli_version_identity()
|
||||||
.map_err(platform_llm::LlmError::InvalidConfig)?;
|
.map_err(platform_llm::LlmError::InvalidConfig)?;
|
||||||
let mut effective_llm = llm.clone();
|
let mut effective_llm = llm.clone();
|
||||||
let credential = if game_creator_official_llm_route_locked() {
|
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 session = current_platform_session().ok_or_else(|| {
|
let session = current_platform_session().ok_or_else(|| {
|
||||||
platform_llm::LlmError::InvalidConfig(
|
platform_llm::LlmError::InvalidConfig(
|
||||||
"authentication-required: 请先登录陶泥儿账号".to_string(),
|
"authentication-required: 请先登录陶泥儿账号".to_string(),
|
||||||
@@ -2186,7 +2205,8 @@ impl CodexAppServerConnection {
|
|||||||
true,
|
true,
|
||||||
),
|
),
|
||||||
_ => (
|
_ => (
|
||||||
(workspace_mode == CodexAppServerWorkspaceMode::DirectProject)
|
(llm.custom_enabled
|
||||||
|
|| workspace_mode == CodexAppServerWorkspaceMode::DirectProject)
|
||||||
.then(|| credential.direct_provider_route(llm))
|
.then(|| credential.direct_provider_route(llm))
|
||||||
.flatten()
|
.flatten()
|
||||||
.map(|(base_url, api_key)| (base_url.to_string(), api_key.to_string())),
|
.map(|(base_url, api_key)| (base_url.to_string(), api_key.to_string())),
|
||||||
@@ -2840,22 +2860,8 @@ impl CodexAppServerConnection {
|
|||||||
codex_app_server_text_prompt(&request)
|
codex_app_server_text_prompt(&request)
|
||||||
.map_err(platform_llm::LlmError::InvalidRequest)?
|
.map_err(platform_llm::LlmError::InvalidRequest)?
|
||||||
};
|
};
|
||||||
let input = if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
|
let input =
|
||||||
if let Some(item) = direct_user_item {
|
codex_app_server_turn_input(&request, &prompt, &self.inner.workspace_path).await?;
|
||||||
let canonical: DirectCodexUserItem = serde_json::from_value(item.clone())
|
|
||||||
.map_err(|error| platform_llm::LlmError::InvalidRequest(error.to_string()))?;
|
|
||||||
direct_codex_user_item_to_codex_turn_input(
|
|
||||||
&self.inner.workspace_path,
|
|
||||||
&canonical,
|
|
||||||
self.inner._skill_roots.as_deref().unwrap_or_default(),
|
|
||||||
)
|
|
||||||
.map_err(platform_llm::LlmError::InvalidRequest)?
|
|
||||||
} else {
|
|
||||||
codex_app_server_turn_input(&request, &prompt, &self.inner.workspace_path).await?
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
codex_app_server_turn_input(&request, &prompt, &self.inner.workspace_path).await?
|
|
||||||
};
|
|
||||||
let _direct_tool_bridge_turn_guard =
|
let _direct_tool_bridge_turn_guard =
|
||||||
if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
|
if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
|
||||||
Some(
|
Some(
|
||||||
@@ -3574,7 +3580,7 @@ impl Drop for CodexThreadLease {
|
|||||||
let connection = self.connection.clone();
|
let connection = self.connection.clone();
|
||||||
let key = self.key.clone();
|
let key = self.key.clone();
|
||||||
let thread_id = self.thread_id.clone();
|
let thread_id = self.thread_id.clone();
|
||||||
tokio::spawn(async move {
|
spawn_codex_app_server_task(async move {
|
||||||
let mut threads = connection.inner.threads.lock().await;
|
let mut threads = connection.inner.threads.lock().await;
|
||||||
if let Some(entry) = threads.get_mut(&key) {
|
if let Some(entry) = threads.get_mut(&key) {
|
||||||
if entry.thread_id == thread_id {
|
if entry.thread_id == thread_id {
|
||||||
@@ -3601,7 +3607,7 @@ impl Drop for CodexTurnGuard {
|
|||||||
let connection = self.connection.clone();
|
let connection = self.connection.clone();
|
||||||
let thread_id = self.thread_id.clone();
|
let thread_id = self.thread_id.clone();
|
||||||
let turn_id = self.turn_id.clone();
|
let turn_id = self.turn_id.clone();
|
||||||
tokio::spawn(async move {
|
spawn_codex_app_server_task(async move {
|
||||||
connection.inner.turns.lock().await.remove(&turn_id);
|
connection.inner.turns.lock().await.remove(&turn_id);
|
||||||
connection.inner.turn_backlog.lock().await.remove(&turn_id);
|
connection.inner.turn_backlog.lock().await.remove(&turn_id);
|
||||||
let _ = connection
|
let _ = connection
|
||||||
@@ -4497,6 +4503,19 @@ mod tests {
|
|||||||
assert!(table.select(&key, None).is_err());
|
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 上
|
/// 注册键:前端传的项目路径与回合注册时的路径必须归一化成同一个键(Windows 上
|
||||||
/// `canonicalize` 会带 `\\?\` 前缀,去掉后两边才相等)。
|
/// `canonicalize` 会带 `\\?\` 前缀,去掉后两边才相等)。
|
||||||
#[test]
|
#[test]
|
||||||
@@ -4757,6 +4776,8 @@ mod tests {
|
|||||||
|
|
||||||
fn test_llm() -> GameCreatorLlmConfig {
|
fn test_llm() -> GameCreatorLlmConfig {
|
||||||
GameCreatorLlmConfig {
|
GameCreatorLlmConfig {
|
||||||
|
custom_enabled: false,
|
||||||
|
visible_models: Vec::new(),
|
||||||
api_key: "fixture-secret".to_string(),
|
api_key: "fixture-secret".to_string(),
|
||||||
base_url: "https://example.invalid/v1".to_string(),
|
base_url: "https://example.invalid/v1".to_string(),
|
||||||
model: "fixture-model".to_string(),
|
model: "fixture-model".to_string(),
|
||||||
@@ -5542,6 +5563,59 @@ mod tests {
|
|||||||
assert_ne!(command_token, provider_key);
|
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)]
|
#[cfg(unix)]
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn direct_project_spawn_restores_broker_token_after_environment_isolation() {
|
async fn direct_project_spawn_restores_broker_token_after_environment_isolation() {
|
||||||
|
|||||||
@@ -527,10 +527,6 @@ fn process_design_batch(
|
|||||||
let result = if uncertain {
|
let result = if uncertain {
|
||||||
Err("进程在工具执行期间中断,执行结果未保存。未重复执行;请读取实际工作区确认结果后再决定下一步。".to_string())
|
Err("进程在工具执行期间中断,执行结果未保存。未重复执行;请读取实际工作区确认结果后再决定下一步。".to_string())
|
||||||
} else {
|
} else {
|
||||||
let _write = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
|
|
||||||
root,
|
|
||||||
"design.tool",
|
|
||||||
)?;
|
|
||||||
execute_design_tool(root, resources, session, &call)
|
execute_design_tool(root, resources, session, &call)
|
||||||
};
|
};
|
||||||
let error = result
|
let error = result
|
||||||
@@ -1026,6 +1022,15 @@ pub(crate) async fn continue_design_agent_at(
|
|||||||
finish_design_command(root, resources, session, active, run, emit).await
|
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(
|
pub(crate) async fn decide_design_phase_at(
|
||||||
root: &Path,
|
root: &Path,
|
||||||
resources: &DesignResources,
|
resources: &DesignResources,
|
||||||
@@ -1058,7 +1063,8 @@ fn ensure_design_runtime_active(root: &Path) -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub(crate) fn hydrate_design_agent_session(
|
pub(crate) async fn hydrate_design_agent_session(
|
||||||
|
app: tauri::AppHandle,
|
||||||
project_path: String,
|
project_path: String,
|
||||||
) -> Result<Option<DesignView>, String> {
|
) -> Result<Option<DesignView>, String> {
|
||||||
let root = Path::new(project_path.trim());
|
let root = Path::new(project_path.trim());
|
||||||
@@ -1084,8 +1090,33 @@ pub(crate) fn hydrate_design_agent_session(
|
|||||||
if session.project_id != project_id {
|
if session.project_id != project_id {
|
||||||
return Err("策划会话与当前项目不匹配".into());
|
return Err("策划会话与当前项目不匹配".into());
|
||||||
}
|
}
|
||||||
let active = try_open_game_creator_agent_runtime_task_lock_file(root, DESIGN_ACTIVE_LOCK)?;
|
let Some(active) =
|
||||||
Ok(Some(design_view(&session, active.is_none())))
|
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)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn design_session_error_is_recoverable(error: &str) -> bool {
|
fn design_session_error_is_recoverable(error: &str) -> bool {
|
||||||
@@ -1958,4 +1989,94 @@ mod tests {
|
|||||||
.any(|message| message.text.contains("重试后继续")));
|
.any(|message| message.text.contains("重试后继续")));
|
||||||
assert!(next.session.last_error.is_none());
|
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,15 +321,31 @@ pub(crate) fn execute_design_file_tool(
|
|||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let mut matches = Vec::new();
|
let mut matches = Vec::new();
|
||||||
|
let mut edit_errors = Vec::new();
|
||||||
|
let mut valid_edits = 0;
|
||||||
for (index, (old, new)) in normalized.iter().enumerate() {
|
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();
|
let count = content.matches(old).count();
|
||||||
if count == 0 {
|
if count == 0 {
|
||||||
return Err(format!("edits[{index}] 原文未找到:{display}"));
|
edit_errors.push(format!(
|
||||||
|
"edits[{index}] 原文未找到:{}{}",
|
||||||
|
display,
|
||||||
|
design_patch_location_hint(&content, old)
|
||||||
|
));
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
if count != 1 {
|
if count != 1 {
|
||||||
return Err(format!(
|
let start = content.find(old).expect("count checked");
|
||||||
"edits[{index}] 原文匹配 {count} 处,必须唯一:{display}"
|
let line = design_patch_line_number(&content, start);
|
||||||
|
edit_errors.push(format!(
|
||||||
|
"edits[{index}] 原文匹配 {count} 处,必须唯一;首次位于第 {line} 行"
|
||||||
));
|
));
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
let start = content.find(old).expect("count checked");
|
let start = content.find(old).expect("count checked");
|
||||||
let end = start + old.len();
|
let end = start + old.len();
|
||||||
@@ -337,13 +353,33 @@ pub(crate) fn execute_design_file_tool(
|
|||||||
.iter()
|
.iter()
|
||||||
.find(|(_, other_start, other_end)| start < *other_end && *other_start < end)
|
.find(|(_, other_start, other_end)| start < *other_end && *other_start < end)
|
||||||
{
|
{
|
||||||
return Err(format!(
|
edit_errors.push(format!(
|
||||||
"edits[{index}] 与 edits[{other_index}] 修改范围重叠:{display}"
|
"edits[{index}] 与 edits[{other_index}] 修改范围重叠;请合并为一个 edit 或缩短 old_text"
|
||||||
));
|
));
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
matches.push((index, start, end));
|
matches.push((index, start, end));
|
||||||
|
valid_edits += 1;
|
||||||
let _ = new;
|
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();
|
let mut updated = content.clone();
|
||||||
for (index, start, end) in matches.into_iter().rev() {
|
for (index, start, end) in matches.into_iter().rev() {
|
||||||
let (_, new) = &normalized[index];
|
let (_, new) = &normalized[index];
|
||||||
@@ -396,6 +432,60 @@ 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(
|
pub(crate) fn list_design_workspace_files(
|
||||||
root: &Path,
|
root: &Path,
|
||||||
) -> Result<Vec<DesignWorkspaceEntry>, String> {
|
) -> Result<Vec<DesignWorkspaceEntry>, String> {
|
||||||
@@ -693,6 +783,22 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.expect_err("escape");
|
.expect_err("escape");
|
||||||
assert!(escaped.contains("路径"));
|
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(
|
let patched = execute_design_file_tool(
|
||||||
root,
|
root,
|
||||||
"patch_file",
|
"patch_file",
|
||||||
|
|||||||
@@ -5,12 +5,11 @@ mod validation;
|
|||||||
mod wire;
|
mod wire;
|
||||||
|
|
||||||
pub(crate) use model::{
|
pub(crate) use model::{
|
||||||
DirectCodexUserAttachmentReferencePart, DirectCodexUserContentPart, DirectCodexUserItem,
|
DirectCodexUserContentPart, DirectCodexUserItem, DirectCodexUserMessageEnvelope,
|
||||||
DirectCodexUserMessageEnvelope, DirectCodexUserMessageItem, DirectCodexUserRole,
|
DirectCodexUserMessageItem, DirectCodexUserRole, DirectCodexUserRuntimeRegionPart,
|
||||||
DirectCodexUserRuntimeRegionPart,
|
|
||||||
};
|
};
|
||||||
pub(crate) use validation::validate_direct_codex_user_item;
|
pub(crate) use validation::validate_direct_codex_user_item;
|
||||||
pub(crate) use wire::{
|
pub(crate) use wire::{
|
||||||
direct_codex_user_item_to_codex_turn_input, direct_codex_user_item_to_prompt,
|
direct_codex_user_item_to_prompt, direct_codex_user_item_to_response_item,
|
||||||
direct_codex_user_item_to_response_item, direct_codex_user_item_to_wire_input,
|
direct_codex_user_item_to_wire_input,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -34,25 +34,8 @@ pub(crate) enum DirectCodexUserContentPart {
|
|||||||
InputText { text: String },
|
InputText { text: String },
|
||||||
#[serde(rename = "agc_resource_reference")]
|
#[serde(rename = "agc_resource_reference")]
|
||||||
AgcResourceReference { resource_id: String },
|
AgcResourceReference { resource_id: String },
|
||||||
#[serde(rename = "agc_skill_reference")]
|
|
||||||
AgcSkillReference { name: String },
|
|
||||||
#[serde(rename = "agc_runtime_region_reference")]
|
#[serde(rename = "agc_runtime_region_reference")]
|
||||||
AgcRuntimeRegionReference(DirectCodexUserRuntimeRegionPart),
|
AgcRuntimeRegionReference(DirectCodexUserRuntimeRegionPart),
|
||||||
/// Uploaded project attachment kept inline in canonical content.
|
|
||||||
#[serde(rename = "agc_attachment_reference")]
|
|
||||||
AgcAttachmentReference(DirectCodexUserAttachmentReferencePart),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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 DirectCodexUserAttachmentReferencePart {
|
|
||||||
pub(crate) name: String,
|
|
||||||
pub(crate) media_type: String,
|
|
||||||
#[ts(type = "number")]
|
|
||||||
pub(crate) size: u64,
|
|
||||||
pub(crate) local_path: String,
|
|
||||||
pub(crate) status: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
|
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
|
||||||
|
|||||||
+2
-28
@@ -12,7 +12,7 @@ pub(crate) const MAX_DIRECT_CODEX_REFERENCES: usize = 32;
|
|||||||
pub(crate) fn validate_direct_codex_user_item(
|
pub(crate) fn validate_direct_codex_user_item(
|
||||||
root: &Path,
|
root: &Path,
|
||||||
item: &DirectCodexUserItem,
|
item: &DirectCodexUserItem,
|
||||||
) -> Result<GameCreationAppManifest, String> {
|
) -> Result<(), String> {
|
||||||
let DirectCodexUserItem::Message(message) = item;
|
let DirectCodexUserItem::Message(message) = item;
|
||||||
if !matches!(message.role, DirectCodexUserRole::User) {
|
if !matches!(message.role, DirectCodexUserRole::User) {
|
||||||
return Err("DirectProject 只接受 user message item".to_string());
|
return Err("DirectProject 只接受 user message item".to_string());
|
||||||
@@ -36,42 +36,16 @@ pub(crate) fn validate_direct_codex_user_item(
|
|||||||
reference_count = reference_count.saturating_add(1);
|
reference_count = reference_count.saturating_add(1);
|
||||||
validate_resource_id_and_manifest(&manifest, resource_id)?;
|
validate_resource_id_and_manifest(&manifest, resource_id)?;
|
||||||
}
|
}
|
||||||
DirectCodexUserContentPart::AgcSkillReference { name } => {
|
|
||||||
let name = name.trim();
|
|
||||||
if name.is_empty()
|
|
||||||
|| name.chars().count() > 120
|
|
||||||
|| matches!(name, "." | "..")
|
|
||||||
|| name.chars().any(|character| {
|
|
||||||
character.is_control()
|
|
||||||
|| character.is_whitespace()
|
|
||||||
|| matches!(character, '/' | '\\' | ':' | '$')
|
|
||||||
})
|
|
||||||
{
|
|
||||||
return Err("引用的 Skill 名称无效,请移除后重新选择".to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => {
|
DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => {
|
||||||
reference_count = reference_count.saturating_add(1);
|
reference_count = reference_count.saturating_add(1);
|
||||||
validate_runtime_region_reference(&manifest, reference)?;
|
validate_runtime_region_reference(&manifest, reference)?;
|
||||||
}
|
}
|
||||||
DirectCodexUserContentPart::AgcAttachmentReference(reference) => {
|
|
||||||
if reference.name.trim().is_empty() {
|
|
||||||
return Err("附件缺少文件名".to_string());
|
|
||||||
}
|
|
||||||
if !reference.local_path.trim().is_empty() {
|
|
||||||
sanitize_attachment_local_path(&reference.local_path)
|
|
||||||
.ok_or_else(|| "附件项目路径无效".to_string())?;
|
|
||||||
}
|
|
||||||
if !matches!(reference.status.trim(), "imported" | "failed") {
|
|
||||||
return Err("附件状态无效".to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if reference_count > MAX_DIRECT_CODEX_REFERENCES {
|
if reference_count > MAX_DIRECT_CODEX_REFERENCES {
|
||||||
return Err(format!("一次最多引用 {MAX_DIRECT_CODEX_REFERENCES} 个素材"));
|
return Err(format!("一次最多引用 {MAX_DIRECT_CODEX_REFERENCES} 个素材"));
|
||||||
}
|
}
|
||||||
Ok(manifest)
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn validate_resource_id_and_manifest(
|
pub(crate) fn validate_resource_id_and_manifest(
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
use super::model::{
|
use super::model::{DirectCodexUserContentPart, DirectCodexUserItem};
|
||||||
DirectCodexUserContentPart, DirectCodexUserItem, DirectCodexUserRuntimeRegionPart,
|
|
||||||
};
|
|
||||||
use super::validation::validate_direct_codex_user_item;
|
use super::validation::validate_direct_codex_user_item;
|
||||||
use crate::agent::{
|
use crate::agent::{read_manifest_for_project, sanitize_attachment_local_path};
|
||||||
read_manifest_for_project, sanitize_attachment_local_path, GameCreationAppManifest,
|
|
||||||
};
|
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
@@ -55,47 +51,6 @@ fn direct_codex_user_item_to_response_content(
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resource_reference_summary(
|
|
||||||
manifest: &GameCreationAppManifest,
|
|
||||||
resource_id: &str,
|
|
||||||
) -> Result<String, String> {
|
|
||||||
let resource_id = resource_id.trim();
|
|
||||||
let asset = manifest
|
|
||||||
.assets
|
|
||||||
.iter()
|
|
||||||
.find(|asset| asset.id == resource_id)
|
|
||||||
.ok_or_else(|| "引用的素材已不存在,请移除后重新选择".to_string())?;
|
|
||||||
let path = sanitize_attachment_local_path(&asset.local_path)
|
|
||||||
.ok_or_else(|| "引用的素材路径无效,请移除后重新选择".to_string())?;
|
|
||||||
Ok(format!(
|
|
||||||
"[素材引用 resourceId={resource_id};项目路径={path}]"
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn runtime_region_summary(reference: &DirectCodexUserRuntimeRegionPart) -> String {
|
|
||||||
let resources = reference
|
|
||||||
.resource_ids
|
|
||||||
.iter()
|
|
||||||
.map(|id| id.trim())
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(",");
|
|
||||||
let mut summary = format!("[运行画面区域:名称={} ", reference.label.trim());
|
|
||||||
if let Some(run_id) = reference.run_id.as_deref() {
|
|
||||||
summary.push_str(&format!("运行标识={} ", run_id.trim()));
|
|
||||||
}
|
|
||||||
if let Some(role) = reference.element_role.as_deref() {
|
|
||||||
summary.push_str(&format!("角色={} ", role.trim()));
|
|
||||||
}
|
|
||||||
if let Some(text) = reference.text.as_deref() {
|
|
||||||
summary.push_str(&format!("文本={} ", text.trim()));
|
|
||||||
}
|
|
||||||
if !resources.is_empty() {
|
|
||||||
summary.push_str(&format!("关联素材={resources}"));
|
|
||||||
}
|
|
||||||
summary.push(']');
|
|
||||||
summary
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 将 canonical user item 转为 app-server `turn/start.input` 可接受的文本数组。
|
/// 将 canonical user item 转为 app-server `turn/start.input` 可接受的文本数组。
|
||||||
/// AGC 私有 part 只在这里投影为安全摘要,canonical item 本身不被修改。
|
/// AGC 私有 part 只在这里投影为安全摘要,canonical item 本身不被修改。
|
||||||
pub(crate) fn direct_codex_user_item_to_wire_input(
|
pub(crate) fn direct_codex_user_item_to_wire_input(
|
||||||
@@ -110,25 +65,38 @@ pub(crate) fn direct_codex_user_item_to_wire_input(
|
|||||||
let text = match part {
|
let text = match part {
|
||||||
DirectCodexUserContentPart::InputText { text } => text.clone(),
|
DirectCodexUserContentPart::InputText { text } => text.clone(),
|
||||||
DirectCodexUserContentPart::AgcResourceReference { resource_id } => {
|
DirectCodexUserContentPart::AgcResourceReference { resource_id } => {
|
||||||
resource_reference_summary(&manifest, resource_id)?
|
let asset = manifest
|
||||||
}
|
.assets
|
||||||
DirectCodexUserContentPart::AgcSkillReference { name } => {
|
.iter()
|
||||||
format!("${}", name.trim())
|
.find(|asset| asset.id == resource_id.trim())
|
||||||
|
.ok_or_else(|| "引用的素材已不存在,请移除后重新选择".to_string())?;
|
||||||
|
let path = sanitize_attachment_local_path(&asset.local_path)
|
||||||
|
.ok_or_else(|| "引用的素材路径无效,请移除后重新选择".to_string())?;
|
||||||
|
format!(
|
||||||
|
"[素材引用 resourceId={};项目路径={path}]",
|
||||||
|
resource_id.trim()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => {
|
DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => {
|
||||||
runtime_region_summary(reference)
|
let resources = reference
|
||||||
}
|
.resource_ids
|
||||||
DirectCodexUserContentPart::AgcAttachmentReference(reference) => {
|
.iter()
|
||||||
let mut summary = format!(
|
.map(|id| id.trim())
|
||||||
"[附件:名称={};类型={};大小={} 字节",
|
.collect::<Vec<_>>()
|
||||||
reference.name.trim(),
|
.join(",");
|
||||||
reference.media_type.trim(),
|
let mut summary = format!("[运行画面区域:名称={} ", reference.label.trim());
|
||||||
reference.size
|
if let Some(run_id) = reference.run_id.as_deref() {
|
||||||
);
|
summary.push_str(&format!("运行标识={} ", run_id.trim()));
|
||||||
if !reference.local_path.trim().is_empty() {
|
}
|
||||||
summary.push_str(&format!(";项目路径={}", reference.local_path.trim()));
|
if let Some(role) = reference.element_role.as_deref() {
|
||||||
|
summary.push_str(&format!("角色={} ", role.trim()));
|
||||||
|
}
|
||||||
|
if let Some(text) = reference.text.as_deref() {
|
||||||
|
summary.push_str(&format!("文本={} ", text.trim()));
|
||||||
|
}
|
||||||
|
if !resources.is_empty() {
|
||||||
|
summary.push_str(&format!("关联素材={resources}"));
|
||||||
}
|
}
|
||||||
summary.push_str(&format!(";状态={}", reference.status.trim()));
|
|
||||||
summary.push(']');
|
summary.push(']');
|
||||||
summary
|
summary
|
||||||
}
|
}
|
||||||
@@ -138,66 +106,6 @@ pub(crate) fn direct_codex_user_item_to_wire_input(
|
|||||||
Ok(Value::Array(input))
|
Ok(Value::Array(input))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn direct_codex_user_item_to_codex_turn_input(
|
|
||||||
root: &Path,
|
|
||||||
item: &DirectCodexUserItem,
|
|
||||||
skill_roots: &[std::path::PathBuf],
|
|
||||||
) -> Result<Value, String> {
|
|
||||||
let manifest = validate_direct_codex_user_item(root, item)?;
|
|
||||||
let DirectCodexUserItem::Message(message) = item;
|
|
||||||
let mut input = Vec::with_capacity(message.content.len());
|
|
||||||
for part in &message.content {
|
|
||||||
match part {
|
|
||||||
DirectCodexUserContentPart::InputText { text } => {
|
|
||||||
input.push(serde_json::json!({ "type": "text", "text": text }));
|
|
||||||
}
|
|
||||||
DirectCodexUserContentPart::AgcResourceReference { resource_id } => {
|
|
||||||
input.push(serde_json::json!({
|
|
||||||
"type": "text",
|
|
||||||
"text": resource_reference_summary(&manifest, resource_id)?,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
DirectCodexUserContentPart::AgcSkillReference { name } => {
|
|
||||||
let name = name.trim();
|
|
||||||
let path = skill_roots
|
|
||||||
.iter()
|
|
||||||
.map(|root| root.join(name).join("SKILL.md"))
|
|
||||||
.find(|path| path.is_file())
|
|
||||||
.ok_or_else(|| "引用的 Skill 当前不可用,请重新选择".to_string())?;
|
|
||||||
input.push(serde_json::json!({
|
|
||||||
"type": "skill",
|
|
||||||
"name": name,
|
|
||||||
"path": path,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => {
|
|
||||||
input.push(serde_json::json!({
|
|
||||||
"type": "text",
|
|
||||||
"text": runtime_region_summary(reference),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
DirectCodexUserContentPart::AgcAttachmentReference(reference) => {
|
|
||||||
let mut summary = format!(
|
|
||||||
"[附件:名称={};类型={};大小={} 字节",
|
|
||||||
reference.name.trim(),
|
|
||||||
reference.media_type.trim(),
|
|
||||||
reference.size
|
|
||||||
);
|
|
||||||
if !reference.local_path.trim().is_empty() {
|
|
||||||
summary.push_str(&format!(";项目路径={}", reference.local_path.trim()));
|
|
||||||
}
|
|
||||||
summary.push_str(&format!(";状态={}", reference.status.trim()));
|
|
||||||
summary.push(']');
|
|
||||||
input.push(serde_json::json!({
|
|
||||||
"type": "text",
|
|
||||||
"text": summary,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(Value::Array(input))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn direct_codex_user_item_to_prompt(
|
pub(crate) fn direct_codex_user_item_to_prompt(
|
||||||
root: &Path,
|
root: &Path,
|
||||||
item: &DirectCodexUserItem,
|
item: &DirectCodexUserItem,
|
||||||
@@ -266,26 +174,4 @@ mod tests {
|
|||||||
.expect_err("history item without type must fail");
|
.expect_err("history item without type must fail");
|
||||||
assert!(error.contains("缺少 type"), "{error}");
|
assert!(error.contains("缺少 type"), "{error}");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn attachment_parts_remain_in_canonical_order_when_projected() {
|
|
||||||
let root = tempfile::tempdir().expect("temp project");
|
|
||||||
crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试")
|
|
||||||
.expect("init project");
|
|
||||||
let item = json!({
|
|
||||||
"type": "message",
|
|
||||||
"role": "user",
|
|
||||||
"id": "turn-1:user",
|
|
||||||
"content": [
|
|
||||||
{"type": "input_text", "text": "先看"},
|
|
||||||
{"type": "agc_attachment_reference", "name": "notes.txt", "mediaType": "text/plain", "size": 4, "localPath": "assets/notes.txt", "status": "imported"}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
let projected = direct_codex_user_item_to_response_item(root.path(), &item)
|
|
||||||
.expect("user response item should project");
|
|
||||||
let content = projected["content"].as_array().expect("content array");
|
|
||||||
assert_eq!(content.len(), 2);
|
|
||||||
assert!(content[0]["text"].as_str().unwrap().contains("先看"));
|
|
||||||
assert!(content[1]["text"].as_str().unwrap().contains("notes.txt"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -31,9 +31,10 @@ pub(crate) fn normalize_direct_client_turn_id(
|
|||||||
pub(crate) async fn chat_with_game_creator_direct_codex(
|
pub(crate) async fn chat_with_game_creator_direct_codex(
|
||||||
project_path: String,
|
project_path: String,
|
||||||
prompt: String,
|
prompt: String,
|
||||||
user_item: DirectCodexUserItem,
|
mut user_item: DirectCodexUserItem,
|
||||||
creation_type: Option<String>,
|
creation_type: Option<String>,
|
||||||
client_turn_id: Option<String>,
|
client_turn_id: Option<String>,
|
||||||
|
attachments: Option<Vec<DirectCodexTurnAttachment>>,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
let root = Path::new(project_path.trim());
|
let root = Path::new(project_path.trim());
|
||||||
let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?;
|
let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?;
|
||||||
@@ -42,7 +43,24 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
|
|||||||
redact_agent_runtime_error(root, &format!("恢复上一轮陶泥儿整包事务失败:{error}"), 500)
|
redact_agent_runtime_error(root, &format!("恢复上一轮陶泥儿整包事务失败:{error}"), 500)
|
||||||
})?;
|
})?;
|
||||||
let turn_emitter = DirectGameCreatorTurnUpdateEmitter::new(root, turn_id.clone());
|
let turn_emitter = DirectGameCreatorTurnUpdateEmitter::new(root, turn_id.clone());
|
||||||
let mut audit = DirectCodexTurnAudit::start(root, &turn_id, &prompt, &[]);
|
let mut audit = DirectCodexTurnAudit::start(
|
||||||
|
root,
|
||||||
|
&turn_id,
|
||||||
|
&prompt,
|
||||||
|
attachments.as_deref().unwrap_or_default(),
|
||||||
|
);
|
||||||
|
let attachments = attachments.unwrap_or_default();
|
||||||
|
if !attachments.is_empty() {
|
||||||
|
let attachment_context =
|
||||||
|
render_direct_codex_user_prompt("", &attachments).map_err(|error| {
|
||||||
|
audit.finish(false);
|
||||||
|
error
|
||||||
|
})?;
|
||||||
|
let DirectCodexUserItem::Message(message) = &mut user_item;
|
||||||
|
message.content.push(DirectCodexUserContentPart::InputText {
|
||||||
|
text: attachment_context,
|
||||||
|
});
|
||||||
|
}
|
||||||
validate_direct_codex_user_item(root, &user_item).map_err(|error| {
|
validate_direct_codex_user_item(root, &user_item).map_err(|error| {
|
||||||
audit.finish(false);
|
audit.finish(false);
|
||||||
error
|
error
|
||||||
|
|||||||
@@ -1887,7 +1887,7 @@ async fn bridge_create_or_derive_resource(
|
|||||||
|
|
||||||
async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Value) -> Value {
|
async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Value) -> Value {
|
||||||
let result = async {
|
let result = async {
|
||||||
bridge_reject_unknown_fields(arguments, &["sourceLocalAssetId", "assetName"])?;
|
super::direct_tools_mcp::validate_remove_background_arguments(arguments)?;
|
||||||
enforce_project_permission_policy(&state.root, "canvas.asset_generate")?;
|
enforce_project_permission_policy(&state.root, "canvas.asset_generate")?;
|
||||||
enforce_project_permission_policy(&state.root, "asset.register")?;
|
enforce_project_permission_policy(&state.root, "asset.register")?;
|
||||||
let source_asset_id = bridge_bounded_string(arguments, "sourceLocalAssetId", 80)?;
|
let source_asset_id = bridge_bounded_string(arguments, "sourceLocalAssetId", 80)?;
|
||||||
@@ -1896,6 +1896,8 @@ async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Val
|
|||||||
"assetName",
|
"assetName",
|
||||||
DIRECT_TOOL_BRIDGE_MAX_RESOURCE_NAME_CHARS,
|
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 manifest = read_existing_manifest_for_project(&state.root)?;
|
||||||
let source_asset = manifest
|
let source_asset = manifest
|
||||||
.assets
|
.assets
|
||||||
@@ -1920,22 +1922,34 @@ async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Val
|
|||||||
.map_err(|_| "创建抠图服务连接失败".to_string())?;
|
.map_err(|_| "创建抠图服务连接失败".to_string())?;
|
||||||
let context =
|
let context =
|
||||||
prepare_external_canvas_generation_context(&state.root, &client, &access).await?;
|
prepare_external_canvas_generation_context(&state.root, &client, &access).await?;
|
||||||
let fingerprint = format!("{}\0{}", source_asset_id, asset_name);
|
let fingerprint = background_removal_request_fingerprint(
|
||||||
|
&source_asset_id,
|
||||||
|
&asset_name,
|
||||||
|
background_mode,
|
||||||
|
screen_color,
|
||||||
|
);
|
||||||
let (_operation_id, idempotency_key) = state.resource_request_ids(&fingerprint)?;
|
let (_operation_id, idempotency_key) = state.resource_request_ids(&fingerprint)?;
|
||||||
let route = "/api/external/v1/editor/images/background-removals";
|
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(
|
let response = crate::http_client::with_agc_main_site_marker(
|
||||||
client
|
client
|
||||||
.post(format!("{}{}", api_base_url, route))
|
.post(format!("{}{}", api_base_url, route))
|
||||||
.bearer_auth(api_key)
|
.bearer_auth(api_key)
|
||||||
.header("Idempotency-Key", idempotency_key)
|
.header("Idempotency-Key", idempotency_key)
|
||||||
.json(&json!({
|
.json(&request_body),
|
||||||
"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()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -1972,6 +1986,20 @@ 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 {
|
fn bridge_safe_queue_state(value: Value) -> Value {
|
||||||
let object = value.as_object();
|
let object = value.as_object();
|
||||||
json!({
|
json!({
|
||||||
@@ -2703,6 +2731,29 @@ pub(crate) async fn start_direct_tool_bridge(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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 super::*;
|
||||||
use std::io::{Cursor, Read, Write};
|
use std::io::{Cursor, Read, Write};
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ struct ExternalMcpHttpState {
|
|||||||
root: PathBuf,
|
root: PathBuf,
|
||||||
token: String,
|
token: String,
|
||||||
session_user_id: String,
|
session_user_id: String,
|
||||||
session_generation: u64,
|
session_identity_generation: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn direct_tools_mcp_mode_requested(args: &[String]) -> bool {
|
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!({
|
json!({
|
||||||
"name": "agc_remove_background",
|
"name": "agc_remove_background",
|
||||||
"description": "为当前项目已登记的图片资源去除背景。客户端使用当前登录账号的抠图服务、项目画布和素材目录,模型只能提供已登记资源身份与结果名称;不会返回 Token、内部路由、宿主路径或临时签名 URL。",
|
"description": "为当前项目已登记的图片资源去除背景。complex 通过语义分割识别前景;flat 用于纯色背景抠图,确定背景为纯色时优先选择 flat。提供资源身份、结果名称及可选模式和背景色;客户端管理登录、项目画布和素材目录,不返回 Token、内部路由、宿主路径或临时签名 URL。",
|
||||||
"inputSchema": {
|
"inputSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -449,6 +449,16 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"minLength": 1,
|
"minLength": 1,
|
||||||
"maxLength": DIRECT_TOOLS_MCP_MAX_RESOURCE_NAME_CHARS
|
"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"],
|
"required": ["sourceLocalAssetId", "assetName"],
|
||||||
@@ -881,14 +891,46 @@ fn validate_resource_generation_arguments(arguments: &Value) -> Result<(), Strin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_remove_background_arguments(arguments: &Value) -> Result<(), String> {
|
pub(super) fn validate_remove_background_arguments(arguments: &Value) -> Result<(), String> {
|
||||||
validate_tool_object_fields(arguments, &["sourceLocalAssetId", "assetName"])?;
|
validate_tool_object_fields(
|
||||||
|
arguments,
|
||||||
|
&[
|
||||||
|
"sourceLocalAssetId",
|
||||||
|
"assetName",
|
||||||
|
"backgroundMode",
|
||||||
|
"screenColor",
|
||||||
|
],
|
||||||
|
)?;
|
||||||
bounded_tool_string(arguments, "sourceLocalAssetId", 80)?;
|
bounded_tool_string(arguments, "sourceLocalAssetId", 80)?;
|
||||||
bounded_tool_string(
|
bounded_tool_string(
|
||||||
arguments,
|
arguments,
|
||||||
"assetName",
|
"assetName",
|
||||||
DIRECT_TOOLS_MCP_MAX_RESOURCE_NAME_CHARS,
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1241,7 +1283,8 @@ fn external_mcp_session_id(root: &Path) -> String {
|
|||||||
material.push('\0');
|
material.push('\0');
|
||||||
material.push_str(&session.user_id);
|
material.push_str(&session.user_id);
|
||||||
material.push('\0');
|
material.push('\0');
|
||||||
material.push_str(&session.generation.to_string());
|
// 用身份代次而不是 token:同一账号续期不得让 MCP 会话身份漂移。
|
||||||
|
material.push_str(&session.identity_generation.to_string());
|
||||||
}
|
}
|
||||||
format!("mcp-{:x}", Sha256::digest(material.as_bytes()))
|
format!("mcp-{:x}", Sha256::digest(material.as_bytes()))
|
||||||
}
|
}
|
||||||
@@ -1759,7 +1802,9 @@ async fn handle_external_mcp_http_request(
|
|||||||
let Some(session) = current_platform_session() else {
|
let Some(session) = current_platform_session() else {
|
||||||
return Err(StatusCode::UNAUTHORIZED);
|
return Err(StatusCode::UNAUTHORIZED);
|
||||||
};
|
};
|
||||||
if session.user_id != state.session_user_id || session.generation != state.session_generation {
|
if session.user_id != state.session_user_id
|
||||||
|
|| session.identity_generation != state.session_identity_generation
|
||||||
|
{
|
||||||
return Err(StatusCode::UNAUTHORIZED);
|
return Err(StatusCode::UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
let response = EXTERNAL_MCP_BRIDGE_URL
|
let response = EXTERNAL_MCP_BRIDGE_URL
|
||||||
@@ -1794,7 +1839,7 @@ pub(crate) async fn start_external_mcp_loopback(
|
|||||||
root,
|
root,
|
||||||
token: token.clone(),
|
token: token.clone(),
|
||||||
session_user_id: session.user_id,
|
session_user_id: session.user_id,
|
||||||
session_generation: session.generation,
|
session_identity_generation: session.identity_generation,
|
||||||
};
|
};
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route(&route, post(handle_external_mcp_http_request))
|
.route(&route, post(handle_external_mcp_http_request))
|
||||||
@@ -1846,6 +1891,51 @@ pub(crate) fn stop_game_creator_external_mcp() -> Result<(), String> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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"))]
|
#[cfg(all(windows, feature = "cocos-editor-execute"))]
|
||||||
#[test]
|
#[test]
|
||||||
fn builtin_mcp_process_probe() {
|
fn builtin_mcp_process_probe() {
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ use super::external_generation_state::{
|
|||||||
retain_platform_art_generation_runtime_accepted_result, PlatformArtGenerationRuntimeState,
|
retain_platform_art_generation_runtime_accepted_result, PlatformArtGenerationRuntimeState,
|
||||||
};
|
};
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::platform_session::{
|
||||||
|
acquire_platform_session_identity_lease, validate_platform_session_identity,
|
||||||
|
PlatformSessionIdentity,
|
||||||
|
};
|
||||||
use reqwest::multipart::{Form, Part};
|
use reqwest::multipart::{Form, Part};
|
||||||
|
|
||||||
const EXTERNAL_GENERATION_POLL_TIMEOUT: Duration = Duration::from_secs(35 * 60);
|
const EXTERNAL_GENERATION_POLL_TIMEOUT: Duration = Duration::from_secs(35 * 60);
|
||||||
@@ -1510,10 +1514,7 @@ struct PreparedPlatformArtAssetSlice {
|
|||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct PreparedPlatformSessionFence {
|
struct PreparedPlatformSessionFence {
|
||||||
user_id: String,
|
identity: PlatformSessionIdentity,
|
||||||
api_base_url: String,
|
|
||||||
generation: u64,
|
|
||||||
access_token_sha256: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PreparedPlatformSessionFence {
|
impl PreparedPlatformSessionFence {
|
||||||
@@ -1521,41 +1522,17 @@ impl PreparedPlatformSessionFence {
|
|||||||
access
|
access
|
||||||
.frozen_platform_session()
|
.frozen_platform_session()
|
||||||
.map(|session| PreparedPlatformSessionFence {
|
.map(|session| PreparedPlatformSessionFence {
|
||||||
user_id: session.user_id.clone(),
|
identity: session.identity(),
|
||||||
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> {
|
fn validate(&self) -> Result<(), String> {
|
||||||
let matches = current_platform_session().is_some_and(|session| {
|
// 只比较身份:同一账号的 access token 轮换不得让在途生成 operation 失败。
|
||||||
session.user_id == self.user_id
|
validate_platform_session_identity(&self.identity)
|
||||||
&& 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> {
|
fn acquire_lease(&self) -> Result<ValidatedPlatformSessionLease, String> {
|
||||||
acquire_validated_platform_session_fingerprint(
|
acquire_platform_session_identity_lease(&self.identity)
|
||||||
&self.user_id,
|
|
||||||
&self.api_base_url,
|
|
||||||
self.generation,
|
|
||||||
&self.access_token_sha256,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -10636,7 +10613,7 @@ mod canvas_generation_tests {
|
|||||||
}
|
}
|
||||||
drop(owner_a_access);
|
drop(owner_a_access);
|
||||||
drop(frozen_owner_a);
|
drop(frozen_owner_a);
|
||||||
install_platform_session("durable-owner-b", "durable-owner-b-token", &base_url, 2)
|
install_platform_session("durable-owner-b", "durable-owner-b-token", &base_url, 2, 2)
|
||||||
.expect("switch to owner B");
|
.expect("switch to owner B");
|
||||||
|
|
||||||
let error = match request_platform_art_asset_with_runtime_options_at(
|
let error = match request_platform_art_asset_with_runtime_options_at(
|
||||||
@@ -10761,8 +10738,14 @@ mod canvas_generation_tests {
|
|||||||
.recv_timeout(Duration::from_secs(3))
|
.recv_timeout(Duration::from_secs(3))
|
||||||
.expect("wait for accepted response");
|
.expect("wait for accepted response");
|
||||||
std::thread::sleep(Duration::from_millis(50));
|
std::thread::sleep(Duration::from_millis(50));
|
||||||
install_platform_session("post-202-user-b", "post-202-token-b", &switch_base_url, 2)
|
install_platform_session(
|
||||||
.expect("switch platform account after accepted response");
|
"post-202-user-b",
|
||||||
|
"post-202-token-b",
|
||||||
|
&switch_base_url,
|
||||||
|
2,
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
.expect("switch platform account after accepted response");
|
||||||
});
|
});
|
||||||
let runtime_context = PlatformArtGenerationRuntimeContext {
|
let runtime_context = PlatformArtGenerationRuntimeContext {
|
||||||
agent_id: "art-director".to_string(),
|
agent_id: "art-director".to_string(),
|
||||||
|
|||||||
+2
-1
@@ -1431,12 +1431,13 @@ mod external_generation_state_tests {
|
|||||||
base_url,
|
base_url,
|
||||||
);
|
);
|
||||||
let frozen_a = current_platform_session().expect("freeze owner A");
|
let frozen_a = current_platform_session().expect("freeze owner A");
|
||||||
validate_platform_session_snapshot(&frozen_a).expect("owner A is current before switch");
|
validate_frozen_platform_session(&frozen_a).expect("owner A is current before switch");
|
||||||
replace_platform_session_for_gui_owner(
|
replace_platform_session_for_gui_owner(
|
||||||
"fingerprint-owner-b",
|
"fingerprint-owner-b",
|
||||||
"fingerprint-token-b",
|
"fingerprint-token-b",
|
||||||
base_url,
|
base_url,
|
||||||
2,
|
2,
|
||||||
|
2,
|
||||||
)
|
)
|
||||||
.expect("switch global session to owner B");
|
.expect("switch global session to owner B");
|
||||||
let current_b = current_platform_session().expect("owner B is current after switch");
|
let current_b = current_platform_session().expect("owner B is current after switch");
|
||||||
|
|||||||
+18
@@ -605,6 +605,8 @@ fn finalization_cleanup_closes_entire_tool_plan_repair_chain_before_removal() {
|
|||||||
response_stream_fixture("finalization-tool-plan-repair-chain-run");
|
response_stream_fixture("finalization-tool-plan-repair-chain-run");
|
||||||
let root = project.path();
|
let root = project.path();
|
||||||
let llm = GameCreatorLlmConfig {
|
let llm = GameCreatorLlmConfig {
|
||||||
|
custom_enabled: false,
|
||||||
|
visible_models: Vec::new(),
|
||||||
api_key: "finalization-tool-plan-key".to_string(),
|
api_key: "finalization-tool-plan-key".to_string(),
|
||||||
base_url: "http://127.0.0.1:1/v1".to_string(),
|
base_url: "http://127.0.0.1:1/v1".to_string(),
|
||||||
model: "finalization-tool-plan-model".to_string(),
|
model: "finalization-tool-plan-model".to_string(),
|
||||||
@@ -968,6 +970,8 @@ async fn provider_handoff_identity_drift_closes_lifecycle_without_leaking_respon
|
|||||||
let root = project.path();
|
let root = project.path();
|
||||||
let request = LlmRunRequest::new(vec![LlmMessage::user("验证 handoff 身份漂移")]);
|
let request = LlmRunRequest::new(vec![LlmMessage::user("验证 handoff 身份漂移")]);
|
||||||
let old_llm = GameCreatorLlmConfig {
|
let old_llm = GameCreatorLlmConfig {
|
||||||
|
custom_enabled: false,
|
||||||
|
visible_models: Vec::new(),
|
||||||
api_key: "old-provider-key".to_string(),
|
api_key: "old-provider-key".to_string(),
|
||||||
base_url: "http://127.0.0.1:1/v1".to_string(),
|
base_url: "http://127.0.0.1:1/v1".to_string(),
|
||||||
model: "old-provider-model".to_string(),
|
model: "old-provider-model".to_string(),
|
||||||
@@ -1073,6 +1077,8 @@ async fn tool_plan_handoff_identity_drift_closes_entire_repair_chain_before_remo
|
|||||||
LlmMessage::user("修复格式"),
|
LlmMessage::user("修复格式"),
|
||||||
]);
|
]);
|
||||||
let old_llm = GameCreatorLlmConfig {
|
let old_llm = GameCreatorLlmConfig {
|
||||||
|
custom_enabled: false,
|
||||||
|
visible_models: Vec::new(),
|
||||||
api_key: "old-tool-plan-provider-key".to_string(),
|
api_key: "old-tool-plan-provider-key".to_string(),
|
||||||
base_url: "http://127.0.0.1:1/v1".to_string(),
|
base_url: "http://127.0.0.1:1/v1".to_string(),
|
||||||
model: "old-tool-plan-model".to_string(),
|
model: "old-tool-plan-model".to_string(),
|
||||||
@@ -1192,6 +1198,8 @@ async fn generic_retry_identity_drift_closes_tool_plan_repair_chain_before_remov
|
|||||||
response_stream_fixture("generic-retry-drift-tool-plan-chain-run");
|
response_stream_fixture("generic-retry-drift-tool-plan-chain-run");
|
||||||
let root = project.path();
|
let root = project.path();
|
||||||
let old_llm = GameCreatorLlmConfig {
|
let old_llm = GameCreatorLlmConfig {
|
||||||
|
custom_enabled: false,
|
||||||
|
visible_models: Vec::new(),
|
||||||
api_key: "old-generic-retry-key".to_string(),
|
api_key: "old-generic-retry-key".to_string(),
|
||||||
base_url: "http://127.0.0.1:1/v1".to_string(),
|
base_url: "http://127.0.0.1:1/v1".to_string(),
|
||||||
model: "old-generic-retry-model".to_string(),
|
model: "old-generic-retry-model".to_string(),
|
||||||
@@ -1277,6 +1285,8 @@ async fn tool_plan_capacity_gate_runs_before_provider_lifecycle_and_network() {
|
|||||||
response_stream_fixture("tool-plan-capacity-preflight-run");
|
response_stream_fixture("tool-plan-capacity-preflight-run");
|
||||||
let root = project.path();
|
let root = project.path();
|
||||||
let llm = GameCreatorLlmConfig {
|
let llm = GameCreatorLlmConfig {
|
||||||
|
custom_enabled: false,
|
||||||
|
visible_models: Vec::new(),
|
||||||
api_key: "tool-plan-capacity-key".to_string(),
|
api_key: "tool-plan-capacity-key".to_string(),
|
||||||
base_url: "http://127.0.0.1:1/v1".to_string(),
|
base_url: "http://127.0.0.1:1/v1".to_string(),
|
||||||
model: "tool-plan-capacity-model".to_string(),
|
model: "tool-plan-capacity-model".to_string(),
|
||||||
@@ -1400,6 +1410,8 @@ async fn tool_plan_handoff_durable_control_closes_entire_repair_chain_before_rem
|
|||||||
LlmMessage::user("修复格式"),
|
LlmMessage::user("修复格式"),
|
||||||
]);
|
]);
|
||||||
let llm = GameCreatorLlmConfig {
|
let llm = GameCreatorLlmConfig {
|
||||||
|
custom_enabled: false,
|
||||||
|
visible_models: Vec::new(),
|
||||||
api_key: "durable-control-tool-plan-key".to_string(),
|
api_key: "durable-control-tool-plan-key".to_string(),
|
||||||
base_url: "http://127.0.0.1:1/v1".to_string(),
|
base_url: "http://127.0.0.1:1/v1".to_string(),
|
||||||
model: "durable-control-tool-plan-model".to_string(),
|
model: "durable-control-tool-plan-model".to_string(),
|
||||||
@@ -1525,6 +1537,8 @@ fn provider_recovery_cleanup_closes_tool_plan_lifecycle_before_removing_handoff(
|
|||||||
snapshot.request_slot = "loop-0-repair-0".to_string();
|
snapshot.request_slot = "loop-0-repair-0".to_string();
|
||||||
let request = LlmRunRequest::new(vec![LlmMessage::user("等待 steer 或 cancel")]);
|
let request = LlmRunRequest::new(vec![LlmMessage::user("等待 steer 或 cancel")]);
|
||||||
let llm = GameCreatorLlmConfig {
|
let llm = GameCreatorLlmConfig {
|
||||||
|
custom_enabled: false,
|
||||||
|
visible_models: Vec::new(),
|
||||||
api_key: "tool-plan-cleanup-key".to_string(),
|
api_key: "tool-plan-cleanup-key".to_string(),
|
||||||
base_url: "http://127.0.0.1:1/v1".to_string(),
|
base_url: "http://127.0.0.1:1/v1".to_string(),
|
||||||
model: "tool-plan-cleanup-model".to_string(),
|
model: "tool-plan-cleanup-model".to_string(),
|
||||||
@@ -1597,6 +1611,8 @@ fn runtime_resume_scans_and_cleans_terminal_tool_plan_handoff() {
|
|||||||
snapshot.request_slot = "loop-0-repair-0".to_string();
|
snapshot.request_slot = "loop-0-repair-0".to_string();
|
||||||
let request = LlmRunRequest::new(vec![LlmMessage::user("终态遗留 handoff")]);
|
let request = LlmRunRequest::new(vec![LlmMessage::user("终态遗留 handoff")]);
|
||||||
let llm = GameCreatorLlmConfig {
|
let llm = GameCreatorLlmConfig {
|
||||||
|
custom_enabled: false,
|
||||||
|
visible_models: Vec::new(),
|
||||||
api_key: "terminal-handoff-key".to_string(),
|
api_key: "terminal-handoff-key".to_string(),
|
||||||
base_url: "http://127.0.0.1:1/v1".to_string(),
|
base_url: "http://127.0.0.1:1/v1".to_string(),
|
||||||
model: "terminal-handoff-model".to_string(),
|
model: "terminal-handoff-model".to_string(),
|
||||||
@@ -1693,6 +1709,8 @@ async fn provider_handoff_retry_conflict_preserves_both_sidecars_for_reconciliat
|
|||||||
let root = project.path();
|
let root = project.path();
|
||||||
let request = LlmRunRequest::new(vec![LlmMessage::user("验证 handoff/retry 冲突")]);
|
let request = LlmRunRequest::new(vec![LlmMessage::user("验证 handoff/retry 冲突")]);
|
||||||
let llm = GameCreatorLlmConfig {
|
let llm = GameCreatorLlmConfig {
|
||||||
|
custom_enabled: false,
|
||||||
|
visible_models: Vec::new(),
|
||||||
api_key: "provider-key".to_string(),
|
api_key: "provider-key".to_string(),
|
||||||
base_url: "http://127.0.0.1:1/v1".to_string(),
|
base_url: "http://127.0.0.1:1/v1".to_string(),
|
||||||
model: "provider-model".to_string(),
|
model: "provider-model".to_string(),
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user