Compare commits

..

116 Commits

Author SHA1 Message Date
k88936 38ad2ce256 抽离项目开发 DirectProject 聊天模块
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 5m42s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 5m31s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 6m9s
Project CI / Backend tests (pull_request) Failing after 30s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m16s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m54s
Project CI / Repository checks (pull_request) Failing after 10s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 5m32s
Project CI / Frontend tests (pull_request) Failing after 16m6s
Project CI / Native shell tests (pull_request) Successful in 24m45s
Project CI / AI game creator shell web tests (pull_request) Failing after 24m28s
将 DirectProject 表现与 controller 移到 view/project-development/chat

删除运行时 Direct feature flag 与 ProjectSupervisorView 的 Direct 行为分支

迁移 ts-rs 生成契约并删除无意义 generated/index.ts

将钱包入口提升到工作台布局并保留现有 Direct 测试覆盖
2026-09-18 22:06:15 +08:00
k88936 65f8e44a88 拆分 DirectProject 聊天架构文档
记录 DirectProject 独立聊天容器与工作台钱包布局决策

更新实施里程碑、计划和项目上下文,明确 DirectProject 固定属于项目开发工作台

记录代码与生成契约迁移的验收边界
2026-09-18 21:04:23 +08:00
k88936 ade1b7803e 修复 DirectProject 校验清单合并
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 7m3s
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 7m25s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 7m30s
Project CI / Backend tests (pull_request) Failing after 11s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 7m41s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m9s
Project CI / Repository checks (pull_request) Failing after 10s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m40s
Project CI / Frontend tests (pull_request) Failing after 7m45s
Project CI / AI game creator shell web tests (pull_request) Failing after 8m31s
Project CI / Native shell tests (pull_request) Successful in 13m1s
让校验函数返回已读取的项目清单

补齐附件在 Codex 输入转换中的分支;图片 part 已退役,只保留附件 part
2026-09-18 14:27:23 +08:00
k88936 71000b6df1 通过 Tauri command 提供 Skill 候选
从已校验的 AGC Skill manifest 派生 list_agc_skill_catalog 命令

移除前端内置 Skill 名称硬编码并增加读取命令测试
2026-09-18 14:27:23 +08:00
k88936 c047543825 复用 DirectProject 引用摘要构造
提取素材与运行区域摘要 helper

让历史投影和 turn 输入转换共用同一份摘要逻辑
2026-09-18 14:27:21 +08:00
k88936 34b3af1d2a 复用已校验的项目清单
DirectProject wire 转换只读取一次项目 manifest

避免每个素材引用重复 I/O 与解析并缩小校验到解析之间的竞态窗口
2026-09-18 14:26:50 +08:00
k88936 f7cba30b6b 拒绝保留路径段的 Skill 名称
在 DirectProject Skill 校验中拒绝 . 与 ..

阻止 Skill 路径解析通过父目录段逃逸配置根目录
2026-09-18 14:26:50 +08:00
k88936 273f12633c 按字符数限制 Skill 名称
使用 Unicode 字符数量而非 UTF-8 字节长度执行 120 字符上限

让多字节 Skill 名称与资源 ID 的长度校验口径一致
2026-09-18 14:26:50 +08:00
k88936 5f5aa17152 展开引用列表指纹分支
将资源、Skill 与运行区域 key 生成改为显式分支

移除 chatReferenceListKey 的嵌套三元表达式
2026-09-18 14:26:50 +08:00
k88936 3a57d9fbdf 移除未使用的 JSON 类型导入
删除 codex_app_server 中未被直接引用的 serde_json::Value 导入
2026-09-18 14:26:50 +08:00
k88936 382b925ab9 复用引用节点插入逻辑
统一资源与 Skill 候选的 Lexical 节点插入流程

避免两个选择处理器在删除查询和关闭菜单行为上漂移
2026-09-18 14:26:50 +08:00
k88936 c1482012c6 整理引用芯片标题分支
提取资源、Skill 与运行区域标题计算 helper

移除 ResourceReferenceChip 的嵌套三元表达式
2026-09-18 14:26:50 +08:00
k88936 c3a17a6efc 更新Skill提及里程碑状态
标记输入提示实现进入进行中阶段
2026-09-18 14:26:50 +08:00
k88936 1bfdc3a760 接通Skill提及Codex输入
将 canonical Skill part 按原顺序转换为 type skill 输入项

沿用客户端隔离 Skill root 解析受控路径
2026-09-18 14:26:50 +08:00
k88936 1e27cd229d 接入客户端已启用Skill候选
从扩展索引读取启用状态并合并内置 Skill 清单

输入提示只展示可用 Skill 并保持取消安全
2026-09-18 14:26:49 +08:00
k88936 774710452f 校验Skill提及用户输入
为 canonical Skill part 增加名称边界校验

将 Skill 提及保留为 Codex 可识别的 $name 文本
2026-09-18 14:26:49 +08:00
k88936 aaecb82622 增加输入框Skill提及候选
扩展聊天引用模型支持 Skill 节点与 canonical content

复用 Lexical typeahead 增加 $Skill 候选和芯片展示

 Conflicts:
	apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx

 Conflicts:
	apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx

 Conflicts:
	apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx
	apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts
2026-09-18 14:26:49 +08:00
k88936 64ad24ac77 补充DirectProject Skill提及输入规范
更新聊天输入主规范并增加 Skill 提及边界

新增 Skill 提及里程碑与小切片实施计划
2026-09-18 14:26:49 +08:00
k88936 75ec3361dc 限制附件元数据格式与长度
校验 inline 附件名称和媒体类型的控制字符、格式与长度。

共享附件清洗上限常量并补充异常元数据拒绝测试。
2026-09-18 14:12:47 +08:00
k88936 bc1dc868a1 校验已导入附件的项目路径
imported 附件必须携带有效非空的项目相对路径。

failed 附件仍允许无路径,并补充状态路径组合测试。
2026-09-18 14:07:07 +08:00
k88936 50204ff7aa 限制 DirectProject inline 附件数量
为 canonical 附件增加独立的八个条目上限。

补充超限附件拒绝测试,避免绕过素材引用数量门禁。
2026-09-18 14:03:30 +08:00
k88936 70fa160819 清洗附件摘要元数据
wire 投影复用附件名称与媒体类型的安全清洗。

附件项目路径输出归一化结果,避免原始控制字符进入模型提示。
2026-09-18 14:00:00 +08:00
k88936 2e78cbf152 Merge remote-tracking branch 'refs/remotes/origin/master' into fix/ref-persistent
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 7m18s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 7m22s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 7m30s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 7m41s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m56s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m25s
Project CI / Repository checks (pull_request) Failing after 4m0s
Project CI / Frontend tests (pull_request) Failing after 4m50s
Project CI / Native shell tests (pull_request) Successful in 8m29s
Project CI / Backend tests (pull_request) Successful in 11m23s
Project CI / AI game creator shell web tests (pull_request) Failing after 5m16s
2026-09-18 13:29:58 +08:00
k88936 840baf208f 移除草稿转换的文本引用回退
将 ChatComposerDraft.content 设为必填的 canonical 唯一来源。

删除 text 与 references 重建 DirectCodexUserItem 的兼容分支及其无效空值兜底。
2026-09-18 13:19:01 +08:00
k88936 d8af0fa882 保留聊天草稿中的附件引用节点
为 agc_attachment_reference 增加 Lexical 附件节点与可移除 chip。

恢复初始 canonical content 时不再静默丢弃附件,并让编辑器读回完整附件 part。
2026-09-18 13:17:31 +08:00
k88936 faed0ec251 移除 DirectProject 入口的重复回合审计
DirectProject 继续以 project.jsonl 作为完整回合记录。

入口不再创建 DirectCodexTurnAudit,保留底层可选审计接口供独立测试和其它路径使用。
2026-09-18 12:56:43 +08:00
k88936 6e63a118e0 延后资源草稿直到素材索引可用
避免初始编辑器挂载时 assetsById 为空导致资源引用永久丢失。

保留 Lexical 节点自行解析资产名称的后续 TODO。
2026-09-18 12:53:44 +08:00
k88936 4ae5ab14fa 移除队列标签不可达兜底分支
队列标签统一复用 canonical content 的文本派生。

删除不会被现有引用类型触发的附件和素材兜底文案。
2026-09-18 11:36:10 +08:00
k88936 b73a329b2c 修复附件场景下历史命令识别
使用不含附件的文本输入判断 /history 命令。

避免待发送附件把 canonical prompt 拼成长文本后绕过历史记录加载。
2026-09-18 11:35:18 +08:00
k88936 06004bfeea 清理资源引用标签的重复分支
移除相同结果的死三元分支。

保持资源与运行区域引用均使用统一的 @标签 文本。
2026-09-18 11:34:35 +08:00
k88936 ecea7b4ad3 解决 DirectProject 合并冲突
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 6m2s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 5m54s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m20s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m31s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 5m3s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 4m49s
Project CI / Frontend tests (pull_request) Failing after 6m56s
Project CI / Repository checks (pull_request) Failing after 6m23s
Project CI / Native shell tests (pull_request) Successful in 10m17s
Project CI / Backend tests (pull_request) Successful in 11m8s
Project CI / AI game creator shell web tests (pull_request) Failing after 6m0s
合并双方 DirectProject 历史分页、锚点闸门与订阅竞态处理。

保留 canonical content 输入、聊天 reducer、附件发送和历史分页行为。

合并 Codex 用户回显过滤、增量脱敏及 Direct 回合完成状态处理。

补齐历史截断、损坏行 fail-closed、首屏 anchor 与 appSurface 定向测试。

同步合并应用壳层、后端绑定和项目文档变更。
2026-09-18 10:47:55 +08:00
k88936 b1e82d64cb 合并 DirectProject 聊天真相源与 canonical content 行为
- 统一历史切片与 Thread Manager 事件投影,保留 DirectProject 单一聊天事实源

- 保留 canonical content 严格校验、资源显示名派生、附件引用与队列语义

