Compare commits

..

11 Commits

Author SHA1 Message Date
k88936 ed3369a494 文档先行:把 DirectProject 聊天真相源收敛的文档改到当前实现状态
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 5m13s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 4m30s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 4m39s
Project CI / Backend tests (pull_request) Failing after 12s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m43s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 4m8s
Project CI / Repository checks (pull_request) Failing after 10s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m10s
Project CI / Frontend tests (pull_request) Failing after 3m24s
Project CI / AI game creator shell web tests (pull_request) Failing after 3m7s
Project CI / Native shell tests (pull_request) Successful in 6m8s
- 主规范把事件模型写成 ts-rs 导出的 tagged enum,说明 seq 不下发、事件不带回合身份,并补上 ts 绑定生成与 f64 时间戳约定
- ADR 补上 tagged enum 绑定、turn id 删除、正文只增不减与回合回收、前端卡片去掉 turnId、未知类型前端丢弃
- 里程碑与实施计划同步:文件改名到 direct_thread_wire.rs,新增删回合身份与生成绑定两步,验证命令加上 export_bindings
- 工具卡片技术方案在文首加修订段:数据来源层作废,表现层契约仍然有效,DirectRuntime 的 tool-calls.jsonl 写入保留
- 对话回合唯一投影里程碑改为 superseded,列出仍然有效与已作废的分界
2026-09-16 19:57:21 +08:00
k88936 fdc48fe725 前端聊天投影按 ts-rs 生成类型收口,删掉 callId 与回合身份
- directThreadItemProjection.ts 直接消费生成的 DirectThreadItem,工具卡片 kind / 标题 / 摘要 / 状态与可见性判定都在这里
- directThreadChat.ts 聊天条目只留一个 itemId:去掉 callId 概念,去掉 deltaText 缓冲,增量直接追加到运行态条目正文
- 合并规则收成先到者赢、后到只补空:正文只增不减、工具状态允许从 running 升级到终态
- 回合结束把运行态条目并入历史并清空 live,条目既不消失也不重复
- directThreadEvents.ts 改为转发 ts-rs 绑定,只保留历史条目转消息的过渡函数
- ToolCallGroup 与 toolCallGroupPresentation 改用去掉回合身份的 DirectChatToolCard
- generated/index.ts 补上 DirectThread* 绑定导出
- 回退本仓库其它模块仍在改的 DirectCodexUser* 绑定重新生成(避免误改他人进行中的契约)
- 两个 vitest 用例文件按新 API 重写,11 条用例通过
2026-09-16 19:49:26 +08:00
k88936 2748468d12 DirectProject 聊天事件改用 ts-rs 导出的 tagged enum 并删掉 turn id
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 5m11s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 4m51s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m57s
Project CI / Backend tests (pull_request) Failing after 11s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 4m50s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m2s
Project CI / Repository checks (pull_request) Failing after 15s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 4m14s
Project CI / Native shell tests (pull_request) Failing after 2m13s
Project CI / AI game creator shell web tests (pull_request) Failing after 46s
Project CI / Frontend tests (pull_request) Failing after 3m32s
- 新增 direct_thread_wire.rs:DirectThreadItem / DirectThreadEvent / 订阅与历史切片全部改成 ts-rs 导出的 tagged enum,取代原大而全的可空结构体
- 删除 direct_thread_raw_item.rs,模块注册与直通引用改到 direct_thread_wire
- 条目身份只看一个 itemId:工具条目的第二个 id 在 Rust 边界归一,不再对外暴露
- 删除 DirectProject 聊天事件里的 turn id:生命周期用无载荷的 turn.started / turn.completed{status} 表示
- append 直接接收 DirectThreadEvent 并返回同一事件,队列内部自算 seq
- 请求事件改为携带 DirectThreadRequestKind,去掉字符串中转
- 思考增量走 ReasoningDelta 通道,与正文增量共用 item.delta
- at 用 #[ts(as = "f64")] 对齐 Tauri JSON 通道的 number
- 用 cargo test export_bindings 重新生成 project-workspace/generated 绑定
2026-09-16 19:43:57 +08:00
k88936 09ad0073fe 修正 DirectProject 订阅语义:bootstrap 事件就是当前要处理的事件
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Has been cancelled
subscribe 已在同一边界把游标钉在队尾,因此前端直接 reduce bootstrap 事件,不再补一次 consume
主规范、ADR 与实施计划同步删除"竞态窗口"说法,notify 仍是唯一唤醒来源
2026-09-16 19:06:05 +08:00
k88936 42b702d362 按 grill 结论修正 DirectProject 聊天真相源收敛计划
里程碑与实施计划改定边界:Thread Manager 只搬运脱敏原始条目,不生成卡片形状与可见性
条目身份归一为 call_id ?? id,Thread Manager 与前端不再暴露第二个 id 概念,分页锚点仍取文件里的原始 item id
合并规则收敛为"先到定形、后到补空白",删除逐字段优先级表
主规范补充条目形状三条职责边界,并说明思考正文流式下发不放宽可见范围
ADR 同步:DirectRuntime 自己的进度事件与投影文件保留,前端删除的是 DirectProject 的读路径
失败与中止说明只在运行期显示;历史切片改为从文件尾反向扫描;未知 item 类型透传并加 TODO
2026-09-16 19:05:04 +08:00
k88936 656c89e4b2 把 DirectProject 聊天条目的投影从前端收口,Rust 只搬运脱敏原始条目
新增 agent/direct_thread_raw_item.rs:把 Codex 原始 response item 与 app-server item 收敛成同一形状的脱敏原始条目,只做挑字段、脱敏、截断,不再生成卡片的 kind / 标题 / 折叠摘要
删除 agent/direct_chat_entry.rs:工具卡片形状不再由 Rust 生产
历史切片改为返回脱敏原始条目列表,不再在 Rust 侧合并 function_call 与 function_call_output
新增前端 directThreadItemProjection.ts:工具卡片 kind / 标题 / 摘要 / 状态与可见性判定全部在前端完成
directThreadChat reducer 改用同一投影函数处理运行态事件,新增 mergeDirectHistoryItems 走同一份投影与合并规则
DirectThreadHistorySlice 去掉 entries 与 itemTimestamps,改为 items + firstItemId
2026-09-16 18:45:13 +08:00
k88936 0ec1179bf1 移除前端 consume 轮询兜底
- 唤醒只由 notify 驱动,bootstrap 后立刻 consume 一次补齐竞态窗口
- 定时 tick 无法自举:判断活动回合本身依赖事件,唤醒丢失时定时器不会启动
- 主规范、ADR 与实施计划同步删除该条款
2026-09-16 18:17:38 +08:00
k88936 2938a49cac 固化 DirectProject 聊天真相源契约
- 主规范新增首屏锚点、生命周期锚点必进 bootstrap、事件自足与不提供单点快照四条契约
- 新增 ADR 记录单一事实源取舍、删除清单与待验证的 id 空间风险
- 新增里程碑与实施计划,CONTEXT.md 补项目对话历史/运行态事件/聊天投影词条
- 目标文档合同与文档索引同步
2026-09-16 18:05:30 +08:00
k88936 ae0f9376c9 新增 DirectProject 聊天 reducer 深模块
- directThreadChat 把运行态事件与历史切片归并成同一份聊天条目,活动回合只由 turn.started/turn.completed 判定
- item.delta 载荷补 kind,reducer 不再靠 itemId 猜条目类型
- bootstrap 原子替换运行态并保留历史窗口,历史与运行态按 callId ?? itemId 去重合并
- 新增 5 条 reducer 单测
2026-09-16 18:05:10 +08:00
k88936 29d0cbb4df 运行态事件与历史切片共用聊天条目投影
- 新增 direct_chat_entry 深模块,把原始 response item 与 app-server item 投影成同一聊天条目
- item.started/item.completed 载荷改为完整条目,前端不再需要按 itemId 取快照
- 原始 function_call 与 function_call_output 按 callId 并成一张卡片,文本与明细统一脱敏截断
- 历史切片新增 entries 与 first_item_id,分页锚点不再依赖是否出现可显示条目
2026-09-16 18:01:50 +08:00
k88936 721e45f01b 统一 DirectProject 运行态条目的合并身份
- Thread Manager 事件新增 callId,条目身份统一为 callId 优先、其次 itemId
- 工具条目按 callId 归并 started/completed,不再留下永远收不到完成事件的幽灵活跃条目
- 历史锚点仍取 response item id,保证分页锚点能在 project.jsonl 中命中
- 补充跨 id 空间归并的单元测试
2026-09-16 17:45:28 +08:00
139 changed files with 3329 additions and 7173 deletions
-3
View File
@@ -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
-3
View File
@@ -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
View File
@@ -172,6 +172,20 @@ _Avoid_: 多步骤向导、完整规则编辑器、拖拽编辑器
Bark Battle 平台作品闭环按契约与领域规则、后端存储/API、最小前端纵切、投影体验、收口验证的顺序推进。
_Avoid_: mock 先行堆积、前后端各自发散、先做排行榜 UI
## 项目开发对话(DirectProject
**项目对话历史**:
AGC 本地项目内 Codex 原始对话条目的持久集合,是聊天展示、工具卡片和线程恢复注入的唯一持久事实源。
_Avoid_: 会话缓存、展示态历史、按 UI 需要另存的对话副本
**运行态事件**:
Thread Manager 向订阅者推送的当前回合原始事件流,只服务运行期间与短期断线恢复,不替代项目对话历史。
_Avoid_: 进度通知、快照轮询、第二套历史
**聊天投影**:
把项目对话历史条目与运行态事件转换成消息气泡和工具卡片的读取期转换;不持久化,也不构成事实源。
_Avoid_: 投影缓存文件、已脱敏卡片库、第二套 reducer
## Relationships
- 一个 **汪汪声浪大作战** 单局包含多个 **有效声浪触发**
@@ -206,3 +220,5 @@ _Avoid_: mock 先行堆积、前后端各自发散、先做排行榜 UI
- “入口闭环”曾可能只指内部 demo 或单个详情 CTA;已解析为 **正式作品入口闭环**,不新增独立专区或活动页。
- “创作编辑”曾可能指多步骤向导或完整编辑器;已解析为 **轻配置编辑流程**,使用单页表单 + 预览卡片完成保存草稿、发布和发布后跳转作品详情。
- “实施顺序”曾可能按 UI 或功能并行发散;已解析为契约/领域规则先行,再做后端存储/API,随后打通最小前端纵切,最后补投影体验与收口验证。
- “回合进度事件”曾同时指 Direct turn update 与 Thread Manager 运行态事件;已解析为 AGC 项目开发对话只保留 **运行态事件**
- “哪些消息可显示”曾可能由后端历史分页判断;已解析为可见性判断属于 **聊天投影**,后端只按原始条目分页,前端负责跳过不可显示条目并推进分页锚点。
@@ -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 -1
View File
@@ -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
View File
@@ -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}}},
@@ -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_EXITabort——现场就是"点终止,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` 在那里 panicpanic 跨不过 IPC 回调边界就把整个
/// 进程 abort0xC0000409"点终止就闪退")。这条用例把派发入口钉在没有 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",
@@ -482,38 +482,9 @@ pub(crate) fn read_direct_project_history_items_at(root: &Path) -> Result<Vec<Va
}
fn read_direct_project_history_entries_at(root: &Path) -> Result<Vec<(Value, u64)>, String> {
read_direct_project_history_entries_filtered_at(root, None, false)
}
fn is_direct_project_chat_message(item: &Value) -> bool {
matches!(
item.get("role").and_then(Value::as_str),
Some("user" | "assistant")
) && item
.get("content")
.and_then(Value::as_array)
.is_some_and(|parts| {
parts.iter().any(|part| {
part.get("text")
.and_then(Value::as_str)
.is_some_and(|text| !text.is_empty())
})
})
}
/// 消息模式逐行丢弃工具输出,只保留聊天正文,避免 40 MiB 工具日志被整表积累或发给 UI。
fn read_direct_project_history_entries_filtered_at(
root: &Path,
before_item_id: Option<&str>,
messages_only: bool,
) -> Result<Vec<(Value, u64)>, String> {
let path = history_path(root);
if !prepare_game_creator_private_path_for_read(&path, false, "DirectProject 历史")? {
return if before_item_id.is_some() {
Err("DirectProject 历史游标对应的文件已不存在".to_string())
} else {
Ok(Vec::new())
};
return Ok(Vec::new());
}
let file = File::open(&path)
.map_err(|error| format!("打开 DirectProject 历史失败:{}: {error}", path.display()))?;
@@ -548,12 +519,6 @@ fn read_direct_project_history_entries_filtered_at(
if is_direct_project_internal_context_item(&item) {
continue;
}
if before_item_id.is_some_and(|id| item.get("id").and_then(Value::as_str) == Some(id)) {
return Ok(items);
}
if messages_only && !is_direct_project_chat_message(&item) {
continue;
}
items.push((
item,
parsed
@@ -562,45 +527,7 @@ fn read_direct_project_history_entries_filtered_at(
.unwrap_or(0),
));
}
match before_item_id {
Some(item_id) => Err(format!("DirectProject 历史中不存在 item{item_id}")),
None => Ok(items),
}
}
pub(crate) fn read_direct_project_chat_items_slice_at(
root: &Path,
before_item_id: Option<&str>,
limit: usize,
) -> Result<(Vec<Value>, bool, BTreeMap<String, u64>), String> {
let entries = read_direct_project_history_entries_filtered_at(root, before_item_id, true)?;
let mut start = entries.len().saturating_sub(limit.clamp(1, 200));
// 旧消息可能没有 ID:保留原文,并向前扩到可寻址的已有 ID,不能制造原始消息身份。
while start > 0
&& entries[start]
.0
.get("id")
.and_then(Value::as_str)
.is_none_or(str::is_empty)
{
start -= 1;
}
let timestamps = entries[start..]
.iter()
.filter_map(|(item, at)| {
let id = item.get("id").and_then(Value::as_str)?;
(*at > 0).then(|| (id.to_string(), *at))
})
.collect();
Ok((
entries
.into_iter()
.skip(start)
.map(|(item, _)| item)
.collect(),
start > 0,
timestamps,
))
Ok(items)
}
pub(crate) fn read_direct_project_history_items_slice_at(
@@ -713,165 +640,6 @@ mod tests {
const RESPONSE_ITEM_ROW: &str = r#"{"type":"response_item","payload":{"type":"message","role":"user","id":"codex-item-2","content":[{"type":"input_text","text":"再加一个按钮"}]}}"#;
const RESPONSE_ASSISTANT_ROW: &str = r#"{"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"已完成"}]}}"#;
fn write_items(root: &std::path::Path, items: &[Value]) {
let lines = items
.iter()
.enumerate()
.map(|(index, item)| {
json!({"type": "response_item", "payload": item, "recordedAt": 1000 + index})
.to_string()
})
.collect::<Vec<_>>();
write_history_lines(root, &lines.iter().map(String::as_str).collect::<Vec<_>>());
}
#[test]
fn chat_pages_skip_tool_only_tail_and_gaps_without_losing_messages_or_times() {
let root = init_history_project("message-pages");
let mut raw = Vec::new();
let mut expected = Vec::new();
for n in 0..44 {
let item = json!({
"id": format!("message-{n}"), "type": "message",
"role": if n == 0 || n == 38 { "user" } else { "assistant" },
"content": [{"type": "output_text", "text": format!("消息 {n}")}],
});
expected.push(item.clone());
raw.push(item);
for tool in 0..25 {
raw.push(json!({
"id": format!("tool-{n}-{tool}"), "type": "function_call_output",
"output": "工具结果不应占聊天页名额",
}));
}
}
write_items(root.path(), &raw);
let path = history_path(root.path());
let before = std::fs::read(&path).unwrap();
let (old_page, _, _) =
super::read_direct_project_history_items_slice_at(root.path(), None, 20).unwrap();
assert!(old_page
.iter()
.all(|item| item["type"] == "function_call_output"));
let mut cursor = None;
let mut all = Vec::new();
let mut sizes = Vec::new();
loop {
let (mut page, more, timestamps) =
super::read_direct_project_chat_items_slice_at(root.path(), cursor.as_deref(), 20)
.unwrap();
sizes.push(page.len());
for item in &page {
let index = raw.iter().position(|raw| raw["id"] == item["id"]).unwrap();
assert_eq!(
timestamps[item["id"].as_str().unwrap()],
1000 + index as u64
);
}
let next = page
.first()
.and_then(|item| item["id"].as_str())
.map(str::to_string);
page.append(&mut all);
all = page;
if !more {
break;
}
assert_ne!(next, cursor);
cursor = next;
assert!(sizes.len() < 10);
}
assert_eq!(sizes, vec![20, 20, 4]);
assert_eq!(all, expected);
assert_eq!(std::fs::read(&path).unwrap(), before);
}
#[test]
fn chat_pages_handle_empty_content_internal_context_and_missing_ids() {
let root = init_history_project("message-page-boundary");
write_items(
root.path(),
&[
json!({"id":"u", "role":"user", "content":[{"text":"第一条"}]}),
json!({"role":"assistant", "content":[{"text":"无ID的旧消息"}]}),
json!({"id":"a", "role":"assistant", "content":[{"text":"最后一条"}]}),
json!({"id":"empty", "role":"assistant", "content":[{"text":""}]}),
json!({"id":"internal", "role":"user", "content":[{"text":"<environment_context>内部</environment_context>"}]}),
json!({"id":"reason", "type":"reasoning", "content":[{"text":"推理"}]}),
],
);
let (page, more, _) =
super::read_direct_project_chat_items_slice_at(root.path(), None, 1).unwrap();
assert_eq!(page[0]["id"], "a");
assert!(more);
let (page, more, _) =
super::read_direct_project_chat_items_slice_at(root.path(), Some("a"), 1).unwrap();
assert_eq!(page.len(), 2);
assert_eq!(page[0]["id"], "u");
assert!(page[1].get("id").is_none());
assert!(!more);
assert!(
super::read_direct_project_chat_items_slice_at(root.path(), Some("missing"), 20)
.is_err()
);
write_items(
root.path(),
&[json!({"id":"tool", "type":"function_call", "arguments":"{}"})],
);
let (page, more, _) =
super::read_direct_project_chat_items_slice_at(root.path(), None, 20).unwrap();
assert!(page.is_empty());
assert!(!more);
}
#[test]
#[ignore = "人工只读诊断:通过 AGC_HISTORY_REPLAY_SOURCE 提供原始历史文件"]
fn replay_external_chat_history_pages_without_mutating_source() {
let source = std::env::var_os("AGC_HISTORY_REPLAY_SOURCE").expect("provide replay source");
let before = std::fs::read(&source).expect("read source");
let root = init_history_project("external-history-replay");
let path = history_path(root.path());
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, &before).unwrap();
let expected =
super::read_direct_project_history_entries_filtered_at(root.path(), None, true)
.expect("read messages");
let mut cursor = None;
let mut all = Vec::new();
let mut pages = 0;
loop {
let (mut items, more, _) =
super::read_direct_project_chat_items_slice_at(root.path(), cursor.as_deref(), 20)
.expect("read page");
let next = items
.first()
.and_then(|item| item["id"].as_str())
.map(str::to_string);
items.append(&mut all);
all = items;
pages += 1;
if !more {
break;
}
assert!(next.is_some() && next != cursor, "cursor must advance");
assert!(pages <= expected.len() + 1, "pagination must terminate");
cursor = next;
}
assert!(
all.iter().eq(expected.iter().map(|(item, _)| item)),
"message order and content must match"
);
assert!(
std::fs::read(&source).unwrap() == before,
"source must remain unchanged"
);
eprintln!(
"history replay: messages={}, pages={pages}, users={}",
all.len(),
all.iter().filter(|item| item["role"] == "user").count()
);
}
#[test]
fn history_timestamps_survive_reload_and_idempotent_append_without_changing_raw_items() {
let root = init_history_project("history-time");
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
@@ -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 {
@@ -1241,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()))
}
@@ -1760,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
@@ -1797,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))
@@ -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(),
@@ -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");
@@ -605,8 +605,6 @@ fn finalization_cleanup_closes_entire_tool_plan_repair_chain_before_removal() {
response_stream_fixture("finalization-tool-plan-repair-chain-run");
let root = project.path();
let llm = GameCreatorLlmConfig {
custom_enabled: false,
visible_models: Vec::new(),
api_key: "finalization-tool-plan-key".to_string(),
base_url: "http://127.0.0.1:1/v1".to_string(),
model: "finalization-tool-plan-model".to_string(),
@@ -970,8 +968,6 @@ async fn provider_handoff_identity_drift_closes_lifecycle_without_leaking_respon
let root = project.path();
let request = LlmRunRequest::new(vec![LlmMessage::user("验证 handoff 身份漂移")]);
let old_llm = GameCreatorLlmConfig {
custom_enabled: false,
visible_models: Vec::new(),
api_key: "old-provider-key".to_string(),
base_url: "http://127.0.0.1:1/v1".to_string(),
model: "old-provider-model".to_string(),
@@ -1077,8 +1073,6 @@ async fn tool_plan_handoff_identity_drift_closes_entire_repair_chain_before_remo
LlmMessage::user("修复格式"),
]);
let old_llm = GameCreatorLlmConfig {
custom_enabled: false,
visible_models: Vec::new(),
api_key: "old-tool-plan-provider-key".to_string(),
base_url: "http://127.0.0.1:1/v1".to_string(),
model: "old-tool-plan-model".to_string(),
@@ -1198,8 +1192,6 @@ async fn generic_retry_identity_drift_closes_tool_plan_repair_chain_before_remov
response_stream_fixture("generic-retry-drift-tool-plan-chain-run");
let root = project.path();
let old_llm = GameCreatorLlmConfig {
custom_enabled: false,
visible_models: Vec::new(),
api_key: "old-generic-retry-key".to_string(),
base_url: "http://127.0.0.1:1/v1".to_string(),
model: "old-generic-retry-model".to_string(),
@@ -1285,8 +1277,6 @@ async fn tool_plan_capacity_gate_runs_before_provider_lifecycle_and_network() {
response_stream_fixture("tool-plan-capacity-preflight-run");
let root = project.path();
let llm = GameCreatorLlmConfig {
custom_enabled: false,
visible_models: Vec::new(),
api_key: "tool-plan-capacity-key".to_string(),
base_url: "http://127.0.0.1:1/v1".to_string(),
model: "tool-plan-capacity-model".to_string(),
@@ -1410,8 +1400,6 @@ async fn tool_plan_handoff_durable_control_closes_entire_repair_chain_before_rem
LlmMessage::user("修复格式"),
]);
let llm = GameCreatorLlmConfig {
custom_enabled: false,
visible_models: Vec::new(),
api_key: "durable-control-tool-plan-key".to_string(),
base_url: "http://127.0.0.1:1/v1".to_string(),
model: "durable-control-tool-plan-model".to_string(),
@@ -1537,8 +1525,6 @@ fn provider_recovery_cleanup_closes_tool_plan_lifecycle_before_removing_handoff(
snapshot.request_slot = "loop-0-repair-0".to_string();
let request = LlmRunRequest::new(vec![LlmMessage::user("等待 steer 或 cancel")]);
let llm = GameCreatorLlmConfig {
custom_enabled: false,
visible_models: Vec::new(),
api_key: "tool-plan-cleanup-key".to_string(),
base_url: "http://127.0.0.1:1/v1".to_string(),
model: "tool-plan-cleanup-model".to_string(),
@@ -1611,8 +1597,6 @@ fn runtime_resume_scans_and_cleans_terminal_tool_plan_handoff() {
snapshot.request_slot = "loop-0-repair-0".to_string();
let request = LlmRunRequest::new(vec![LlmMessage::user("终态遗留 handoff")]);
let llm = GameCreatorLlmConfig {
custom_enabled: false,
visible_models: Vec::new(),
api_key: "terminal-handoff-key".to_string(),
base_url: "http://127.0.0.1:1/v1".to_string(),
model: "terminal-handoff-model".to_string(),
@@ -1709,8 +1693,6 @@ async fn provider_handoff_retry_conflict_preserves_both_sidecars_for_reconciliat
let root = project.path();
let request = LlmRunRequest::new(vec![LlmMessage::user("验证 handoff/retry 冲突")]);
let llm = GameCreatorLlmConfig {
custom_enabled: false,
visible_models: Vec::new(),
api_key: "provider-key".to_string(),
base_url: "http://127.0.0.1:1/v1".to_string(),
model: "provider-model".to_string(),
@@ -2,9 +2,8 @@ use super::*;
pub(super) static GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE: OnceLock<tauri::AppHandle> =
OnceLock::new();
/// 同一 AppData 允许多个界面窗口同时挂载事件接收端,因此这里是按 token 去重的注册表。
pub(super) static GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINKS: OnceLock<
std::sync::Mutex<Vec<GameCreatorManifestInvalidationEventSink>>,
pub(super) static GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK: OnceLock<
std::sync::Mutex<Option<GameCreatorManifestInvalidationEventSink>>,
> = OnceLock::new();
#[cfg(test)]
pub(super) static GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK_TEST_LOCK: std::sync::Mutex<()> =
@@ -294,8 +293,8 @@ pub(crate) use entrypoints::{
configure_game_creator_manifest_invalidation_event_sink, emit_direct_game_creator_progress,
emit_game_creator_agent_runtime_update, emit_game_creator_manifest_invalidated,
game_creator_agent_runtime_update_event, generate_local_game_draft_at,
read_game_creator_agent_runtime_at, read_game_creator_agent_runtime_for_session_at,
read_game_creator_agent_runtimes_at, register_game_creator_manifest_invalidation_event_sink,
install_game_creator_manifest_invalidation_event_sink, read_game_creator_agent_runtime_at,
read_game_creator_agent_runtime_for_session_at, read_game_creator_agent_runtimes_at,
set_game_creator_agent_runtime_update_app_handle,
start_game_creator_manifest_invalidation_event_sink,
validate_game_creator_manifest_invalidation_event_sink,
@@ -1,12 +1,11 @@
use super::*;
const GAME_CREATOR_MANIFEST_INVALIDATION_RELAY_MAX_BYTES: u64 = 64 * 1024;
const GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK_MAX: usize = 16;
fn lock_game_creator_manifest_invalidation_event_sinks(
) -> std::sync::MutexGuard<'static, Vec<GameCreatorManifestInvalidationEventSink>> {
GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINKS
.get_or_init(|| Mutex::new(Vec::new()))
fn lock_game_creator_manifest_invalidation_event_sink(
) -> std::sync::MutexGuard<'static, Option<GameCreatorManifestInvalidationEventSink>> {
GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK
.get_or_init(|| Mutex::new(None))
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
@@ -220,7 +219,7 @@ pub(crate) fn configure_game_creator_manifest_invalidation_event_sink(
token: &str,
) -> Result<(), String> {
let sink = validate_game_creator_manifest_invalidation_event_sink(port, token)?;
register_game_creator_manifest_invalidation_event_sink(sink);
install_game_creator_manifest_invalidation_event_sink(sink);
Ok(())
}
@@ -241,29 +240,10 @@ pub(crate) fn validate_game_creator_manifest_invalidation_event_sink(
})
}
/// 登记一个界面窗口的事件接收端。
///
/// 同一窗口重复 attach 用同一个 token,按 token 覆盖旧登记;不同窗口各自持有
/// 自己的 token,注册表按登记顺序保留,最多 `GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK_MAX` 个。
pub(crate) fn register_game_creator_manifest_invalidation_event_sink(
pub(crate) fn install_game_creator_manifest_invalidation_event_sink(
sink: GameCreatorManifestInvalidationEventSink,
) {
let mut sinks = lock_game_creator_manifest_invalidation_event_sinks();
if let Some(existing) = sinks
.iter_mut()
.find(|existing| existing.token == sink.token)
{
*existing = sink;
return;
}
if sinks.len() >= GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK_MAX {
sinks.remove(0);
}
sinks.push(sink);
}
fn remove_game_creator_manifest_invalidation_event_sink(token: &str) {
lock_game_creator_manifest_invalidation_event_sinks().retain(|sink| sink.token != token);
*lock_game_creator_manifest_invalidation_event_sink() = Some(sink);
}
#[cfg(test)]
@@ -278,20 +258,14 @@ impl GameCreatorManifestInvalidationEventSinkTestGuard {
}
pub(crate) fn configured_sink(&self) -> Option<GameCreatorManifestInvalidationEventSink> {
lock_game_creator_manifest_invalidation_event_sinks()
.first()
.cloned()
}
pub(crate) fn configured_sinks(&self) -> Vec<GameCreatorManifestInvalidationEventSink> {
lock_game_creator_manifest_invalidation_event_sinks().clone()
lock_game_creator_manifest_invalidation_event_sink().clone()
}
}
#[cfg(test)]
impl Drop for GameCreatorManifestInvalidationEventSinkTestGuard {
fn drop(&mut self) {
lock_game_creator_manifest_invalidation_event_sinks().clear();
*lock_game_creator_manifest_invalidation_event_sink() = None;
}
}
@@ -307,43 +281,16 @@ pub(crate) fn acquire_game_creator_manifest_invalidation_event_sink_test_guard(
}
fn relay_game_creator_manifest_invalidation(root: &Path, agent_id: &str) -> Result<(), String> {
let sinks = lock_game_creator_manifest_invalidation_event_sinks().clone();
if sinks.is_empty() {
let sink = lock_game_creator_manifest_invalidation_event_sink().clone();
let Some(sink) = sink else {
return Ok(());
}
let event = GameCreatorManifestInvalidatedEvent {
project_path: root.to_string_lossy().into_owned(),
agent_id: agent_id.to_string(),
};
let mut failed_tokens = Vec::new();
let mut last_error = None;
for sink in &sinks {
match relay_game_creator_manifest_invalidation_to_sink(sink, &event) {
Ok(()) => {}
Err(error) => {
// 窗口已退出或接收端已释放时只淘汰该接收端,不能影响其它窗口。
failed_tokens.push(sink.token.clone());
last_error = Some(error);
}
}
}
if !failed_tokens.is_empty() {
lock_game_creator_manifest_invalidation_event_sinks()
.retain(|sink| !failed_tokens.contains(&sink.token));
}
match last_error {
Some(error) => Err(error),
None => Ok(()),
}
}
fn relay_game_creator_manifest_invalidation_to_sink(
sink: &GameCreatorManifestInvalidationEventSink,
event: &GameCreatorManifestInvalidatedEvent,
) -> Result<(), String> {
let envelope = GameCreatorManifestInvalidationRelayEnvelope {
token: sink.token.clone(),
event: event.clone(),
token: sink.token,
event: GameCreatorManifestInvalidatedEvent {
project_path: root.to_string_lossy().into_owned(),
agent_id: agent_id.to_string(),
},
};
let payload = serde_json::to_vec(&envelope)
.map_err(|error| format!("序列化 manifest 失效事件失败:{error}"))?;
@@ -1939,9 +1939,8 @@ pub(crate) async fn polish_local_project_prompt(
}
#[tauri::command]
pub(crate) fn read_platform_account_session_state(
) -> crate::platform_session::PlatformSessionWriteState {
crate::platform_session::current_platform_session_write_state()
pub(crate) fn read_platform_account_session_generation() -> u64 {
current_platform_session_generation()
}
#[tauri::command]
@@ -1949,45 +1948,28 @@ pub(crate) async fn install_platform_account_session(
user_id: String,
access_token: String,
api_base_url: String,
identity_generation: u64,
revision: u64,
generation: u64,
) -> Result<(), String> {
tokio::task::spawn_blocking(move || {
validate_platform_session_input(
&user_id,
&access_token,
&api_base_url,
identity_generation,
revision,
)?;
validate_platform_session_input(&user_id, &access_token, &api_base_url, generation)?;
install_external_agent_runner_platform_session(
&user_id,
&access_token,
&api_base_url,
identity_generation,
revision,
generation,
)?;
install_platform_session(
&user_id,
&access_token,
&api_base_url,
identity_generation,
revision,
)
install_platform_session(&user_id, &access_token, &api_base_url, generation)
})
.await
.map_err(|error| format!("安装本地运行时会话任务意外终止:{error}"))?
}
#[tauri::command]
pub(crate) async fn clear_platform_account_session(
identity_generation: u64,
revision: u64,
) -> Result<(), String> {
pub(crate) async fn clear_platform_account_session(generation: u64) -> Result<(), String> {
tokio::task::spawn_blocking(move || {
shutdown_game_creator_codex_app_servers()?;
clear_external_agent_runner_platform_session(identity_generation, revision)?;
clear_platform_session(identity_generation, revision);
clear_external_agent_runner_platform_session(generation)?;
clear_platform_session(generation);
Ok(())
})
.await
@@ -2009,8 +1991,6 @@ pub(crate) fn write_game_creator_app_config(
.lock()
.map_err(|_| "配置写入锁不可用")?;
let (current, overlays) = load_game_creator_app_config_for_write()?;
// 自定义开关只能从本地配置文件开启,不能由渲染层越过配置门禁。
config.llm.custom_enabled = current.llm.custom_enabled;
config.selected_model_id = current.selected_model_id;
config.selected_model_is_default = current.selected_model_is_default;
persist_game_creator_app_config(config, overlays, false)
@@ -2047,12 +2027,7 @@ pub(crate) fn select_game_creator_model(
let _guard = GAME_CREATOR_CONFIG_WRITE_LOCK
.lock()
.map_err(|_| "配置写入锁不可用")?;
let (mut config, overlays) = load_game_creator_app_config_for_write()?;
if config.llm.custom_enabled {
if !config.llm.visible_models.contains(&model_id) {
return Err("所选模型未勾选或已移除,请刷新模型列表".into());
}
} else if model_id.is_empty()
if model_id.is_empty()
|| model_id.len() > 64
|| !model_id
.bytes()
@@ -2060,21 +2035,12 @@ pub(crate) fn select_game_creator_model(
{
return Err("模型标识无效".into());
}
let (mut config, overlays) = load_game_creator_app_config_for_write()?;
config.selected_model_id = model_id;
config.selected_model_is_default = is_default;
persist_game_creator_app_config(config, overlays, true)
}
#[tauri::command]
pub(crate) async fn discover_game_creator_llm_models(
llm: GameCreatorLlmConfig,
) -> Result<Vec<String>, String> {
if !load_game_creator_app_config()?.llm.custom_enabled {
return Err("请先在本地配置中开启 llm.customEnabled".to_string());
}
fetch_custom_llm_models(&llm).await
}
fn persist_game_creator_app_config(
config: GameCreatorAppConfig,
overlays: Vec<(PathBuf, serde_json::Value)>,
@@ -2090,8 +2056,8 @@ fn persist_game_creator_app_config(
let previous = overlay.clone();
if let Some(fields) = overlay.as_object_mut() {
for (key, value) in fields.iter_mut() {
if !model_only
|| matches!(key.as_str(), "selectedModelId" | "selectedModelIsDefault")
if matches!(key.as_str(), "selectedModelId" | "selectedModelIsDefault")
== model_only
{
if let Some(saved_value) = saved.get(key) {
// 仅同步已有覆盖项;其它字段继续保留原有覆盖语义。
@@ -4265,7 +4231,14 @@ pub(crate) async fn import_account_editor_assets_for_agent(
access.validate_frozen_session()?;
let _platform_session_lease = frozen_session
.as_ref()
.map(|session| acquire_platform_session_identity_lease(&session.identity()))
.map(|session| {
acquire_validated_platform_session_fingerprint(
&session.user_id,
&session.api_base_url,
session.generation,
&format!("{:x}", Sha256::digest(session.access_token.as_bytes())),
)
})
.transpose()?;
let _lock = acquire_project_write_lock(root, "canvas.asset_import")?;
access.validate_frozen_session()?;
@@ -5035,21 +5008,7 @@ pub(crate) fn read_local_project_text_preview_at(
return Err("只能读取当前项目已登记的文档资源".to_string());
}
cancellation.check()?;
let mut preview =
load_local_project_text_preview_with_cancellation(root, &normalized_path, cancellation)?;
if normalized_path.to_ascii_lowercase().ends_with(".json") {
preview.ui_design_asset_id = manifest.assets.iter().find_map(|asset| {
(asset.local_path == normalized_path
&& ui_editor::persistence::is_valid_ui_design_json(
&preview.content,
&manifest.project_id,
&asset.id,
))
.then(|| asset.id.clone())
});
}
cancellation.check()?;
Ok(preview)
load_local_project_text_preview_with_cancellation(root, &normalized_path, cancellation)
}
#[tauri::command]
@@ -5415,29 +5374,30 @@ pub(crate) async fn read_direct_project_history_slice(
project_path: String,
before_item_id: Option<String>,
limit: Option<usize>,
messages_only: Option<bool>,
) -> Result<DirectThreadHistorySlice, String> {
tauri::async_runtime::spawn_blocking(move || {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "conversation.read")?;
let read_slice = if messages_only.unwrap_or(false) {
read_direct_project_chat_items_slice_at
} else {
read_direct_project_history_items_slice_at
};
let (items, has_more, item_timestamps) =
read_slice(root, before_item_id.as_deref(), limit.unwrap_or(20))?;
let oldest_item_id = items
let (items, has_more, item_timestamps) = read_direct_project_history_items_slice_at(
root,
before_item_id.as_deref(),
limit.unwrap_or(20),
)?;
let first_item_id = items
.first()
.and_then(|item| item.get("id"))
.and_then(serde_json::Value::as_str)
.filter(|id| !id.is_empty())
.map(str::to_string);
let items = direct_thread_items_from_history(root, &items, |item| {
item.get("id")
.and_then(serde_json::Value::as_str)
.and_then(|id| item_timestamps.get(id).copied())
.unwrap_or_default()
});
Ok(DirectThreadHistorySlice {
items,
has_more,
item_timestamps,
oldest_item_id,
first_item_id,
})
})
.await

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