- 修复首页首轮缺失 canonical user item 的真实回归并完成定向验证
2026-09-18 02:49:09 +08:00
k88936 b7622f4a24 Merge remote-tracking branch 'origin/master' into fix/chat-status-lost
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 1/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 2/4 (pull_request) Has been cancelled
# Conflicts:
#	docs/project-memory/shared-memory/pitfalls.md
2026-09-17 16:57:47 +08:00
k88936 cb840c6e8c Merge branch 'master' into fix/chat-status-lost
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Has been cancelled
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
# Conflicts:
#	docs/project-memory/shared-memory/decision-log.md
#	docs/project-memory/shared-memory/pitfalls.md
2026-09-17 16:53:18 +08:00
k88936 0a9cdf6395 补 DirectProject 历史分页连拉的实现证据
里程碑前端连拉那条转成已验收,证据指向新单测与跨页同回合集成用例
证据要求补上本轮实跑结果与变异验证现象,避免再出现打勾但只覆盖一半
决策记录把验证口径写成具体条数与变异验证结论
2026-09-17 16:51:30 +08:00
k88936 e382d23892 补跨页同回合的历史分页回归用例
用 51 条历史复现用户报的现象:20 条工具条目整屏落进同一个折叠的执行过程
断言一次点击连拉两页拿到新用户气泡,并按 process-31 / process-11 两次锚点推进
断言连拉插入旧内容后视口仍停在原处,不被拉回列表底部
2026-09-17 16:49:08 +08:00
k88936 37ae7508b8 App 聊天历史分页改走连拉循环
首屏与「显示更早」共用 readDirectHistoryPages,不再各自单发一页
首屏基线取空并在守卫后一次性并入聊天 reducer,失败仍按原有语义抛出
「显示更早」按当前视图条目判断新回合,取到多页一次性并入并推进锚点
连拉中途失败保留已取到的页,报错统一走同一个出口
2026-09-17 16:39:24 +08:00
k88936 1ae6c6cd06 新增 DirectProject 历史分页连拉模块与单测
directHistoryPaging 按「合并后聊天投影回合数增加」判断可见反馈,出现新回合或 hasMore=false 即停
连拉上限 DIRECT_HISTORY_MAX_PAGES_PER_ACTION 放进 app/constants,每个操作最多 5 次请求
锚点未推进(items 为空 / firstItemId 为 null 或原地打转)时立即停手并收口 hasMore
某页读取失败不丢已取到的页,返回 error 交给调用方报错
directThreadChat 抽出 projectDirectHistoryItems,让分页判据与视图共用同一份投影
2026-09-17 16:27:09 +08:00
k88936 459f8a60e5 文档先行:定死 DirectProject 历史分页连拉的可见性口径
把「可显示」判据明确为合并后聊天投影出现新回合,并写清每操作 5 页上限与锚点不前进即停
里程碑把原第 56 条按后端锚点回扫与前端连拉拆成两条独立验收项,前端那条先留待补证据
实施计划第 6 步同步口径,并把新分页单测加入验证命令
决策记录新增 2026-09-17 条目,记下落点、上限粒度、失败与竞态边界
2026-09-17 16:18:51 +08:00
k88936 63c495dab0 DirectProject 不再把 Codex 回显的用户消息下发给聊天
- 运行态条目投影新增回显过滤 direct_thread_visible_item,item/started 与 rawResponseItem/completed 两条路径共用
- is_direct_project_codex_user_item 提升为 pub(crate),落盘侧与运行态侧共用同一判据
- 新增 E2E direct_project_turn_does_not_forward_codex_user_echo_as_chat_items:修复前事件流会多出两条同文本用户条目(已做变异验证)
- 同步 ADR 与 shared-memory decision-log:回显过滤必须同时覆盖落盘与运行态两侧
2026-09-17 14:41:58 +08:00
k88936 b37d91882c Revert "修复DirectProject对话历史分页卡页"
This reverts commit 1b010fb1f8.
2026-09-17 14:38:39 +08:00
k88936 dd339638cc Revert "修复历史重读后旧消息追加到末尾的乱序"
This reverts commit 036395c6c9.
2026-09-17 14:38:22 +08:00
k88936 0caf998227 重新生成 ts-rs 前端绑定并让前端按 null 语义构造运行时区段
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 6m35s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 5m31s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m15s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 5m47s
Project CI / Backend tests (pull_request) Failing after 11s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 4m15s
Project CI / Repository checks (pull_request) Failing after 19s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m15s
Project CI / Frontend tests (pull_request) Failing after 5m44s
Project CI / AI game creator shell web tests (pull_request) Successful in 5m49s
Project CI / Native shell tests (pull_request) Successful in 10m42s
- 跑 export_bindings 后 prettier 格式化:DirectCodexUser* 5 个文件内容更新,新增 DirectCodexUserMessageEnvelope.ts 与 ui-editor 的 BindingChange/BindingDTO
- DirectCodexUserRuntimeRegionPart 的可选字段由 `?:` 变成 `T | null`(Rust Option 会显式序列化成 null,不是省略键)
- chatReferenceToContentPart 改用 `?? null`,消息条目的 satisfies 目标改为 DirectCodexUserItem(type:'message' 归到信封类型)
- ResourceReferenceInput 不再复制一份区段字面量,改为复用 chatReferenceToContentPart
- 验证:tsc --noEmit 通过;resourceReferences 等 5 个用例集 81 passed;appSurface 452 passed / 17 skipped(连跑两次)
2026-09-17 14:03:05 +08:00
k88936 6eed6bd13f 收敛 content→可读文本为单一口径并强制传 manifest(review 第 2、3 条)
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 7m10s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 6m43s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 6m28s
Project CI / Backend tests (pull_request) Failing after 15s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 4m13s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m0s
Project CI / Repository checks (pull_request) Failing after 12s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m58s
Project CI / Frontend tests (pull_request) Failing after 4m24s
Project CI / AI game creator shell web tests (pull_request) Failing after 4m2s
Project CI / Native shell tests (pull_request) Successful in 8m31s
- directCodexContentToPromptText 的 assets 改为必填,去掉 = [] 默认值:漏传素材清单从运行期文案退化变回编译期错误
- queuedChatTurnLabel 的 assets 与 ComposerTurnQueue 的 assets 属性同样必填,队列 chip 与消息正文共用同一个派生(chip 只多做压单行与限长)
- 断言改为显式传 [] 或 manifest:resourceReferences、chat-composer 用例按新签名收敛
- 更新 pitfalls 条目与 decision-log(canonical content 有效性只判整条 content、投影逐字透传、可读文本单一口径必填 assets)
2026-09-17 14:02:21 +08:00
k88936 b543a060b7 放宽 DirectProject content 校验为「整条判定」并按逐字投影(review 第 1 条,① 口径)
- Rust validate_direct_codex_user_item 改为只判整条 content:有一段非空白文本或任意非文本 part 即有效,单个纯空白 input_text(段落分隔 / 软换行 / chip 后的分隔空格)合法,不再逐个 part 拒绝空文本
- 新增 content_has_meaningful_input,并与前端 hasMeaningfulDirectCodexContent 同口径;wire.rs 的「不能转换为空 prompt」只作兜底
- ResourceReferenceInput 的投影层原样透传编辑器节点:不做空白过滤、也不与相邻 part 合并,删掉 appendInputText 与中间版本的「向前合并」收集器
- 按逐字投影更新资源输入、Godot 回合用例的 canonical content 断言
- 同步里程碑实现结论与证据、pitfalls 条目;Rust validation/wire 新增「单个纯空白 part 通过、整条全空白拒绝」用例
2026-09-17 14:01:41 +08:00
k88936 9993b2feb5 通知分类收敛成生产读取器与单测共用的唯一实现
- direct_codex_notification_event 收编 resolution / request / item / rawItem / Terminal 全部分支,并接收 turnId
- 读取器删掉自己那份内联分类,只保留「必须有 turnId 才处理」与活动/正文节流
- agentMessage 缺 itemId 的告警与 direct-missing-item:{turnId} 兜底身份原样保留,避免行为变化
- 新增 direct_notification_classification_covers_the_reader_branches 钉住各分支;E2E 假 app-server 用例继续覆盖读取器
2026-09-17 13:47:32 +08:00
k88936 d348cf9064 线程 id 只按路径归一,不再受 manifest 可读性影响
- direct_thread_id_for_project 改用 resolve_direct_codex_project_authority(存在/是目录/canonicalize)
- 不再读 .agent/manifest.json:那次读取是连接池权威身份的要求,线程 id 只是路径 key
- 否则 manifest 在订阅时读失败、跑回合时读成功,会让符号链接等非 canonical 调用方路径算出两个线程 id,订阅绑到空线程
- 新增 direct_project_thread_id_ignores_manifest_readability,旧实现下会退回调用方原始字符串(已做变异验证)
2026-09-17 13:42:35 +08:00
k88936 e640219243 思考正文改走生产路径的 ReasoningDelta 通道
- 新增 direct_codex_reasoning_delta_event,两条 reasoning 增量通知只在这一处分类
- 生产 stdout 读取器改用该函数,并跳过「preparing 活动」的降级与活动节流(活动节流会按类别吃掉逐段正文)
- 旧分类函数 direct_codex_notification_event 同步改用它,避免两份实现再次分叉
- 单测 direct_reasoning_deltas_stream_text_while_plan_and_command_output_stay_activity 取代原先断言 Activity 的旧用例
- 新增 E2E codex_app_server_streams_reasoning_deltas_without_activity_fallback:把读取器的路由关掉即转红(已做变异验证)
2026-09-17 13:22:36 +08:00
k88936 da2ad83c3b 分页锚点改用文件里的原始 item id
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 6m22s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 6m34s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 6m49s
Project CI / Backend tests (pull_request) Failing after 10s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 4m49s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m7s
Project CI / Repository checks (pull_request) Failing after 10s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m19s
Project CI / Frontend tests (pull_request) Failing after 6m15s
Project CI / AI game creator shell web tests (pull_request) Failing after 5m47s
Project CI / Native shell tests (pull_request) Successful in 12m22s
- 新增 direct_project_history_anchor_id:优先取 project.jsonl 原始 id,缺 id 时退回归一身份兜底
- read_direct_project_history_items_slice_at 的锚点匹配与 first_item_id 都改用原始 id
- 工具条目的调用与输出共用 call_id,按归一身份当锚点会先跳过 output 再把上一屏的 function_call 带回来
- 原分页用例更名 history_window_paginates_upwards_by_item_id_anchor
- 新增 pagination_anchor_uses_raw_item_id_for_tool_call_pairs,旧实现下会返回 call-1 而非 fc-1(已做变异验证)
2026-09-17 00:24:01 +08:00
k88936 ad19c9475e 回扫的 terminated 改成按真实换行结尾判定
- DirectProjectHistoryReverseLines 增记 saw_delimiter,第一段按"文件是否以换行结尾"判定,之后的行按已切掉的分隔符判定
- 尾部残行(上一行完整、这一行没有换行)解析失败时改为跳过继续回扫,不再报错、也不再就地结束丢掉更早的完整历史
- 换行结尾的坏行(含文件第一段)与顺序读取保持一致地失败关闭
- 补两条回扫用例:尾部残行不丢更早条目、坏掉的第一段必须失败关闭(都按旧实现变异验证过)
2026-09-17 00:18:49 +08:00
k88936 975a2e577c 终止按钮改按综合繁忙信号判定
- handleCancelDirectCodexTurn 的守卫改用 supervisorChatBusy,与「终止」按钮的可见条件对齐
- 刚提交、turn.started 还没到达的窗口里点击终止不再回"当前没有正在运行的回合"
- chat-composer 用例补一条:生命周期事件缺席时也能发出 cancel_direct_codex_turn
2026-09-17 00:10:20 +08:00
k88936 1b40f030e7 删掉聊天 reducer 里没人读的订阅身份与首屏锚点
- DirectThreadChatState 去掉 subscriptionId 与 lastCompletedItemId 两个只写不读的字段
- resolveDirectThreadBootstrap 只把 bootstrap 事件 reduce 进状态,不再顺带写入这两个字段
- directThreadChat 单测改成断言聊天状态里不再出现这两个字段(bootstrap 载荷本身不变)
2026-09-17 00:05:57 +08:00
k88936 098693636a 分页切片开头的前导条目并进后面那个回合
- buildDirectChatTurns 先攒住第一个用户条目之前的过程条目,交给后面那个回合
- 不再渲染没有用户气泡的孤儿回合;整份历史都没有用户条目时仍保留一个回合
- directTurnPresentation 单测补一条分页切片从半截回合开头的情形
2026-09-17 00:02:11 +08:00
k88936 e17af3008e 首屏历史切片改到 staleness 守卫之后再并入聊天 reducer
- loadProjectConversation 不再在 read_direct_project_history_slice 的 then 回调里直接写 directThreadChat
- 切片先暂存,等 loadVersion / localProjectPathRef 守卫通过后再与 hasMore、锚点一起落到状态
- 现有 launcher 按项目路径 key 重挂载 App,跨项目污染暂不可复现;本次只是让这条写入与其它状态写入的时序一致
2026-09-16 23:59:29 +08:00
k88936 64bd006981 测试骨架的历史切片按 limit 与锚点分页
- harness.ts 的 read_direct_project_history_slice 按生产口径从队尾取一屏、锚点条目不进窗口
- 按本屏最老一条算 firstItemId,收满一屏后再有条目才算 hasMore
- project-development.suite.ts 补一条用例:26 条历史只显示尾部 20 条,点「显示更早的对话」按锚点取回前 6 条后按钮消失
2026-09-16 23:52:02 +08:00
k88936 6a4cb2bc18 测试骨架的 subscribe bootstrap 按生产口径过滤
- harness.ts 记录未完成条目集合、事件序号与最新生命周期锚点
- subscribe 只回放未完成条目与最新生命周期锚点,已完成条目与增量正文不再补发
- 去掉已下发事件的序号记录,避免序号表无限增长
2026-09-16 23:48:46 +08:00
k88936 1b3a3b003f 测试骨架不再重复下发 turn.started
- harness.ts 记录运行态"最新回合是否在跑",emitDirectThreadEvents 只在生命周期事件上更新
- completeDirectThreadTurn 仅在回合没在跑时补 turn.started,避免与用例先发的事件重叠
2026-09-16 23:45:19 +08:00
k88936 ff8e517c8e 清理 DirectProject 回合结束后丢弃回复正文的死语句
- App.tsx 不再绑定 chat_with_game_creator_direct_codex 的返回值,删掉 void reply
- 保留说明:回复正文按条目身份从线程事件 / 历史切片进聊天,本地不再补第二条
2026-09-16 23:42:58 +08:00
k88936 d874a13a92 补记 Node 26 下 vitest jsdom 用例的 localStorage 坑
- pitfalls 记录:Node 26 的实验性 globalThis.localStorage 会顶掉 jsdom 的 Storage,导致只声明 jsdom 的用例读到 undefined
- 给出跑法:NODE_OPTIONS=--localstorage-file=… 后 clientApi 与 chatPromptPolish 共 26 项转绿
- 里程碑证据更新为全量结果:125 files / 1676 passed / 17 skipped,tsc exit 0
2026-09-16 22:41:54 +08:00
k88936 5eba53a5cc 补齐 DirectProject 聊天真相源收敛的文档结论
- 主规范:运行态事件改为下发与历史切片同形的脱敏原始条目,不再只给类型与身份
- 主规范与 ADR 写清回执竞态:回执前到达的通知只能记欠账,回执到达后必须补一次 consume
- ADR 与实施计划说明 list_game_creator_direct_active_turns 有意保留,服务首页运行中项目列表
- 里程碑验收标准按自动化证据勾选,运行时验收与已知环境缺口单独标注
- 实施计划补 export_bindings 后需 prettier 生成目录、测试文件更名与竞态风险项
2026-09-16 22:16:24 +08:00
k88936 33ddb5de27 DirectProject 聊天框改用线程订阅作为唯一运行态来源
- App.tsx 删除 Direct 回合事件订阅、turn-stream/tool-calls 读取、活动回合快照接管与瞬时应答文本
- 新增 game-creator-direct-thread-notify 唤醒的 subscribe/consume 单飞循环,过期时重订一次
- 通知先于订阅回执到达时记一笔欠账,回执到达后补一次 consume,避免回合尾部事件卡在队列里
- 历史切片并入同一个 reducer,聊天条目只由 selectDirectChatEntries 投影
- ProjectSupervisorView 改为渲染用户气泡 / 执行过程折叠区 / 最终回复,运行态只保留一个 turnRunning
- directTurnPresentation 重写为条目分区:连续工具合块、本地运行期说明只作最终提示
- 测试改按订阅事件与历史条目断言,补回执竞态、持久化工具卡片、空对话首轮三条用例
2026-09-16 22:16:15 +08:00
k88936 bf8f96c115 DirectProject 回合事件补齐用户条目与终态兜底
- 新增 direct_thread_id_for_project:订阅、回合事件写入与兜底释放共用一个线程身份
- 订阅命令改用同一个身份函数,删掉命令层自己那套路径归一
- 用户消息落盘后立刻作为 item.completed 下发,前端用同一个 itemId 合并乐观气泡与历史条目
- 采集终态事件是否已经下发,缺失时按成功 / 失败补一条 turn.completed,避免前端永远停在运行中
- 取消回合的 Stale 分支补一条 turn.completed(aborted)
2026-09-16 22:15:57 +08:00
k88936 e469831543 修复快速编辑提示词把 @ 引用写成内部 id(review 第 3、4 条)
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 7m39s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 7m18s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 5m8s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m55s
Project CI / Backend tests (pull_request) Failing after 11s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 5m11s
Project CI / Repository checks (pull_request) Failing after 10s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m45s
Project CI / AI game creator shell web tests (pull_request) Successful in 5m10s
Project CI / Native shell tests (pull_request) Successful in 8m37s
Project CI / Frontend tests (pull_request) Successful in 8m46s
- applyResourceQuickEditPrompt 与 applyResourceQuickEditDraft 改用 directCodexContentToPromptText(content, manifest.assets),并在 useCallback 依赖里加上 manifest.assets
- 两处必须同口径:比较用的草稿文本与落进 quickEditPanel.prompt 的文本都按显示名展开,出站 derive_local_project_resource 的 prompt 与聊天 @ 逐字一致,也不会每次输入都重跑 replaceText
- 补充两处注释说明素材清单口径,shared-memory/pitfalls 记录「派生文本漏传 manifest.assets」的现象、原因、处理与验证
2026-09-16 21:26:53 +08:00
k88936 869abad879 修复排队消息 chip 的 @ 引用露出内部 resourceId(review 第 2 条)
- queuedChatTurnLabel 增加 assets 入参并透传给 directCodexContentToPromptText,@ 引用按 manifest 显示名展开,与聊天输入区文案口径一致
- ComposerTurnQueue 增加 assets 属性,ProjectSupervisorView 把 chatProjectAssets 传下去
- chat-composer 用例新增「队列 chip 的 @ 引用按 manifest 显示名展开」,钉住显示名文案与不传清单时的稳定 id 兜底
2026-09-16 21:25:38 +08:00
k88936 280a2dc5d1 修复 DirectProject 输入草稿丢掉引用后的换行(review 第 1 条)
- ResourceReferenceInput 的草稿投影改为「向前合并」:上一 part 是引用 chip 时先把待写文本攒在 pendingText 上,等下一个文本 part 到来再合并,段落分隔符与软换行不再被 trim() 丢掉
- 攒到最后仍只有空白的文本不入 content,避免产出 Rust validate_direct_codex_user_item 会拒绝的空 input_text
- 新增 resourceReferenceInput 用例「引用后面的段落分隔不会被吞掉」,钉住 chip 后一段的 content part、派生文本与「不产生纯空白 part」
- 同步更新 canonical content 严格边界里程碑的实现结论与证据,并在 shared-memory/pitfalls 记录该缺陷的现象、原因、处理与验证
2026-09-16 21:24:41 +08:00
k88936 3c6e084a41 DirectProject 历史切片改为从文件尾回扫并删掉聊天框只读命令
- 新增 DirectProjectHistoryReverseLines:按块从文件尾逐行回读 project.jsonl
- 幂等回扫与"读一屏"共用这一套尾部回读,不再各自从文件头读到尾
- read_direct_project_history_items_slice_at 语义不变仍返回元组,新增第四项为本屏最老的 itemId 作分页锚点
- 分页锚点只按归一身份 itemId 匹配,不再接受第二个 id
- read_direct_project_last_item_id_at 改为尾部回扫读一行,不再读整份历史
- 删除只服务聊天框回读的 read_direct_tool_calls / read_direct_turn_stream 命令与注册
- 保留 list_game_creator_direct_active_turns:它服务首页跨页面"运行中的项目",不是聊天框读路径
- 补历史切片尾部回扫与分页锚点两条用例
2026-09-16 20:17:15 +08:00
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 edfd1f9143 修正 Godot 直接回合用例对 canonical content 的断言
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 2/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 1/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
- appSurface Godot 用例不再断言被裁剪过的纯文本,改为断言编辑器实际提交的 canonical content(Shift+Enter 与组合态 Enter 插入的换行原样保留)
2026-09-16 18:51:08 +08:00
k88936 423f2d7184 修复独立总控聊天窗口未发送草稿的恢复
- App.tsx 增加 supervisorChatDraftHydratedRef 守卫的挂载水合:独立总控聊天窗口挂载走 loadProjectConversation,不经过 openWorkspace,此前 sessionStorage 里的草稿只写不读,重载后为空
- 仅在 supervisorChatOnly、存在持久化草稿且输入框已挂载时回填一次,避免覆盖用户后续输入
2026-09-16 18:50:55 +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 aa9076c95c 删除 DirectProject 图片专用 content part
- 移除 DirectCodexUserContentPart 的 agc_image_reference 分支与对应 ts-rs 绑定
- Rust 校验与 Codex wire 投影只保留 agc_attachment_reference
- 更新附件顺序投影测试的用例名、输入与断言
- 同步聊天素材功能说明、用户 Response item 里程碑与实施计划
- 在决策日志记录上传文件统一走附件 part、不保留图片兼容分支
2026-09-16 18:29:24 +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 41366dd71d 修复DirectProject canonical内容边界(review 第 8 条)
- 移除 chatComposerDraftToDirectCodexUserItem 对纯空白 input_text 的预过滤,原样传递 draft.content
- ChatComposerDraft 只保留 canonical content,删除 text / references 旧字段
- 新增 directCodexContentToPromptText、hasMeaningfulDirectCodexContent、directCodexUserItemFromContent 供 caller 与展示派生
- 删除 executeChatAgentReply 的 userItem 兜底分支,首页首轮、队列出队、普通提交与策略重试显式构造 canonical user item
- QueuedChatTurn 只保存 clientTurnId 与 userItem,队列 chip 文案由 content 派生
- 旧 Planner / legacy Supervisor caller 显式构造纯文本 item 并留下迁移 TODO
- 资源输入区对外只暴露 canonical content,内部文本草稿改为文本 → content 重建
- 迁移草稿、润色、队列与 appSurface 测试 fixture 到 content-only
- 更新 canonical content 里程碑与实施计划文档结论
2026-09-16 17:46:41 +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
k88936 f4b496caca 补充DirectProject canonical内容严格边界计划
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 2/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 shard 1/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Has been cancelled
- 明确 content-only 输入与最终空内容校验

- 规划删除 userItem 兜底并修正 Direct caller
2026-09-16 15:28:25 +08:00
k88936 fc0fd846f9 避免快速编辑润色回填重复更新
- 回填前比较编辑器当前文本

- 防止输入区 onChange 与宿主回调形成循环
2026-09-16 13:22:57 +08:00
k88936 fa99d9ad62 避免总控草稿初始化误删
- 编辑器尚未恢复时跳过空草稿持久化

- 保留已有会话草稿等待输入区完成恢复
2026-09-16 13:20:47 +08:00
k88936 64a52005f8 修复清空草稿后润色状态残留
- 在编辑器发出空草稿时重置润色结果

- 清除本轮发送提醒确认标记
2026-09-16 13:20:01 +08:00
k88936 645d806fed 修复空初始草稿吞掉首次编辑
- 仅在初始内容实际非空时跳过初始化回调

- 保证快速编辑首次输入同步到宿主状态
2026-09-16 13:19:13 +08:00
k88936 0b9d16e8f1 修复润色按钮无法感知输入
- 用 React 状态同步编辑器当前文本

- 让按钮禁用态随用户输入即时更新
2026-09-16 13:18:31 +08:00
k88936 d5c4e0e780 修复快速编辑润色结果未回填
- 为快速编辑输入区绑定编辑器句柄

- 润色回调同时更新面板状态与编辑器文本
2026-09-16 13:17:50 +08:00
k88936 30e27abc60 修复建议命令草稿残留引用
- 准备命令草稿前先清空编辑器

- 避免提交用户上一轮选择的素材引用
2026-09-16 13:16:07 +08:00
k88936 b607501916 修复总控草稿恢复后被清空
- 仅在非纯聊天模式清空聊天编辑器

- 保留纯聊天模式恢复的会话草稿
2026-09-16 13:15:08 +08:00
k88936 46e3cd9744 内联 DirectProject 附件与图片内容
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 5m30s
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 5m35s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 5m54s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 6m1s
Project CI / Backend tests (pull_request) Failing after 9s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m52s
Project CI / Repository checks (pull_request) Failing after 10s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m41s
Project CI / Frontend tests (pull_request) Failing after 3m54s
Project CI / AI game creator shell web tests (pull_request) Failing after 3m41s
Project CI / Native shell tests (pull_request) Successful in 6m9s
新增附件与图片 canonical content part 及 ts-rs 绑定

移除 DirectProject Tauri 附件 sidecar 追加路径

修正前端引用字段与生成类型适配

补充附件图片顺序投影测试并同步里程碑文档
2026-09-16 13:03:11 +08:00
k88936 5fb3db662b 合并主线并保留 DirectProject 输入行为
合并主线回合队列、工具流展示与活动回合管理

保留 Lexical 单一输入状态及附件图片 sidecar 投影

收敛 DirectProject user item 校验、历史恢复与图片预算

同步输入链路里程碑和历史异常恢复文档
2026-09-16 11:27:09 +08:00
k88936 87b798322e 收敛输入编辑器单一状态并补齐附件投影
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 2m6s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Failing after 1m49s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Failing after 1m56s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Failing after 2m2s
Project CI / Backend tests (pull_request) Failing after 11s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m19s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m46s
Project CI / Repository checks (pull_request) Failing after 15s
Project CI / Frontend tests (pull_request) Failing after 3m54s
Project CI / AI game creator shell web tests (pull_request) Failing after 3m13s
Project CI / Native shell tests (pull_request) Successful in 7m14s
聊天与快速编辑输入统一由 Lexical 编辑器持有唯一当前状态

App 通过输入句柄读取草稿,移除跨视图 EditorState 和字符串镜像

DirectProject 将附件与图片 sidecar 投影纳入 prompt,支持附件-only 输入

迁移润色测试并更新 DirectProject 里程碑验收记录
2026-09-15 22:13:25 +08:00
k88936 ec286b3480 抽取通用富文本输入组件
新增可复用的 RichTextInput Lexical 输入外壳。

让首页输入与资源引用输入共享编辑器、回车提交和禁用状态。

保留首页附件与图片粘贴节点能力,并移除 DirectProject 草稿 legacy fallback。

统一聊天草稿 canonical content 状态并更新资源引用测试。
2026-09-15 19:42:37 +08:00
k88936 35733f0e33 过滤 canonical 草稿中的空白输入 part
在统一 user item 转换入口移除纯空白 input_text,覆盖恢复草稿和所有提交路径。
2026-09-15 17:54:00 +08:00
k88936 3c7b02b9f8 过滤 DirectProject 编辑器的空白内容 part
跳过纯空白文本节点并合并相邻文本,避免引用后的分隔空格和换行触发 Rust 空输入校验。
2026-09-15 17:53:33 +08:00
k88936 f2030a616f 复用 DirectProject 用户输入校验读取的 manifest
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 1m44s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Failing after 2m19s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Failing after 2m19s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Failing after 2m17s
Project CI / Backend tests (pull_request) Failing after 11s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m17s
Project CI / Repository checks (pull_request) Failing after 10s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m26s
Project CI / Frontend tests (pull_request) Failing after 3m5s
Project CI / AI game creator shell web tests (pull_request) Failing after 2m51s
Project CI / Native shell tests (pull_request) Successful in 6m21s
让校验返回已读取的 manifest,wire 投影复用同一份数据,避免一次输入重复读取项目清单。
2026-09-15 17:33:49 +08:00
k88936 b3b5d77990 按运行模式创建 DirectProject 回合输入
仅在 DirectCodex 产品运行时生成 direct clientTurnId 和 user item,避免旧 Supervisor 与策划链路复用错误身份。
2026-09-15 17:32:46 +08:00
k88936 1da106af7d 避免重复校验 DirectProject 用户输入
复用 canonical 到 prompt 投影函数中的校验,删除不可达空 prompt 分支。
2026-09-15 17:29:08 +08:00
k88936 cd2edd6966 移除未使用的 DirectProject 输入信封类型
删除未被调用且未生成到前端绑定的 DirectCodexUserMessageEnvelope,收窄内部 API。
2026-09-15 17:27:42 +08:00
k88936 22e830ff93 避免 DirectProject 回合输入的无必要克隆
在互斥的观察器分支中移动 optional user item,避免深层 JSON 克隆。
2026-09-15 17:26:34 +08:00
k88936 c266ae7b50 清理策划模式提交后的聊天草稿
规划 V2 提交时同步清空资源引用和 canonical content,避免切回 DirectProject 后复用旧输入。
2026-09-15 17:25:15 +08:00
k88936 a543b75cf7 拆开聊天草稿内容分支判断
将资源草稿转换的三种回退路径改为显式分支,保持现有优先级并提升可读性。
2026-09-15 17:24:05 +08:00
k88936 f213987f9a 复用资源引用的统一内容映射
让 Lexical 草稿读取复用 chatReferenceToContentPart,避免引用字段映射分叉。
2026-09-15 17:22:55 +08:00
k88936 2c623bb577 移除 DirectProject 回合输入的重复赋值
删除已在对象初始化时设置的 userItem 重复赋值。
2026-09-15 17:19:17 +08:00
k88936 3353906e6f 修正 Response item content 类型投影
区分 turn/start 的 text 与历史 message 的 input_text

将 AGC 私有引用转换为标准 input_text

新增回归测试避免未知 variant text
2026-09-15 16:57:04 +08:00
k88936 17716347e2 收紧历史测试与注释口径
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Failing after 2m17s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Failing after 2m7s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m7s
Project CI / Backend tests (pull_request) Failing after 19s
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 2m19s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Failing after 2m17s
Project CI / Repository checks (pull_request) Failing after 11s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m26s
Project CI / AI game creator shell web tests (pull_request) Failing after 3m43s
Project CI / Frontend tests (pull_request) Failing after 4m6s
Project CI / Native shell tests (pull_request) Successful in 7m29s
移除 legacy 投影描述

将历史测试统一为 Response item 合同
2026-09-15 16:16:03 +08:00
k88936 9ad66a67a3 接入前端 canonical 用户 Response item
按 Lexical 顺序构造 message.content

DirectProject payload 改为发送 userItem

策略重试保留同一 canonical item
2026-09-15 16:12:56 +08:00
k88936 ba3aa3ccdb 更新用户 Response item 里程碑进度
标记 canonical、wire 与失败关闭验收项

同步实施计划为进行中状态
2026-09-15 16:09:28 +08:00
k88936 2b38eaafba 补充前端用户 Response item 契约测试
覆盖 Lexical 文本与引用交错顺序

验证资源引用只发送稳定 resourceId
2026-09-15 16:08:34 +08:00
k88936 7d5b9071e7 删除旧 DirectProject 引用协议
移除 DirectCodexTurnReference 平行 DTO 与渲染路径

将引用数量上限收回 canonical user item 校验模块
2026-09-15 16:01:45 +08:00
k88936 9e83f1d88c 补齐 Response item 严格合同测试
拒绝资源引用额外身份字段与未知 content part

补充历史 item wire 投影边界测试

同步 DirectProject 历史文档为无 fallback 合同
2026-09-15 15:55:15 +08:00
k88936 7e35d7c344 严格使用 DirectProject Response item 历史
移除 game-creator-conversation.v1 读取 fallback

历史注入前逐项投影 canonical user item 为 Codex message

无效 item 在持久化前失败关闭
2026-09-15 15:42:27 +08:00
k88936 5ec40c8b83 抽离 DirectProject 身份解析模块
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 2m16s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Failing after 2m20s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Failing after 2m4s
Project CI / Backend tests (pull_request) Failing after 18s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Failing after 1m54s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m22s
Project CI / Repository checks (pull_request) Failing after 9s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m20s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m36s
Project CI / Frontend tests (pull_request) Successful in 3m45s
Project CI / Native shell tests (pull_request) Successful in 5m27s
将项目 canonical identity 与 digest 放入独立文件

保持 Codex app-server 线程池调用接口不变
2026-09-15 15:22:39 +08:00
k88936 948a80fc49 重构 DirectProject Agent 深模块
将 direct_runtime 与 codex_app_server 改为目录模块

拆分 user item 的模型、校验与 wire 投影文件

抽离 DirectProject 历史注入载荷构造 seam
2026-09-15 15:19:17 +08:00
k88936 dfd6fadedf 新增 DirectProject 用户 Response item 契约
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 4m35s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 4m34s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m36s
Project CI / Backend tests (pull_request) Failing after 10s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 4m31s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 4m46s
Project CI / Repository checks (pull_request) Failing after 10s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m32s
Project CI / Frontend tests (pull_request) Successful in 4m0s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m34s
Project CI / Native shell tests (pull_request) Successful in 6m35s
新增 Rust canonical user item 与 Codex wire 转换模块

增加 ts-rs 绑定目录及生成文件检查边界

更新聊天素材引用规范与里程碑实施计划
2026-09-15 14:45:58 +08:00
174 changed files with 4834 additions and 19959 deletions
+8
View File
@@ -174,6 +174,14 @@ _Avoid_: mock 先行堆积、前后端各自发散、先做排行榜 UI
## 项目开发对话(DirectProject
**DirectProject 专属聊天模块**:
AGC 普通项目聊天的独立容器,拥有 DirectProject 的聊天状态、运行态订阅、历史读取、发送队列、附件和中止交互,并把聊天投影交给专属表现层渲染;它不承接 Supervisor、Design Agent 或 Planning V2 的运行态。
_Avoid_: 把 DirectProject 作为项目总控聊天的一个布尔分支、把四种 Agent 会话抽象成同一事实源
**项目工作台布局**:
承载本地项目的资源工作区、项目级工具和独立聊天产品路径的外层界面;布局拥有跨面板的账户/钱包入口,聊天模块只负责项目对话,不嵌套账户展示。
_Avoid_: 把钱包入口塞进聊天设置、让聊天组件拥有工作台级账户状态
**项目对话历史**:
AGC 本地项目内 Codex 原始对话条目的持久集合,是聊天展示、工具卡片和线程恢复注入的唯一持久事实源。
_Avoid_: 会话缓存、展示态历史、按 UI 需要另存的对话副本
@@ -14,9 +14,6 @@ Implement the user's actual game request in the current project as an npm-manage
3. Build with the project's npm script before previewing. The playable entry is the package directory's `dist/index.html`; never report an unbuilt bare-module page as playable. Import assets or configure public assets so all runtime media is included in dist; preview and exports cannot read outside it.
4. Build a complete playable loop: visible objective, responsive input, meaningful state changes, success or failure feedback, and a reliable restart path where the game needs one.
5. Fit the active game scene to desktop and mobile viewports without accidental page scrollbars. Reserve deliberate safe space for HUD elements instead of covering interactive content.
- **画布居中只能由一处负责。** 使用 `Phaser.Scale.FIT``autoCenter: Phaser.Scale.CENTER_BOTH` 时,canvas 的直接父容器应使用尺寸明确的普通块布局,不再对同一 canvas 叠加 Grid/Flex 居中、`place-items: center`、自动外边距或居中 transform。Phaser 自动计算的 margin 与 CSS 居中叠加会使竖屏画面向右偏移。
- 若决定由 CSS 居中,则显式使用 `autoCenter: Phaser.Scale.NO_CENTER`,由 CSS 独立完成定位;外围页面可以继续使用 Grid/Flex,限制只针对同一 canvas 的重复定位。
- 出现偏移先检查游戏自身的 CSS 与 Phaser scale 配置,不添加 AGC 预览容器固定偏移补偿。修改布局后重新构建 dist,在桌面、移动及窗口 resize 后检查 canvas 相对游戏父容器居中(误差不超过 1 CSS px)、画面完整且无意外滚动条;不能仅凭 build 成功宣称布局通过。
6. Invoke `taonier-art-assets` for every new game brief that needs visual assets. First reuse suitable registered Taonier art; when the brief's required visual elements are missing or unsuitable, call the reviewed `agc_tools` generation/edit workflow in the same task. After the tool returns, wire its relative paths into the game and verify the rendered result. A game with unused generated assets or placeholder emoji/CSS where requested art should appear is not complete. Load media defensively only for genuinely optional effects, and never relabel a local placeholder as platform art.
7. Let Phaser own the render loop and input dispatch. Avoid duplicate scenes, stale event listeners, and state that survives restart unintentionally.
8. After a meaningful game change, use the browser playtest Skill and fix issues shown by real evidence before reporting completion.
@@ -7,7 +7,5 @@
- Score, steps, health, timer, or other core state updates consistently.
- Restart restores all state and does not duplicate timers, animation loops, or event listeners.
- Desktop and mobile layouts keep the core scene visible without accidental document scrolling.
- 画布的缩放与居中由 Phaser 或 CSS 中的一方独立负责。`FIT + CENTER_BOTH` 不与同一 canvas 父容器的 Grid/Flex 居中、自动外边距或居中 transform 叠加;使用 CSS 居中时关闭 Phaser 自动居中(`NO_CENTER`)。
- 在构建后的实际页面检查桌面、移动和 resize:比较 canvas 与游戏父容器的中心,预期居中时水平/垂直误差不超过 1 CSS px,并检查画面没有溢出或意外滚动条。偏移先修游戏 CSS/scale 配置,不用修改 AGC 预览位置掩盖。
- HUD and overlays reserve space and do not cover essential interactive content.
- Requested Taonier art is visibly integrated into the core experience when available.
@@ -1,6 +1,6 @@
{
"schemaVersion": "agc-skill-pack.v1",
"version": "2026-08-26.25",
"version": "2026-08-26.24",
"skills": [
{
"name": "agc-game-production-workflow",
@@ -80,7 +80,7 @@
"agents/openai.yaml",
"references/game-quality-checklist.md"
],
"sha256": "e122d8f3a6d986b594b95c971754d68197bf7896912fa8267d44a7aa129a57ba"
"sha256": "05b5cfbf7a40fd303717491f5cea84ff339a73359c9678b283fd54d2b5c45efd"
},
{
"name": "agc-browser-playtest",
@@ -797,16 +797,6 @@ fn direct_thread_visible_item(
direct_thread_event_item(root, item)
}
/// AGC 预写的 canonical 用户条目 id`direct-codex:{clientTurnId}:user`。
///
/// 与 `direct_project_history::is_direct_project_codex_user_item` 的判据同一份口径(前缀 +
/// `:user` 后缀)。回合生命周期事件的 `userItemId` 只能来自这里或已落盘条目自身的 id;
/// clientTurnId 缺失时不猜身份,返回 `None` 让前端按"未知归属"处理。
fn direct_codex_user_item_id_for_client_turn_id(client_turn_id: &str) -> Option<String> {
let client_turn_id = client_turn_id.trim();
(!client_turn_id.is_empty()).then(|| format!("direct-codex:{client_turn_id}:user"))
}
fn direct_codex_command_is_game_verification(command: &str) -> bool {
let command = command.to_ascii_lowercase();
command.contains("game.static_smoke")
@@ -1143,7 +1133,6 @@ fn direct_codex_thread_delta_event(
) -> DirectThreadEvent {
DirectThreadEvent::item_delta(item_id, kind, direct_thread_delta_text(root, delta))
}
/// 通知 → 回合事件的唯一分类函数:运行态读取器与单测共用这一份。
///
/// 读取器只负责"必须有 turnId 才处理"的前置条件与节流(活动 / 正文),分类不在这里之外
@@ -2937,7 +2926,7 @@ impl CodexAppServerConnection {
None => direct_project_local_message_item(
"user",
current_prompt,
direct_codex_user_item_id_for_client_turn_id(client_turn_id).as_deref(),
Some(&format!("direct-codex:{client_turn_id}:user")),
)
.map_err(platform_llm::LlmError::InvalidRequest)?,
};
@@ -2971,8 +2960,22 @@ impl CodexAppServerConnection {
codex_app_server_text_prompt(&request)
.map_err(platform_llm::LlmError::InvalidRequest)?
};
let input =
codex_app_server_turn_input(&request, &prompt, &self.inner.workspace_path).await?;
let input = if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
if let Some(item) = direct_user_item {
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 =
if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
Some(
@@ -3056,30 +3059,13 @@ impl CodexAppServerConnection {
};
turn_start_guard.armed = false;
let direct_thread_id = direct_thread_id_for_project(history_root);
// 回合边界的阶段时间:Turn 上游只有**秒**级 `startedAt` / `completedAt`,秒级截断
// 撑不起前端 0.1 秒粒度的展示,也可能让完成时刻落进该轮用户消息的同一秒、落在真实
// 发送时间之前,被判成无效边界后整轮新回合被吞掉。因此这里只在宿主处理对应阶段时取
// 毫秒钟(与条目侧"没有原生阶段时间就用宿主钟"同一口径),不再读上游秒字段。
let direct_turn_started_at_ms = direct_tool_call_now_ms();
// 本轮开口用户条目的 canonical id:只从已落盘的那条条目上读身份(`id`,工具条目才用
// `call_id`),不在事件侧重造一份。拿不到就留空,让前端按"归属不可证明"处理。
let direct_turn_user_item_id = direct_persisted_user_item
.as_ref()
.and_then(direct_thread_item_identity);
if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
append_direct_thread_event(
&direct_thread_id,
DirectThreadEvent::turn_started(direct_turn_started_at_ms)
.with_user_item_id(direct_turn_user_item_id.as_deref()),
);
append_direct_thread_event(&direct_thread_id, DirectThreadEvent::turn_started());
if let Some(user_item) = direct_persisted_user_item.as_ref() {
if let Some(entry_item) = direct_thread_event_item(history_root, user_item) {
// 这里的条目时间可能是启动应答后的观测时间;前端按同一用户条目身份
// 保留更早的真实发送时间,不用此事件时间覆盖它。
let user_item_at = entry_item.at();
append_direct_thread_event(
&direct_thread_id,
DirectThreadEvent::item_completed(entry_item, user_item_at),
DirectThreadEvent::item_completed(entry_item),
);
}
}
@@ -3218,14 +3204,9 @@ impl CodexAppServerConnection {
.map_err(platform_llm::LlmError::InvalidRequest)?;
direct_project_history.complete_item(&item);
if let Some(entry_item) = entry_item {
// `rawResponseItem/completed` 不带阶段时间,宿主处理到这条
// 通知的钟就是该阶段唯一可证明的时间。
append_direct_thread_event(
&direct_thread_id,
DirectThreadEvent::item_completed(
entry_item,
direct_tool_call_now_ms(),
),
DirectThreadEvent::item_completed(entry_item),
);
}
}
@@ -3354,19 +3335,9 @@ impl CodexAppServerConnection {
if let Some(entry_item) =
direct_thread_visible_item(history_root, item)
{
// `item/started` 的通知层带 `startedAtMs`:这是工具真正
// 开始的阶段时间,优先于条目展示时间与宿主钟。
append_direct_thread_event(
&direct_thread_id,
DirectThreadEvent::item_started(
entry_item,
direct_thread_item_event_at_ms(
&params,
item,
false,
direct_tool_call_now_ms(),
),
),
DirectThreadEvent::item_started(entry_item),
);
}
}
@@ -3409,19 +3380,9 @@ impl CodexAppServerConnection {
&& matches!(status, "completed" | "interrupted" | "failed")
{
terminal_recorded = true;
// 终态时间:`durationMs` 与宿主记下的毫秒起点都可靠时才派生,
// 否则取宿主处理这条终态的钟;上游秒级 `completedAt` 一律不用。
append_direct_thread_event(
&direct_thread_id,
DirectThreadEvent::turn_completed(
status.to_string(),
direct_thread_turn_completed_at_ms(
turn,
Some(direct_turn_started_at_ms),
direct_tool_call_now_ms(),
),
)
.with_user_item_id(direct_turn_user_item_id.as_deref()),
DirectThreadEvent::turn_completed(status.to_string()),
);
}
match status {
@@ -3478,11 +3439,7 @@ impl CodexAppServerConnection {
"failed"
}
.to_string(),
// 这条兜底终态没有对应的 app-server 终态载荷,只能取宿主处理它的钟,
// 不能拿最后一次正文或工具更新时间当回合终点。
direct_tool_call_now_ms(),
)
.with_user_item_id(direct_turn_user_item_id.as_deref()),
),
);
}
let text = match collect_result {
@@ -3680,15 +3637,6 @@ enum DirectCodexTurnCancelTarget {
/// 这时显式释放这条守卫并把可读原因返回给界面。释放条件见
/// [`release_stale_direct_taonier_active_invocation`] 的注释;"正在跑的是另一轮"仍然
/// 保持原拒绝语义,什么都不释放。
///
/// 兜底终态带 `userItemId`:身份取 `release_stale_direct_taonier_active_invocation` 返回的
/// clientTurnId(客户端回合身份的唯一来源),与正常路径的开口条目 id 同一份 canonical 口径。
/// 拿不到 clientTurnId 就留空——这一轮不会再有原生终态,猜一个身份会让前端把边界盖到别人身上。
fn direct_stale_cancel_turn_completed_event(client_turn_id: &str) -> DirectThreadEvent {
DirectThreadEvent::turn_completed("aborted".to_string(), direct_tool_call_now_ms())
.with_user_item_id(direct_codex_user_item_id_for_client_turn_id(client_turn_id).as_deref())
}
pub(crate) fn cancel_direct_codex_turn_at(
root: &Path,
client_turn_id: Option<&str>,
@@ -3745,7 +3693,7 @@ pub(crate) fn cancel_direct_codex_turn_at(
// 兜底补一条,否则前端的"最新回合是否在跑"会永远停在运行中。
append_direct_thread_event(
&direct_thread_id_for_project(root),
direct_stale_cancel_turn_completed_event(&released),
DirectThreadEvent::turn_completed("aborted".to_string()),
);
Ok(DirectTurnCancelView {
outcome: DIRECT_TURN_CANCEL_OUTCOME_RELEASED.to_string(),
@@ -5071,109 +5019,6 @@ mod tests {
));
}
/// 阶段时间取自**通知层**字段,形状照抄 codex-cli 0.147 / 0.155 的 v2 协议 schema
/// `item/started` 带 `startedAtMs`、`item/completed` 带 `completedAtMs`(毫秒),
/// `turn/completed` 带 `turn.startedAt` / `turn.completedAt`(秒)与 `turn.durationMs`(毫秒)。
/// 分类函数把 params 原样交给事件级 `at` 的投影函数,所以字段位置必须在这里钉住;
/// 回合边界的秒字段按"不用"锁在这里,避免以后有人再把秒级截断当 0.1 秒精度。
#[test]
fn direct_lifecycle_stage_times_come_from_notification_params() {
let started = serde_json::json!({
"threadId": "thread-1",
"turnId": "turn-1",
"startedAtMs": 1_700_000_000_123u64,
"item": {"id": "call-1", "type": "commandExecution", "command": "ls"},
});
let completed = serde_json::json!({
"threadId": "thread-1",
"turnId": "turn-1",
"completedAtMs": 1_700_000_001_500u64,
"item": {"id": "call-1", "type": "commandExecution", "command": "ls"},
});
for (method, params, expected_at_ms) in [
("item/started", &started, 1_700_000_000_123u64),
("item/completed", &completed, 1_700_000_001_500u64),
] {
let Some(CodexTurnEvent::Item {
completed,
params: event_params,
}) = direct_codex_notification_event(method, params, None, None, "turn-1")
else {
panic!("{method} 必须分类成条目生命周期事件");
};
let item = event_params.get("item").expect("item payload");
assert_eq!(
direct_thread_item_event_at_ms(&event_params, item, completed, 9_999),
expected_at_ms,
"{method} 必须用通知层的阶段时间,而不是宿主钟"
);
}
let terminal = serde_json::json!({
"threadId": "thread-1",
"turn": {
"id": "turn-1",
"items": [],
"status": "completed",
"startedAt": 1_700_000_000i64,
"completedAt": 1_700_000_042i64,
},
});
let Some(CodexTurnEvent::Terminal(params)) =
direct_codex_notification_event("turn/completed", &terminal, None, None, "turn-1")
else {
panic!("turn/completed 必须分类成终态事件");
};
let turn = params.get("turn").unwrap_or(&params);
assert_eq!(
direct_thread_turn_completed_at_ms(turn, Some(1_700_000_000_500), 9_999),
9_999,
"上游只有秒级 completedAt:不采用,取宿主处理终态的毫秒钟"
);
let with_duration = serde_json::json!({
"id": "turn-1",
"items": [],
"status": "completed",
"startedAt": 1_700_000_000i64,
"completedAt": 1_700_000_042i64,
"durationMs": 42_500u64,
});
assert_eq!(
direct_thread_turn_completed_at_ms(&with_duration, Some(1_700_000_000_500), 9_999),
1_700_000_043_000,
"durationMs + 宿主高精度起点才派生结束"
);
}
/// 取消兜底终态也要带开口用户条目身份,且身份只有一个来源:release 返回的 clientTurnId
/// 走与正常路径同一份 canonical 口径;拿不到(空 / 空白)就留空,不猜。
#[test]
fn stale_cancel_terminal_event_keeps_opener_user_item_id_from_client_turn_id() {
let event = direct_stale_cancel_turn_completed_event("turn-0001");
assert_eq!(event.user_item_id(), Some("direct-codex:turn-0001:user"));
assert!(event.at().is_some(), "兜底终态仍要带宿主观测时间");
assert!(matches!(
event,
DirectThreadEvent::TurnCompleted { ref status, .. } if status == "aborted"
));
for missing in ["", " "] {
let event = direct_stale_cancel_turn_completed_event(missing);
assert_eq!(
event.user_item_id(),
None,
"拿不到 clientTurnId 时不得编造开口条目身份"
);
}
// canonical 口径与落盘侧同一份:`direct-codex:{clientTurnId}:user`。
assert_eq!(
direct_codex_user_item_id_for_client_turn_id(" turn-0001 ").as_deref(),
Some("direct-codex:turn-0001:user")
);
assert_eq!(direct_codex_user_item_id_for_client_turn_id(""), None);
}
fn test_llm() -> GameCreatorLlmConfig {
GameCreatorLlmConfig {
custom_enabled: false,
@@ -6908,33 +6753,12 @@ done
let consumed = crate::agent::consume_direct_thread(&bootstrap.subscription_id)
.expect("consume events");
// 回合起止必须与开口用户条目同源:前端在「只有锚点 + 历史、运行态为空」的回合里靠这个
// 身份把边界认领给同一条用户条目,缺了它就只能隐藏未知用时。
let lifecycle_user_item_ids = consumed
.events
.iter()
.filter(|event| {
matches!(
event,
DirectThreadEvent::TurnStarted { .. } | DirectThreadEvent::TurnCompleted { .. }
)
})
.map(DirectThreadEvent::user_item_id)
.collect::<Vec<_>>();
assert_eq!(
lifecycle_user_item_ids,
vec![
Some("direct-codex:turn-0001:user"),
Some("direct-codex:turn-0001:user"),
],
"turn.started / turn.completed 都要带本轮开口用户条目的 canonical itemId"
);
let mut user_items = Vec::new();
let mut assistant_items = Vec::new();
for event in &consumed.events {
let item = match event {
DirectThreadEvent::ItemStarted { item, .. }
| DirectThreadEvent::ItemCompleted { item, .. } => item,
DirectThreadEvent::ItemStarted { item }
| DirectThreadEvent::ItemCompleted { item } => item,
_ => continue,
};
match item {
@@ -2,9 +2,9 @@
//! 有项目路径或导入状态时输出路径映射;否则保持首页元数据文案。不灌正文。
pub(crate) const MAX_DIRECT_CODEX_ATTACHMENTS: usize = 8;
const MAX_DIRECT_CODEX_ATTACHMENT_NAME_CHARS: usize = 160;
const MAX_DIRECT_CODEX_ATTACHMENT_MEDIA_TYPE_CHARS: usize = 96;
const MAX_DIRECT_CODEX_ATTACHMENT_LOCAL_PATH_CHARS: usize = 512;
pub(crate) const MAX_DIRECT_CODEX_ATTACHMENT_NAME_CHARS: usize = 160;
pub(crate) const MAX_DIRECT_CODEX_ATTACHMENT_MEDIA_TYPE_CHARS: usize = 96;
pub(crate) const MAX_DIRECT_CODEX_ATTACHMENT_LOCAL_PATH_CHARS: usize = 512;
const HOME_ATTACHMENT_HEADER: &str =
"[首页附件说明:当前尚未打开项目,以下仅为附件元数据,附件内容尚不可读取]";
@@ -5,11 +5,11 @@ mod validation;
mod wire;
pub(crate) use model::{
DirectCodexUserContentPart, DirectCodexUserItem, DirectCodexUserMessageItem,
DirectCodexUserRole, DirectCodexUserRuntimeRegionPart,
DirectCodexUserAttachmentReferencePart, DirectCodexUserContentPart, DirectCodexUserItem,
DirectCodexUserMessageItem, DirectCodexUserRole, DirectCodexUserRuntimeRegionPart,
};
pub(crate) use validation::validate_direct_codex_user_item;
pub(crate) use wire::{
direct_codex_user_item_to_prompt, direct_codex_user_item_to_response_item,
direct_codex_user_item_to_wire_input,
direct_codex_user_item_to_codex_turn_input, direct_codex_user_item_to_prompt,
direct_codex_user_item_to_response_item, direct_codex_user_item_to_wire_input,
};
@@ -34,8 +34,25 @@ pub(crate) enum DirectCodexUserContentPart {
InputText { text: String },
#[serde(rename = "agc_resource_reference")]
AgcResourceReference { resource_id: String },
#[serde(rename = "agc_skill_reference")]
AgcSkillReference { name: String },
#[serde(rename = "agc_runtime_region_reference")]
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)]
@@ -4,6 +4,8 @@ use super::model::{
};
use crate::agent::{
read_manifest_for_project, sanitize_attachment_local_path, GameCreationAppManifest,
MAX_DIRECT_CODEX_ATTACHMENTS, MAX_DIRECT_CODEX_ATTACHMENT_MEDIA_TYPE_CHARS,
MAX_DIRECT_CODEX_ATTACHMENT_NAME_CHARS,
};
use std::path::Path;
@@ -12,7 +14,7 @@ pub(crate) const MAX_DIRECT_CODEX_REFERENCES: usize = 32;
pub(crate) fn validate_direct_codex_user_item(
root: &Path,
item: &DirectCodexUserItem,
) -> Result<(), String> {
) -> Result<GameCreationAppManifest, String> {
let DirectCodexUserItem::Message(message) = item;
if !matches!(message.role, DirectCodexUserRole::User) {
return Err("DirectProject 只接受 user message item".to_string());
@@ -20,32 +22,91 @@ pub(crate) fn validate_direct_codex_user_item(
if message.id.trim().is_empty() {
return Err("DirectProject user item 缺少稳定 id".to_string());
}
if message.content.is_empty() {
// 有效输入只判一整条 content:单个纯空白 `input_text` 是合法 part —— 编辑器里的段落
// 分隔、软换行与 chip 后的分隔空格就是这样落进 canonical content 的,前端不为它过滤。
if !content_has_meaningful_input(&message.content) {
return Err("DirectProject user item content 不能为空".to_string());
}
let manifest = read_manifest_for_project(root)?;
let mut reference_count = 0usize;
let mut attachment_count = 0usize;
for part in &message.content {
match part {
DirectCodexUserContentPart::InputText { text } => {
if text.trim().is_empty() {
return Err("DirectProject input_text 不能为空".to_string());
}
}
DirectCodexUserContentPart::InputText { .. } => {}
DirectCodexUserContentPart::AgcResourceReference { resource_id } => {
reference_count = reference_count.saturating_add(1);
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) => {
reference_count = reference_count.saturating_add(1);
validate_runtime_region_reference(&manifest, reference)?;
}
DirectCodexUserContentPart::AgcAttachmentReference(reference) => {
attachment_count = attachment_count.saturating_add(1);
if attachment_count > MAX_DIRECT_CODEX_ATTACHMENTS {
return Err(format!(
"一次最多携带 {MAX_DIRECT_CODEX_ATTACHMENTS} 个附件"
));
}
if reference.name.trim().is_empty() {
return Err("附件缺少文件名".to_string());
}
let name = reference.name.trim();
if name.chars().count() > MAX_DIRECT_CODEX_ATTACHMENT_NAME_CHARS
|| name.chars().any(char::is_control)
{
return Err("附件文件名无效或过长".to_string());
}
let media_type = reference.media_type.trim();
if media_type.is_empty()
|| media_type.chars().count() > MAX_DIRECT_CODEX_ATTACHMENT_MEDIA_TYPE_CHARS
|| media_type.chars().any(|character| {
!(character.is_ascii_alphanumeric()
|| matches!(character, '/' | '+' | '-' | '.' | '_'))
})
{
return Err("附件媒体类型无效或过长".to_string());
}
let status = reference.status.trim();
if status == "imported" && reference.local_path.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!(status, "imported" | "failed") {
return Err("附件状态无效".to_string());
}
}
}
}
if reference_count > MAX_DIRECT_CODEX_REFERENCES {
return Err(format!("一次最多引用 {MAX_DIRECT_CODEX_REFERENCES} 个素材"));
}
Ok(())
Ok(manifest)
}
/// 整条 content 是否还有有效输入:任何一段非空白文本、或任何一个非文本 part 都算。
pub(crate) fn content_has_meaningful_input(content: &[DirectCodexUserContentPart]) -> bool {
content.iter().any(|part| match part {
DirectCodexUserContentPart::InputText { text } => !text.trim().is_empty(),
_ => true,
})
}
pub(crate) fn validate_resource_id_and_manifest(
@@ -86,3 +147,150 @@ fn validate_runtime_region_reference(
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{content_has_meaningful_input, validate_direct_codex_user_item};
use crate::agent::direct_codex_user_item::model::DirectCodexUserContentPart;
use serde_json::json;
fn input_text(text: &str) -> DirectCodexUserContentPart {
DirectCodexUserContentPart::InputText {
text: text.to_string(),
}
}
#[test]
fn only_all_blank_content_counts_as_empty_input() {
// 空数组与「整条只有空白」是同一种空输入。
assert!(!content_has_meaningful_input(&[]));
assert!(!content_has_meaningful_input(&[input_text(" \n ")]));
assert!(!content_has_meaningful_input(&[
input_text("\n"),
input_text(" "),
]));
}
#[test]
fn whitespace_parts_are_valid_next_to_meaningful_input() {
// 段落分隔 / 软换行 / chip 后的分隔空格都是合法的单个 part。
assert!(content_has_meaningful_input(&[
input_text("\n"),
input_text(""),
]));
assert!(content_has_meaningful_input(&[
input_text(""),
input_text("\n\n"),
]));
}
#[test]
fn non_text_parts_always_count_as_input() {
assert!(content_has_meaningful_input(&[
DirectCodexUserContentPart::AgcResourceReference {
resource_id: "asset-hero".to_string(),
},
]));
}
#[test]
fn inline_attachment_count_is_bounded_independently() {
let root = tempfile::tempdir().expect("temp project");
crate::init_local_game_project_at(root.path(), "validation-test", "校验测试")
.expect("init project");
let content = (0..=crate::agent::MAX_DIRECT_CODEX_ATTACHMENTS)
.map(|index| {
json!({
"type": "agc_attachment_reference",
"name": format!("attachment-{index}.txt"),
"mediaType": "text/plain",
"size": 1,
"localPath": "",
"status": "failed"
})
})
.collect::<Vec<_>>();
let item = serde_json::from_value(json!({
"type": "message",
"role": "user",
"content": content,
"id": "turn-1:user"
}))
.expect("deserialize user item");
let error = validate_direct_codex_user_item(root.path(), &item)
.expect_err("too many inline attachments must be rejected");
assert!(error.contains("最多携带"), "{error}");
}
#[test]
fn imported_attachment_requires_a_project_path() {
let root = tempfile::tempdir().expect("temp project");
crate::init_local_game_project_at(root.path(), "validation-test", "校验测试")
.expect("init project");
let item = serde_json::from_value(json!({
"type": "message",
"role": "user",
"content": [{
"type": "agc_attachment_reference",
"name": "attachment.txt",
"mediaType": "text/plain",
"size": 1,
"localPath": "",
"status": "imported"
}],
"id": "turn-1:user"
}))
.expect("deserialize user item");
let error = validate_direct_codex_user_item(root.path(), &item)
.expect_err("imported attachment without a project path must fail");
assert!(error.contains("缺少项目路径"), "{error}");
}
#[test]
fn attachment_name_and_media_type_are_bounded_and_well_formed() {
let root = tempfile::tempdir().expect("temp project");
crate::init_local_game_project_at(root.path(), "validation-test", "校验测试")
.expect("init project");
let long_name = "a".repeat(crate::agent::MAX_DIRECT_CODEX_ATTACHMENT_NAME_CHARS + 1);
let cases = [
(
json!({
"name": "bad\nname.txt",
"mediaType": "text/plain"
}),
"文件名",
),
(
json!({
"name": "ok.txt",
"mediaType": "text/plain\nsecret"
}),
"媒体类型",
),
(
json!({
"name": long_name,
"mediaType": "text/plain"
}),
"文件名",
),
];
for (metadata, expected) in cases {
let mut value = metadata;
value["type"] = json!("agc_attachment_reference");
value["size"] = json!(1);
value["localPath"] = json!("");
value["status"] = json!("failed");
let item = serde_json::from_value(json!({
"type": "message",
"role": "user",
"content": [value],
"id": "turn-1:user"
}))
.expect("deserialize user item");
let error = validate_direct_codex_user_item(root.path(), &item)
.expect_err("invalid attachment metadata must fail");
assert!(error.contains(expected), "{error}");
}
}
}
@@ -1,6 +1,11 @@
use super::model::{DirectCodexUserContentPart, DirectCodexUserItem};
use super::model::{
DirectCodexUserContentPart, DirectCodexUserItem, DirectCodexUserRuntimeRegionPart,
};
use super::validation::validate_direct_codex_user_item;
use crate::agent::{read_manifest_for_project, sanitize_attachment_local_path};
use crate::agent::{
read_manifest_for_project, sanitize_attachment_local_path, sanitize_attachment_media_type,
sanitize_attachment_name,GameCreationAppManifest,
};
use serde_json::Value;
use std::path::Path;
@@ -51,6 +56,47 @@ fn direct_codex_user_item_to_response_content(
.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` 可接受的文本数组。
/// AGC 私有 part 只在这里投影为安全摘要,canonical item 本身不被修改。
pub(crate) fn direct_codex_user_item_to_wire_input(
@@ -65,38 +111,26 @@ pub(crate) fn direct_codex_user_item_to_wire_input(
let text = match part {
DirectCodexUserContentPart::InputText { text } => text.clone(),
DirectCodexUserContentPart::AgcResourceReference { resource_id } => {
let asset = manifest
.assets
.iter()
.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()
)
resource_reference_summary(&manifest, resource_id)?
}
DirectCodexUserContentPart::AgcSkillReference { name } => {
format!("${}", name.trim())
}
DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => {
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}"));
runtime_region_summary(reference)
}
DirectCodexUserContentPart::AgcAttachmentReference(reference) => {
let name = sanitize_attachment_name(&reference.name);
let media_type = sanitize_attachment_media_type(&reference.media_type);
let local_path = sanitize_attachment_local_path(&reference.local_path);
let mut summary = format!(
"[附件:名称={};类型={};大小={} 字节",
name, media_type, reference.size
);
if let Some(local_path) = local_path {
summary.push_str(&format!(";项目路径={local_path}"));
}
summary.push_str(&format!(";状态={}", reference.status.trim()));
summary.push(']');
summary
}
@@ -106,6 +140,66 @@ pub(crate) fn direct_codex_user_item_to_wire_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(
root: &Path,
item: &DirectCodexUserItem,
@@ -130,7 +224,8 @@ pub(crate) fn direct_codex_user_item_to_prompt(
#[cfg(test)]
mod tests {
use super::direct_codex_user_item_to_response_item;
use super::{direct_codex_user_item_to_response_item, direct_codex_user_item_to_wire_input};
use crate::agent::direct_codex_user_item::model::DirectCodexUserItem;
use serde_json::json;
use std::path::Path;
@@ -174,4 +269,99 @@ mod tests {
.expect_err("history item without type must fail");
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"));
}
#[test]
fn attachment_metadata_is_sanitized_before_prompt_projection() {
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": "agc_attachment_reference",
"name": "C:\\tmp\\notes.md",
"mediaType": "text/plain",
"size": 4,
"localPath": "assets\\.\\notes.txt",
"status": "imported"
}]
});
let wire = super::direct_codex_user_item_to_wire_input(
root.path(),
&serde_json::from_value(item).expect("deserialize user item"),
)
.expect("attachment metadata should project");
let text = wire[0]["text"].as_str().expect("wire text");
assert!(text.contains("名称=notes.md"), "{text}");
assert!(text.contains("类型=text/plain"), "{text}");
assert!(text.contains("项目路径=assets/notes.txt"), "{text}");
}
#[test]
fn whitespace_only_text_parts_survive_validation() {
let root = tempfile::tempdir().expect("temp project");
crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试")
.expect("init project");
let item: DirectCodexUserItem = serde_json::from_value(json!({
"type": "message",
"role": "user",
"id": "turn-1:user",
"content": [
{"type": "input_text", "text": "先看"},
{"type": "input_text", "text": "\n"},
{"type": "input_text", "text": " "}
]
}))
.expect("deserialize user item");
let wire = direct_codex_user_item_to_wire_input(root.path(), &item)
.expect("whitespace-only part next to real text must pass");
let parts = wire.as_array().expect("wire input array");
assert_eq!(parts.len(), 3);
assert_eq!(parts[1]["text"].as_str(), Some("\n"));
assert_eq!(parts[2]["text"].as_str(), Some(" "));
}
#[test]
fn all_blank_content_is_rejected() {
let root = tempfile::tempdir().expect("temp project");
crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试")
.expect("init project");
let item: DirectCodexUserItem = serde_json::from_value(json!({
"type": "message",
"role": "user",
"id": "turn-1:user",
"content": [
{"type": "input_text", "text": "\n"},
{"type": "input_text", "text": " "}
]
}))
.expect("deserialize user item");
let error = direct_codex_user_item_to_wire_input(root.path(), &item)
.expect_err("all-blank content must fail closed");
assert!(error.contains("不能为空"), "{error}");
}
}
File diff suppressed because one or more lines are too long
@@ -31,10 +31,9 @@ pub(crate) fn normalize_direct_client_turn_id(
pub(crate) async fn chat_with_game_creator_direct_codex(
project_path: String,
prompt: String,
mut user_item: DirectCodexUserItem,
user_item: DirectCodexUserItem,
creation_type: Option<String>,
client_turn_id: Option<String>,
attachments: Option<Vec<DirectCodexTurnAttachment>>,
) -> Result<String, String> {
let root = Path::new(project_path.trim());
let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?;
@@ -43,34 +42,9 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
redact_agent_runtime_error(root, &format!("恢复上一轮陶泥儿整包事务失败:{error}"), 500)
})?;
let turn_emitter = DirectGameCreatorTurnUpdateEmitter::new(root, turn_id.clone());
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| {
audit.finish(false);
error
})?;
let user_prompt = direct_codex_user_item_to_prompt(root, &user_item).map_err(|error| {
audit.finish(false);
error
})?;
validate_direct_codex_user_item(root, &user_item)?;
let user_prompt = direct_codex_user_item_to_prompt(root, &user_item)?;
if user_prompt.trim().is_empty() {
audit.finish(false);
return Err("聊天内容不能为空".to_string());
}
let canonical_user_item =
@@ -80,18 +54,15 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
&user_prompt,
creation_type.as_deref(),
Some(&turn_emitter),
Some(&mut audit),
// DirectProject 的完整回合权威已经落在 project.jsonl;不再创建平行审计日志。
None,
canonical_user_item,
)
.await
{
Ok(reply) => reply,
Err(error) => {
audit.finish(false);
return Err(error);
}
Err(error) => return Err(error),
};
audit.finish(true);
turn_emitter.emit("completed", Some("none"), Some(reply.clone()), None);
Ok(reply)
}
@@ -427,16 +427,12 @@ mod tests {
}
}
/// 事件级阶段时间只在重放稳定性用例里逐个指定;其余用例用一个固定值即可,
/// 它们断言的是队列 / 游标语义,不是时间本身。
const FIXED_AT_MS: u64 = 1_000;
fn item_started(item_id: &str) -> DirectThreadEvent {
DirectThreadEvent::item_started(message(item_id), FIXED_AT_MS)
DirectThreadEvent::item_started(message(item_id))
}
fn item_completed(item_id: &str) -> DirectThreadEvent {
DirectThreadEvent::item_completed(message(item_id), FIXED_AT_MS)
DirectThreadEvent::item_completed(message(item_id))
}
fn item_delta(item_id: &str) -> DirectThreadEvent {
@@ -454,7 +450,7 @@ mod tests {
#[test]
fn subscribers_have_independent_cursors_on_one_global_queue() {
let mut manager = DirectThreadManager::with_limits(100, 100_000);
manager.append("thread-1", DirectThreadEvent::turn_started(FIXED_AT_MS));
manager.append("thread-1", DirectThreadEvent::turn_started());
let first = manager.subscribe("thread-1");
let second = manager.subscribe("thread-1");
manager.append("thread-1", item_started("item-1"));
@@ -478,7 +474,7 @@ mod tests {
#[test]
fn bootstrap_contains_lifecycle_anchor_and_unfinished_events_only() {
let mut manager = DirectThreadManager::with_limits(100, 100_000);
manager.append("thread-1", DirectThreadEvent::turn_started(FIXED_AT_MS));
manager.append("thread-1", DirectThreadEvent::turn_started());
manager.append("thread-1", item_started("item-1"));
manager.append("thread-1", item_delta("item-1"));
manager.append("thread-1", item_completed("item-1"));
@@ -488,7 +484,7 @@ mod tests {
assert!(matches!(
bootstrap.events.as_slice(),
[
DirectThreadEvent::TurnStarted { .. },
DirectThreadEvent::TurnStarted {},
DirectThreadEvent::ItemStarted { item, .. },
] if item.item_id() == "item-2"
));
@@ -593,118 +589,15 @@ mod tests {
let mut manager = DirectThreadManager::with_limits(100, 100_000);
manager.append(
"thread-1",
DirectThreadEvent::turn_completed("completed".to_string(), FIXED_AT_MS),
DirectThreadEvent::turn_completed("completed".to_string()),
);
let bootstrap = manager.subscribe("thread-1");
assert!(matches!(
bootstrap.events.as_slice(),
[DirectThreadEvent::TurnCompleted { status, at, .. }]
if status == "completed" && *at == Some(FIXED_AT_MS)
[DirectThreadEvent::TurnCompleted { status }] if status == "completed"
));
}
/// 阶段时间必须随事件一起进队列:bootstrap 与重复订阅都拿到**原值**,
/// 重放不得重新取钟(否则每次重连都会把已固定的起止时间改掉)。
#[test]
fn replayed_events_keep_their_original_stage_time() {
let mut manager = DirectThreadManager::with_limits(100, 100_000);
manager.append("thread-1", DirectThreadEvent::turn_started(1_000));
manager.append(
"thread-1",
DirectThreadEvent::item_started(message("item-1"), 2_000),
);
let first = manager.subscribe("thread-1");
assert_eq!(
first
.events
.iter()
.map(DirectThreadEvent::at)
.collect::<Vec<_>>(),
vec![Some(1_000), Some(2_000)]
);
// 第二个订阅看到的是同一份事件,时间不因"又取了一次当前时间"而漂移。
let second = manager.subscribe("thread-1");
assert_eq!(second.events, first.events);
manager.append(
"thread-1",
DirectThreadEvent::item_completed(message("item-1"), 3_000),
);
let completion = manager
.consume(&first.subscription_id)
.expect("consume completion")
.events;
assert_eq!(
completion
.iter()
.map(DirectThreadEvent::at)
.collect::<Vec<_>>(),
vec![Some(3_000)]
);
// 重复消费不产生新事件,也不改写已下发过的时间。
assert!(manager
.consume(&first.subscription_id)
.expect("empty consume")
.events
.is_empty());
assert_eq!(
completion
.iter()
.map(DirectThreadEvent::at)
.collect::<Vec<_>>(),
vec![Some(3_000)]
);
}
/// 生命周期锚点重放时必须带上开口用户条目身份:前端在「只有锚点 + 历史切片、运行态一直空」
/// 的回合里也要能把边界认领给同一条用户条目,而不是按时间戳猜。
#[test]
fn bootstrap_replays_opener_user_item_id() {
let mut manager = DirectThreadManager::with_limits(100, 100_000);
manager.append(
"thread-1",
DirectThreadEvent::turn_started(1_000)
.with_user_item_id(Some("direct-codex:turn-1:user")),
);
let bootstrap = manager.subscribe("thread-1");
assert_eq!(
bootstrap
.events
.iter()
.map(DirectThreadEvent::user_item_id)
.collect::<Vec<_>>(),
vec![Some("direct-codex:turn-1:user")]
);
assert_eq!(bootstrap.events[0].at(), Some(1_000));
// 锚点是独立保存的副本:队列里那条事件被回收之后,新订阅仍拿到同一个身份。
manager
.consume(&bootstrap.subscription_id)
.expect("consume anchor");
manager.append(
"thread-1",
DirectThreadEvent::item_completed(message("item-1"), 2_000),
);
manager.append(
"thread-1",
DirectThreadEvent::turn_completed("completed".to_string(), 3_000)
.with_user_item_id(Some("direct-codex:turn-1:user")),
);
let second = manager.subscribe("thread-1");
assert_eq!(
second
.events
.iter()
.map(DirectThreadEvent::user_item_id)
.collect::<Vec<_>>(),
vec![Some("direct-codex:turn-1:user")],
"起止同源:终态锚点也带同一个开口用户条目身份"
);
assert_eq!(second.events[0].at(), Some(3_000));
}
#[test]
fn queue_cleanup_only_removes_a_cleanable_prefix() {
let mut manager = DirectThreadManager::with_limits(100, 100_000);
File diff suppressed because it is too large Load Diff
@@ -662,19 +662,6 @@ impl DirectToolBridgeState {
);
Ok((operation_id, idempotency_key))
}
/// 不计费的派生请求(如抠图)不进 `resource_request_ids` 计数,只取回合身份做确定性 id 派生。
fn active_resource_turn_id(&self) -> Result<String, String> {
let authorization = self
.turn_authorization
.lock()
.map_err(|_| "AGC 工具桥回合授权状态不可用".to_string())?;
authorization
.active
.as_ref()
.map(|active| active.turn_id.clone())
.ok_or_else(|| "当前没有客户端签发的资源生成回合身份".to_string())
}
}
fn direct_resource_request_uuid(turn_id: &str, domain: &str, request_fingerprint: &str) -> String {
@@ -2014,12 +2001,7 @@ async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Val
})
.await?
} else {
// 抠图不计费且服务端秒级完成,不占每回合付费媒体请求的四项额度,不进计数 map;
// id 仍按回合身份与请求指纹确定性派生,同指纹重试与 pending 对账语义不变。
let turn_id = state.active_resource_turn_id()?;
let operation_id = direct_resource_request_uuid(&turn_id, "operation", &fingerprint);
let idempotency_key =
direct_resource_request_uuid(&turn_id, "idempotency", &fingerprint);
let (operation_id, idempotency_key) = state.resource_request_ids(&fingerprint)?;
let revision = read_game_creator_agent_runtime_project_revision(&state.root)?.revision;
let request = DeriveLocalProjectResourceInput {
project_path: state.root.to_string_lossy().into_owned(),
@@ -2409,8 +2391,6 @@ async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value)
slice_mode,
grid_x,
grid_y,
reference_asset_ids: Vec::new(),
target_category: None,
screen_color,
};
let _generation_guard = state.image_generation_gate.lock().await;
@@ -67,11 +67,10 @@ pub(crate) use canvas_generation::{
generate_platform_art_asset_with_options_at,
generate_platform_art_asset_with_required_slices_at, maybe_generate_platform_art_asset_step,
needs_platform_art_asset_generation, normalize_platform_art_asset_generation_kind,
normalize_platform_art_reference_asset_ids, normalize_platform_art_target_category,
platform_art_asset_art_spec, platform_art_asset_output_extension_matches,
platform_art_runtime_references_match_request_contract, prepare_platform_art_asset_output_path,
project_canvas_asset_media_types, role_has_canvas_assets, suggested_canvas_tool_call,
PlatformArtAssetGenerationOptions, PLATFORM_ART_ASSET_GENERATION_KINDS,
prepare_platform_art_asset_output_path, project_canvas_asset_media_types,
role_has_canvas_assets, suggested_canvas_tool_call, PlatformArtAssetGenerationOptions,
PLATFORM_ART_ASSET_GENERATION_KINDS,
};
#[allow(unused_imports)]
pub(crate) use draft_validation::{
File diff suppressed because it is too large Load Diff
@@ -577,8 +577,6 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio
slice_mode: (!slice_mode.trim().is_empty()).then_some(slice_mode.clone()),
grid_x,
grid_y,
reference_asset_ids: Vec::new(),
target_category: None,
screen_color: None,
};
if let Some(pending) = pending_action {
@@ -631,9 +629,6 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio
.or_else(|| (!slice_mode.trim().is_empty()).then_some(slice_mode)),
grid_x,
grid_y,
reference_asset_ids: requested_options.reference_asset_ids,
// Agent 运行时不会指定完成登记的目标栏目,保持调用方给的值(默认 `None`)。
target_category: requested_options.target_category,
screen_color: requested_options.screen_color,
}
};
@@ -1,4 +1,4 @@
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::borrow::Cow;
use std::collections::BTreeSet;
@@ -121,6 +121,13 @@ struct AgcSkillManifestEntry {
sha256: String,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct AgcSkillCatalogEntry {
pub(crate) name: String,
pub(crate) description: String,
}
fn is_safe_skill_relative_path(value: &str) -> bool {
let path = Path::new(value);
!value.is_empty()
@@ -234,6 +241,21 @@ pub(crate) fn agc_skill_pack_fingerprint() -> Result<String, String> {
Ok(format!("{:x}", Sha256::digest(canonical_manifest.as_ref())))
}
/// 返回当前客户端随 AGC 一起启用的内置 Skill 候选。
///
/// 前端不得复制审核清单;Skill 名称和描述统一从经过校验的资源 manifest 派生。
#[tauri::command]
pub(crate) fn list_agc_skill_catalog() -> Result<Vec<AgcSkillCatalogEntry>, String> {
Ok(validated_skill_pack_manifest()?
.skills
.into_iter()
.map(|entry| AgcSkillCatalogEntry {
name: entry.name,
description: entry.purpose,
})
.collect())
}
pub(crate) fn render_agc_skill_pack_index() -> Result<String, String> {
let manifest = validated_skill_pack_manifest()?;
let mut lines = vec![format!(
@@ -328,6 +350,19 @@ mod tests {
}
}
#[test]
fn skill_catalog_is_derived_from_the_validated_manifest() {
let catalog = list_agc_skill_catalog().expect("skill catalog");
assert_eq!(catalog.len(), AGC_SKILL_PACK_EXPECTED_NAMES.len());
for expected_name in AGC_SKILL_PACK_EXPECTED_NAMES {
let entry = catalog
.iter()
.find(|entry| entry.name == expected_name)
.expect("expected bundled skill");
assert!(!entry.description.trim().is_empty());
}
}
#[test]
fn skill_content_digest_is_stable_across_lf_and_crlf() {
fn digest(bytes: &[u8]) -> String {
@@ -390,12 +390,6 @@ pub(crate) async fn start_local_project_asset_generation(
image_size: Option<String>,
asset_name: Option<String>,
output_path: Option<String>,
// 前端 IPC 字段 `referenceAssetIds`:当前项目 manifest 里的图片素材 id,只做参考输入,
// 不进任务账本(重试由调用方继续用同一份引用提交,账本本身不新增字段)。
reference_asset_ids: Option<Vec<String>>,
// 前端 IPC 字段 `targetCategory`:完成登记时要落盘的正式栏目分类。同样不进任务账本:
// 它与引用一样属于「同一次提交的本地落点」,重试由调用方继续用同一个栏目提交。
target_category: Option<String>,
) -> Result<AssetGenerationTaskRecord, String> {
let task_id = asset_generation_task_id(&task_id)?;
let request = prepare_local_project_asset_generation(
@@ -406,8 +400,6 @@ pub(crate) async fn start_local_project_asset_generation(
image_size.as_deref(),
asset_name.as_deref(),
output_path.as_deref(),
reference_asset_ids.as_deref().unwrap_or_default(),
target_category.as_deref(),
)?;
enforce_project_permission_policy(&request.root, "canvas.asset_generate")?;
enforce_project_permission_policy(&request.root, "asset.register")?;
@@ -1,6 +1,5 @@
use super::*;
use sha2::{Digest as _, Sha256};
use shared_contracts::game_creation_app::GameCreationAppAssetCategory;
use std::future::Future;
const PRIVATE_EXTERNAL_EDITOR_API_KEY_FILE_PREFIX: &str = "external-editor-api-";
@@ -663,7 +662,6 @@ pub(crate) fn register_design_artifacts_at(root: &Path) -> Result<bool, String>
generation_kind: None,
reference_resource_ids: Vec::new(),
},
None,
)?;
changed |= asset_changed;
}
@@ -1878,56 +1876,8 @@ pub(crate) fn register_local_asset_entry(
id_prefix: &str,
source: GameCreationAppAssetSource,
) -> Result<UploadLocalAssetResult, String> {
register_local_asset_entry_with_change(
root, local_path, kind, media_type, id_prefix, source, None,
)
.map(|(result, _)| result)
}
/// 带**显式目标分类**的登记入口:只给 GUI 生成完成路径用(前端 `targetCategory`)。
///
/// 入口栏目与生成 kind 不是同一套词汇(栏目 `character` / `scene` / `ui-interaction`
/// 生成 kind 的派生分类会把图片落到 `unclassified`、规范图落到 `document`),所以要落回
/// 入口栏目只能由调用方把目标分类显式交进来。取值必须先过
/// [`shared_contracts::game_creation_app::game_creation_app_asset_category_from_str`]
/// 非法值失败关闭,绝不回退到 kind 派生;其它调用方继续走
/// [`register_local_asset_entry`],行为不变。
pub(crate) fn register_local_asset_entry_with_category(
root: &Path,
local_path: &str,
kind: &str,
media_type: &str,
id_prefix: &str,
source: GameCreationAppAssetSource,
target_category: Option<&str>,
) -> Result<UploadLocalAssetResult, String> {
let target_category = normalize_asset_category_override(target_category)?;
register_local_asset_entry_with_change(
root,
local_path,
kind,
media_type,
id_prefix,
source,
target_category,
)
.map(|(result, _)| result)
}
/// 归一显式目标分类:只接受合法枚举值,返回落盘字符串。
fn normalize_asset_category_override(
target_category: Option<&str>,
) -> Result<Option<GameCreationAppAssetCategory>, String> {
let Some(target_category) = target_category else {
return Ok(None);
};
let target_category = target_category.trim();
if target_category.is_empty() {
return Ok(None);
}
game_creation_app_asset_category_from_str(target_category)
.map(Some)
.ok_or_else(|| format!("非法资源分类:{target_category}"))
register_local_asset_entry_with_change(root, local_path, kind, media_type, id_prefix, source)
.map(|(result, _)| result)
}
fn register_local_asset_entry_with_change(
@@ -1937,7 +1887,6 @@ fn register_local_asset_entry_with_change(
media_type: &str,
id_prefix: &str,
source: GameCreationAppAssetSource,
target_category: Option<GameCreationAppAssetCategory>,
) -> Result<(UploadLocalAssetResult, bool), String> {
let normalized_path = normalize_relative_path(local_path)?;
let absolute_path = resolve_local_project_path(root, &normalized_path)?;
@@ -1963,17 +1912,11 @@ fn register_local_asset_entry_with_change(
// kind 没变时刻意不动 category——落盘分类是权威值,同 kind 重登记不得抹掉它。
let changed = existing.kind != kind
|| existing.media_type != media_type
|| existing.source != source
|| target_category.is_some_and(|category| existing.category != category);
|| existing.source != source;
if existing.kind != kind {
existing.kind = kind.to_string();
existing.category = game_creation_app_asset_category_for_kind(kind);
}
// 调用方显式给出目标分类时它就是权威值:GUI 完成登记必须能落回入口栏目,
// 这也是同路径重新生成时把资产从旧栏目(或 unclassified)原位接管过来的唯一入口。
if let Some(category) = target_category {
existing.category = category;
}
existing.media_type = media_type.to_string();
existing.source = source;
Ok((existing.id.clone(), "asset.update", changed))
@@ -1990,8 +1933,7 @@ fn register_local_asset_entry_with_change(
local_path: normalized_path.clone(),
image_sequence_frames: None,
image_sequence_duration_ms: None,
category: target_category
.unwrap_or_else(|| game_creation_app_asset_category_for_kind(kind)),
category: game_creation_app_asset_category_for_kind(kind),
tags: Vec::new(),
source,
});
@@ -2211,7 +2153,6 @@ pub(crate) fn delete_manifest_asset_at(
#[cfg(test)]
mod tests {
use super::*;
use shared_contracts::game_creation_app::GameCreationAppAssetCategory;
use std::io::{Read, Write};
#[test]
@@ -2235,121 +2176,6 @@ mod tests {
assert!(!register_design_artifacts_at(root).expect("register idempotently"));
}
/// GUI 完成登记可以显式指定目标栏目:新建条目与已登记条目都按显式值落盘。
///
/// 入口栏目(character / scene / ui-interaction)与生成 kind 不是同一套词汇,按 kind 派生
/// 会把图片落到 unclassified,占位拿不回原位;非法值必须失败关闭,不传时保持 kind 派生。
#[test]
fn explicit_target_category_overrides_the_kind_derived_category() {
fn canvas_source() -> GameCreationAppAssetSource {
GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Canvas,
canvas_project_id: None,
resource_id: None,
asset_object_id: None,
task_id: None,
prompt: None,
model: None,
generation_route: None,
generation_kind: None,
reference_resource_ids: Vec::new(),
}
}
fn category_of(root: &Path, asset_id: &str) -> GameCreationAppAssetCategory {
read_existing_manifest_for_project(root)
.expect("read manifest")
.assets
.into_iter()
.find(|asset| asset.id == asset_id)
.expect("registered asset is present")
.category
}
let temporary = tempfile::tempdir().expect("tempdir");
let root = temporary.path();
crate::project::init_local_game_project_at(root, "target-category-test", "目标栏目登记")
.expect("init project");
fs::create_dir_all(root.join("assets")).expect("create assets dir");
fs::write(root.join("assets/hero.png"), b"png-bytes").expect("write asset");
let registered = register_local_asset_entry_with_category(
root,
"assets/hero.png",
"image",
"image/png",
"platform-art",
canvas_source(),
Some("character"),
)
.expect("register with a target category");
assert_eq!(
category_of(root, &registered.id),
GameCreationAppAssetCategory::Character
);
// 同 kind 重新生成时显式目标分类仍是权威值:资产要能换栏目原位接管。
register_local_asset_entry_with_category(
root,
"assets/hero.png",
"image",
"image/png",
"platform-art",
canvas_source(),
Some("ui-interaction"),
)
.expect("re-register with another target category");
assert_eq!(
category_of(root, &registered.id),
GameCreationAppAssetCategory::UiInteraction
);
// 非法值失败关闭,且不动已落盘的分类。
assert!(register_local_asset_entry_with_category(
root,
"assets/hero.png",
"image",
"image/png",
"platform-art",
canvas_source(),
Some("version"),
)
.is_err());
assert_eq!(
category_of(root, &registered.id),
GameCreationAppAssetCategory::UiInteraction
);
// 不传目标分类时保持原有行为:新条目按 kind 派生(image → unclassified)。
fs::write(root.join("assets/plain.png"), b"png-bytes").expect("write plain asset");
let plain = register_local_asset_entry(
root,
"assets/plain.png",
"image",
"image/png",
"platform-art",
canvas_source(),
)
.expect("register without a target category");
assert_eq!(
category_of(root, &plain.id),
GameCreationAppAssetCategory::Unclassified
);
// 已落盘的显式分类在 kind 未变时仍然是权威值:同 kind 重登记不得把它抹掉。
register_local_asset_entry(
root,
"assets/hero.png",
"image",
"image/png",
"platform-art",
canvas_source(),
)
.expect("re-register without a target category");
assert_eq!(
category_of(root, &registered.id),
GameCreationAppAssetCategory::UiInteraction
);
}
/// 画板导出推断出的 kind 必须已经是 canonical 值。
///
/// 这个值会被原样写进 manifest 并据以派生落盘 `category`;一旦写出非 canonical 值
@@ -482,43 +482,6 @@ fn automatic_local_game_projects_root(app: &tauri::AppHandle) -> Result<PathBuf,
.map_err(|error| format!("无法读取 AGC 应用数据目录:{error}"))
}
/// 校验用户选择的项目创建目录。
///
/// 目录必须已经存在(原生目录选择器返回的结果),并且先过 AGC 私有路径门禁:门禁失败时
/// 这里就拒绝,避免项目被建到 AGC 无法加固、后续无法打开的位置。
pub(crate) fn validate_requested_game_project_creation_root(
requested: &str,
) -> Result<PathBuf, String> {
let requested = requested.trim();
let root = Path::new(requested);
if requested.is_empty() || !root.is_absolute() {
return Err("项目创建目录必须是绝对路径".to_string());
}
if project_path_has_control_chars(root) {
return Err("项目创建目录不能包含控制字符".to_string());
}
let metadata = fs::symlink_metadata(root)
.map_err(|error| format!("读取项目创建目录失败:{}: {error}", root.display()))?;
if metadata.file_type().is_symlink() || !metadata.is_dir() {
return Err("项目创建目录必须是普通文件夹".to_string());
}
// 用户选择的外部目录仍走显式的项目根准备:保留 user-selected 范围的一次性修复,
// 同时不放弃 reparse point / 非普通目录的失败关闭。
prepare_game_creator_project_root_for_read(root, true, "项目创建目录")?;
Ok(root.to_path_buf())
}
/// 解析本次建项要使用的根目录:没选就用 AGC 管理的默认目录,选了就用用户指定的目录。
pub(crate) fn resolve_game_project_creation_root(
app: &tauri::AppHandle,
requested: Option<&str>,
) -> Result<PathBuf, String> {
match requested.map(str::trim).filter(|value| !value.is_empty()) {
Some(requested) => validate_requested_game_project_creation_root(requested),
None => automatic_local_game_projects_root(app),
}
}
pub(crate) fn create_automatic_local_game_project_at(
projects_root: &Path,
requested_name: Option<&str>,
@@ -588,10 +551,9 @@ pub(crate) fn create_automatic_local_game_project(
app: tauri::AppHandle,
name: Option<String>,
planning: Option<bool>,
projects_root: Option<String>,
) -> Result<InitLocalProjectResult, String> {
create_automatic_local_game_project_at(
&resolve_game_project_creation_root(&app, projects_root.as_deref())?,
&automatic_local_game_projects_root(&app)?,
name.as_deref(),
planning.unwrap_or(false),
)
@@ -802,30 +764,13 @@ pub(crate) fn recent_game_creator_run_trace(root: &Path) -> Option<GameCreationA
serde_json::from_str::<GameCreationAgentRunTrace>(&content).ok()
}
/// 目录选择器标题:调用方只能给短标题,其余(超长、含控制字符、空白)一律回退默认文案。
fn pick_project_directory_title(title: Option<&str>) -> &str {
const MAX_TITLE_CHARS: usize = 24;
title
.map(str::trim)
.filter(|value| {
!value.is_empty()
&& value.chars().count() <= MAX_TITLE_CHARS
&& !value.chars().any(char::is_control)
})
.unwrap_or("选择游戏项目目录")
}
#[tauri::command]
pub(crate) async fn pick_local_project_directory(
app: tauri::AppHandle,
initial_path: Option<String>,
title: Option<String>,
) -> Result<Option<String>, String> {
let (sender, receiver) = tokio::sync::oneshot::channel();
let mut dialog = app
.dialog()
.file()
.set_title(pick_project_directory_title(title.as_deref()));
let mut dialog = app.dialog().file().set_title("选择游戏项目目录");
if let Some(initial_path) = initial_path
.as_deref()
.map(str::trim)
@@ -2375,27 +2320,6 @@ pub(crate) fn update_local_project_resource_classification(
)
}
/// 为一批已登记素材追加标签:整批一次校验、一次 manifest 写入、一次 revision 推进。
///
/// 权限位与单素材分类更新同口径取 `asset.register`(命令包装层只做权限门面,
/// 身份 / 写锁 / CAS / 原子写与审计都在 `project/manifest.rs` 内完成)。
/// 这里刻意**不**循环调用单素材命令:逐项调用会写出多份 manifest、推进多次 revision
/// 中途失败还会留下"前几个素材改了、后面的没改"的部分写入。
#[tauri::command]
pub(crate) fn add_local_project_resource_tags(
input: AddLocalProjectResourceTagsInput,
) -> Result<AddLocalProjectResourceTagsResult, String> {
let root = Path::new(input.project_path.trim());
enforce_project_permission_policy(root, "asset.register")?;
add_manifest_asset_tags_at(
root,
&input.expected_project_id,
input.expected_project_revision,
input.asset_ids,
input.tags,
)
}
#[tauri::command]
pub(crate) async fn derive_local_project_resource(
input: DeriveLocalProjectResourceInput,
@@ -4947,8 +4871,6 @@ pub(crate) fn prepare_local_project_asset_generation(
image_size: Option<&str>,
asset_name: Option<&str>,
output_path: Option<&str>,
reference_asset_ids: &[String],
target_category: Option<&str>,
) -> Result<LocalProjectAssetGenerationRequest, String> {
let project_path = project_path.trim();
if project_path.is_empty() {
@@ -4956,13 +4878,6 @@ pub(crate) fn prepare_local_project_asset_generation(
}
let asset_kind = normalize_platform_art_asset_generation_kind(kind)
.ok_or_else(|| format!("素材类型不受支持:{}", kind.trim()))?;
// 参考入参只接受当前项目 manifest 素材 id:路径、远端 resourceId 与超限在这里就被拒绝,
// 不把校验推迟到远端(远端只该收到当前账号绑定下的 resource ID)。
let reference_asset_ids =
normalize_platform_art_reference_asset_ids(asset_kind, reference_asset_ids)?;
// GUI 完成登记层参数:入口栏目与生成 kind 不是同一套词汇,只有调用方显式给出目标分类
// 才能把产物原位落回入口栏目。非法值(含 `version` / `all` 这类栏目伪值)直接失败关闭。
let target_category = normalize_platform_art_target_category(target_category)?;
Ok(LocalProjectAssetGenerationRequest {
root: PathBuf::from(project_path),
prompt: local_project_asset_prompt(prompt)?,
@@ -4999,8 +4914,6 @@ pub(crate) fn prepare_local_project_asset_generation(
.then(|| "connected-components".to_string()),
grid_x: None,
grid_y: None,
reference_asset_ids,
target_category,
screen_color: None,
},
})
@@ -5023,10 +4936,6 @@ pub(crate) async fn generate_local_project_asset(
image_size: Option<String>,
asset_name: Option<String>,
output_path: Option<String>,
reference_asset_ids: Option<Vec<String>>,
// 前端 IPC 字段 `targetCategory`:本次生成完成登记时要落盘的正式栏目分类,
// 只走 GUI 命令,取值必须是合法素材分类,Agent / Direct 路径不传。
target_category: Option<String>,
) -> Result<UploadLocalAssetResult, String> {
let request = prepare_local_project_asset_generation(
&project_path,
@@ -5036,8 +4945,6 @@ pub(crate) async fn generate_local_project_asset(
image_size.as_deref(),
asset_name.as_deref(),
output_path.as_deref(),
reference_asset_ids.as_deref().unwrap_or_default(),
target_category.as_deref(),
)?;
enforce_project_permission_policy(&request.root, "canvas.asset_generate")?;
enforce_project_permission_policy(&request.root, "asset.register")?;
@@ -5056,17 +4963,7 @@ mod local_project_asset_generation_tests {
use super::*;
fn prepare(kind: &str, prompt: &str) -> Result<LocalProjectAssetGenerationRequest, String> {
prepare_local_project_asset_generation(
"/tmp/project",
kind,
prompt,
None,
None,
None,
None,
&[],
None,
)
prepare_local_project_asset_generation("/tmp/project", kind, prompt, None, None, None, None)
}
#[test]
@@ -5106,8 +5003,6 @@ mod local_project_asset_generation_tests {
Some("2K"),
Some(" 主角图集 "),
Some(" assets/hero.png "),
&[],
None,
)
.expect("explicit options");
assert_eq!(explicit.root, PathBuf::from("/tmp/project"));
@@ -5135,18 +5030,8 @@ mod local_project_asset_generation_tests {
#[test]
fn invalid_toolbar_arguments_are_rejected_before_any_generation() {
assert_eq!(
prepare_local_project_asset_generation(
"",
"image",
"要求",
None,
None,
None,
None,
&[],
None,
)
.expect_err("empty project path"),
prepare_local_project_asset_generation("", "image", "要求", None, None, None, None)
.expect_err("empty project path"),
"项目路径不能为空"
);
assert_eq!(
@@ -5157,60 +5042,6 @@ mod local_project_asset_generation_tests {
prepare("game-art", "要求").expect_err("unverified kind"),
"素材类型不受支持:game-art"
);
// 目标分类只接受合法素材分类枚举:栏目侧伪值 `version` / `all` 与任意其它值都失败关闭。
for rejected in ["version", "all", "bogus", "UI"] {
assert_eq!(
prepare_local_project_asset_generation(
"/tmp/project",
"image",
"要求",
None,
None,
None,
None,
&[],
Some(rejected),
)
.expect_err("illegal target category"),
format!("目标分类不是合法素材分类:{rejected}")
);
}
// 合法值归一成落盘字符串(trim + kebab-case),供 manifest `category` 直接使用。
assert_eq!(
prepare_local_project_asset_generation(
"/tmp/project",
"image",
"要求",
None,
None,
None,
None,
&[],
Some(" ui-interaction "),
)
.expect("legal target category")
.options
.target_category
.as_deref(),
Some("ui-interaction")
);
assert_eq!(
prepare_local_project_asset_generation(
"/tmp/project",
"image",
"要求",
None,
None,
None,
None,
&[],
None,
)
.expect("omitted target category")
.options
.target_category,
None
);
assert_eq!(
prepare(
"spec",
@@ -5227,9 +5058,7 @@ mod local_project_asset_generation_tests {
Some("4:3"),
None,
None,
None,
&[],
None,
None
)
.expect_err("unsupported ratio"),
"图片比例不受支持:4:3"
@@ -5242,9 +5071,7 @@ mod local_project_asset_generation_tests {
None,
Some("4K"),
None,
None,
&[],
None,
None
)
.expect_err("unsupported size"),
"图片尺寸不受支持:4K"
@@ -5257,9 +5084,7 @@ mod local_project_asset_generation_tests {
None,
None,
Some("\u{7}名字"),
None,
&[],
None,
None
)
.expect_err("control character in asset name"),
"素材名称超出安全边界"
@@ -5272,9 +5097,7 @@ mod local_project_asset_generation_tests {
None,
None,
None,
Some(&"a".repeat(LOCAL_PROJECT_ASSET_MAX_OUTPUT_PATH_CHARS + 1)),
&[],
None,
Some(&"a".repeat(LOCAL_PROJECT_ASSET_MAX_OUTPUT_PATH_CHARS + 1))
)
.expect_err("oversized output path"),
"输出路径超出安全边界"
@@ -2518,6 +2518,7 @@ fn main() {
pick_client_extension_file,
pick_client_extension_directory,
list_client_extensions,
list_agc_skill_catalog,
import_client_extension,
set_client_extension_enabled,
rename_client_extension,
@@ -2589,7 +2590,6 @@ fn main() {
register_local_asset,
create_ui_design_resource,
update_local_project_resource_classification,
add_local_project_resource_tags,
derive_local_project_resource,
list_pending_local_project_resource_edits,
resume_local_project_resource_edit,
@@ -1131,17 +1131,8 @@ pub(crate) fn validate_manifest_required_visual_asset(
}
if task_id == "art-director" {
// 规范图是视觉来源链的根:它自身不派生任何视觉资产,但 icon-spec 生成允许用户参考
// (没有规范前置,最多总上限),这些参考只是风格输入,不构成派生关系。这里改为验证
// 参考集合仍符合 icon-spec 请求合同;route / generation kind / canvasProjectId /
// resourceId / PNG 解码等身份判据全部保持不变。
if !crate::agent::platform_art_runtime_references_match_request_contract(
&asset.source.reference_resource_ids,
expected_kind,
) {
return Err(format!(
"统一视觉规范图的参考集合不符合请求合同:{expected_path}"
));
if !asset.source.reference_resource_ids.is_empty() {
return Err("统一视觉规范图不得声明派生资源引用".to_string());
}
return Ok(());
}
@@ -1166,17 +1157,11 @@ pub(crate) fn validate_manifest_required_visual_asset(
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| "统一视觉规范图缺少 resourceId".to_string())?;
// 派生素材的参考合同是「规范图前置在最前,用户参考按顺序追加在后」,图集不接受用户参考:
// 规范身份仍只由首项承担,用户参考不能顶替也不能冒充规范引用。
if !crate::agent::platform_art_runtime_references_match_request_contract(
&asset.source.reference_resource_ids,
expected_kind,
) {
let [reference_resource_id] = asset.source.reference_resource_ids.as_slice() else {
return Err(format!(
"派生视觉资产未精确引用当前统一视觉规范图:{expected_path}"
));
}
let reference_resource_id = asset.source.reference_resource_ids[0].as_str();
};
let original_provenance_matches =
canvas_project_id == art_spec_project_id && reference_resource_id == art_spec_resource_id;
let rebound_local_source_matches = if original_provenance_matches {
@@ -1343,240 +1328,6 @@ pub(crate) fn update_manifest_asset_classification_at(
})
}
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub(crate) struct AddLocalProjectResourceTagsInput {
pub(crate) project_path: String,
pub(crate) expected_project_id: String,
pub(crate) expected_project_revision: u64,
pub(crate) asset_ids: Vec<String>,
#[serde(default)]
pub(crate) tags: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct AddLocalProjectResourceTagsResult {
pub(crate) assets: Vec<GameCreationAppAssetManifestEntry>,
pub(crate) committed_project_revision: u64,
}
/// 一次批量追加的素材上限:与主规范「每批最多 200 个不同素材」一致,按**去重后**数量计算。
/// 批次越大,锁内要重算的合并结果越多,manifest 也越大;无界批次会把成本摊到之后每一次读写上。
pub(crate) const ASSET_BATCH_TAG_MAX_ASSETS: usize = 200;
/// 批量追加标签的素材 ID 归一化:trim、按**首次出现顺序**去重,再在此处收口批次上下界。
///
/// 这里是"整句拒绝"的失败关闭口径,不做任何静默容忍:
///
/// - 空白 `assetId` 直接失败,不 `continue` 跳过。静默跳过会让"请求了 N 个素材"和"实际写了
/// N-1 个"分叉,而调用方拿到的仍是成功——这正是本合同要排除的静默部分写;
/// - 空批次失败;
/// - 去重后超限立即失败(在扫描到第 201 个不同 ID 时就返回,不对剩余 ID 继续做去重扫描),
/// 更不做"截断到 200 个":截断会让用户以为 250 个素材都加上了标签。
fn normalize_manifest_batch_asset_ids(asset_ids: &[String]) -> Result<Vec<String>, String> {
let mut normalized: Vec<String> = Vec::new();
for asset_id in asset_ids {
let asset_id = asset_id.trim();
if asset_id.is_empty() {
return Err("批量标签 assetId 不能为空".to_string());
}
if !normalized.iter().any(|existing| existing == asset_id) {
normalized.push(asset_id.to_string());
if normalized.len() > ASSET_BATCH_TAG_MAX_ASSETS {
return Err(format!(
"批量标签最多支持 {ASSET_BATCH_TAG_MAX_ASSETS} 个素材"
));
}
}
}
if normalized.is_empty() {
return Err("批量标签至少需要一个素材".to_string());
}
Ok(normalized)
}
/// 批量追加的标签归一化:沿用主规范的 trim / 去空 / 去重口径(复用
/// [`normalize_manifest_asset_tags`],其中已含数量与单标签长度收口)。
///
/// 只有**归一后为空**才拒绝:请求里全是空白标签时,用户填的东西一个字都不会落盘,
/// 此时若当成"成功且无变化"返回,界面会显示保存成功而素材上什么都没有。
fn normalize_manifest_batch_tags(tags: &[String]) -> Result<Vec<String>, String> {
let normalized = normalize_manifest_asset_tags(tags)?;
if normalized.is_empty() {
return Err("批量标签不能为空".to_string());
}
Ok(normalized)
}
/// 追加语义:只把请求里**尚不存在**的标签按请求顺序补到原有标签之后。
/// 原有标签的顺序、分类、类型、路径与来源都不参与改写——本命令没有删除或替换语义。
fn merge_manifest_asset_tags(existing: &[String], incoming: &[String]) -> Vec<String> {
let mut merged = existing.to_vec();
for tag in incoming {
if !merged.iter().any(|current| current == tag) {
merged.push(tag.clone());
}
}
merged
}
/// 锁内先算完的整批计划:任何一项缺失或超限都在这里失败,此时 manifest 一个字节都没动。
struct ManifestAssetTagAppendPlan {
/// 按请求顺序(去重后)返回的素材条目,标签为合并后的完整列表。
assets: Vec<GameCreationAppAssetManifestEntry>,
/// 真正需要落值的目标:`(assets 下标, 合并后的标签)`。
updates: Vec<(usize, Vec<String>)>,
/// 确实发生变化的素材 ID,供审计记录使用;空表示整批无变化。
changed_asset_ids: Vec<String>,
}
/// 先校验**全部**目标与**全部**合并结果,再决定是否写值。
///
/// 顺序是刻意的:第一阶段只读,任一目标不存在、任一合并结果超过标签上界都在写之前返回错误;
/// 只有全部通过,第二阶段才逐项落值。这样"缺任一资产 / 超限"都不可能留下部分写入。
fn plan_manifest_asset_tag_append(
manifest: &GameCreationAppManifest,
asset_ids: &[String],
tags: &[String],
) -> Result<ManifestAssetTagAppendPlan, String> {
let mut assets = Vec::with_capacity(asset_ids.len());
let mut updates: Vec<(usize, Vec<String>)> = Vec::with_capacity(asset_ids.len());
let mut changed_asset_ids = Vec::new();
for asset_id in asset_ids {
let index = manifest
.assets
.iter()
.position(|asset| &asset.id == asset_id)
.ok_or_else(|| format!("项目资源不存在:{asset_id}"))?;
let asset = &manifest.assets[index];
// 合并结果复用同一个上界函数:已有标签已归一化,这里等价于对整份新列表再收口一次。
// 上界函数只报"16 个"这种通用口径,200 个素材的批次里看不出是哪一项超了,所以在**调用点**
// 补上目标身份(ID + 可读 localPath)并说明整批未写:用户要能直接定位到那一张素材。
let merged = normalize_manifest_asset_tags(&merge_manifest_asset_tags(&asset.tags, tags))
.map_err(|error| {
format!(
"素材 {}{})的标签合并结果不合法:{error};本次未写入任何素材",
asset.id, asset.local_path
)
})?;
if merged != asset.tags {
changed_asset_ids.push(asset.id.clone());
}
updates.push((index, merged.clone()));
assets.push(GameCreationAppAssetManifestEntry {
tags: merged,
..asset.clone()
});
}
Ok(ManifestAssetTagAppendPlan {
assets,
updates,
changed_asset_ids,
})
}
/// 为一批已登记素材追加标签:一次校验、一次 manifest 写入、一次 revision 推进。
///
/// 语义与 [`update_manifest_asset_classification_at`] 同源(`asset.register` 权限位、项目身份、
/// 项目写锁、revision CAS、manifest 原子写、审计在 manifest 落盘之后 / revision 推进之前),
/// 但作用域是**整批**
///
/// - 项目身份校验两次(进入前与持锁后各一次),锁内按 `expectedProjectRevision` 做一次 CAS
/// - 锁内先算完整批计划,任一目标缺失或任一合并结果超限都**不写任何一项**;
/// - 整批无变化时**不写盘、不审计、不推进 revision**,直接返回当前条目与当前 revision;
/// - 真正有变化时才写一次 manifest、追加一条审计、推进一次 revision。
///
/// 已落盘之后的审计或 revision 失败照实报"整批已写入",不回滚、也不谎称回滚:manifest 是权威
/// 真相且已经改变,把错误说成"没写"只会让用户拿错状态去重试。
pub(crate) fn add_manifest_asset_tags_at(
root: &Path,
expected_project_id: &str,
expected_project_revision: u64,
asset_ids: Vec<String>,
tags: Vec<String>,
) -> Result<AddLocalProjectResourceTagsResult, String> {
if expected_project_revision
> shared_contracts::game_creation_app::GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION
{
return Err("expectedProjectRevision 超出 JavaScript 安全整数范围".to_string());
}
let expected_project_id = expected_project_id.trim();
if expected_project_id.is_empty() {
return Err("批量标签 expectedProjectId 不能为空".to_string());
}
let asset_ids = normalize_manifest_batch_asset_ids(&asset_ids)?;
let tags = normalize_manifest_batch_tags(&tags)?;
if read_existing_manifest_for_project(root)?.project_id != expected_project_id {
return Err("project-identity-conflict".to_string());
}
// 锁的 commandId 用本命令自己的动作名(审计/排障时能区分是批量追加还是别的写路径);
// 权限门面仍然是 `asset.register`,见 `commands.rs` 的命令包装层。
let _lock = acquire_project_write_lock(root, ASSET_BATCH_TAG_AUDIT_RECORD_TYPE)?;
if read_existing_manifest_for_project(root)?.project_id != expected_project_id {
return Err("project-identity-conflict".to_string());
}
if read_game_creator_agent_runtime_project_revision(root)?.revision != expected_project_revision
{
return Err("project-revision-conflict".to_string());
}
// no-op 判定发生在锁内、写盘之前:整批标签都已经存在时,连 manifest 都不必重写一次。
// 这不是优化洁癖——重写会换掉文件 mtime 与内容字节,让"什么都没做"看起来像一次真实改动。
let plan = plan_manifest_asset_tag_append(
&read_existing_manifest_for_project(root)?,
&asset_ids,
&tags,
)?;
if plan.changed_asset_ids.is_empty() {
return Ok(AddLocalProjectResourceTagsResult {
assets: plan.assets,
committed_project_revision: expected_project_revision,
});
}
let plan = mutate_manifest_at(root, |manifest| {
// 锁内复核:`mutate_manifest_at` 自己重新读盘,所以这里按同一套规则重算一遍再落值。
// 复核失败会在 `write_manifest_locked` 之前返回错误,仍然零写入;重算也保证不会拿
// 锁外算出的绝对标签列表去覆盖这份 manifest 上刚出现的新标签。
let plan = plan_manifest_asset_tag_append(manifest, &asset_ids, &tags)?;
for (index, merged) in &plan.updates {
manifest.assets[*index].tags = merged.clone();
}
Ok(plan)
})?;
// 复核阶段才发现"锁外以为有变化、锁内其实已无变化"的极端竞态:这一次写盘写出的就是原内容,
// 不能凭空补一条审计或推进 revision。正常路径不会走到这里——整批目标在此之前已经通过锁内 no-op 判定。
if plan.changed_asset_ids.is_empty() {
return Ok(AddLocalProjectResourceTagsResult {
assets: plan.assets,
committed_project_revision: expected_project_revision,
});
}
append_agent_db_record(
root,
serde_json::json!({
"recordType": ASSET_BATCH_TAG_AUDIT_RECORD_TYPE,
"assetIds": plan.changed_asset_ids,
"expectedProjectRevision": expected_project_revision,
"appendedTags": tags,
}),
)
.map_err(|error| format!("批量标签已写入,但审计记录失败:{error}"))?;
let committed_project_revision = advance_agent_runtime_project_revision_locked(root)
.map_err(|error| format!("批量标签已写入,但项目 revision 未能推进:{error}"))?;
Ok(AddLocalProjectResourceTagsResult {
assets: plan.assets,
committed_project_revision,
})
}
/// 批量标签写入的审计类型:一次批量追加只留一条记录,装的是"谁被追加了什么"。
pub(crate) const ASSET_BATCH_TAG_AUDIT_RECORD_TYPE: &str = "asset.tags.append";
pub(crate) fn create_manifest_task_at(
root: &Path,
task_id: &str,

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