Compare commits

..

51 Commits

Author SHA1 Message Date
k88936 254f3fcc5d 收紧 DirectProject 回合完成判定
Project CI / Frontend tests (pull_request) Waiting to run
Project CI / Repository checks (pull_request) Waiting to run
Project CI / AI game creator shell web tests (pull_request) Waiting to run
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Waiting to run
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Waiting to run
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Waiting to run
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Waiting to run
Project CI / AI game creator shell Rust smoke (pull_request) Waiting to run
Project CI / AI game creator shell Rust crates (pull_request) Waiting to run
Project CI / Backend tests (pull_request) Waiting to run
Project CI / Native shell tests (pull_request) Waiting to run
仅由 turn.completed 结束订阅恢复的运行态

调整终止回归测试以覆盖真实生命周期事件
2026-09-18 00:38:02 +08:00
k88936 5a0a3362fb 统一 DirectProject 流式增量脱敏
对 reasoning 与 agentMessage 增量复用历史正文脱敏和截断规则

补充密钥与绝对路径不泄露的 Rust 回归测试
2026-09-18 00:38:02 +08:00
k88936 de7211e8f2 修复 DirectProject 回合恢复与消息排队
统一恢复回合的忙碌态与终止按钮显示

按 turn.completed 推进 DirectProject 消息队列并补充回归测试
2026-09-18 00:38:02 +08:00
lhk229 bb772c7422 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
2026-09-18 00:31:44 +08:00
k88936 cb7db4923b Merge remote-tracking branch 'origin/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 18:47:18 +08:00
k88936 e7bd6a339b 补首屏历史锚点的实现证据,里程碑验收那条转成已验收
证据要求新增「首屏锚点(已跑)」一条:Rust 22 条、闸门单测 10 条、appSurface 全量 475/457/17 与唯一既有失败的对照结论
里程碑验收「首屏边界只认订阅回执」那条打勾,并写明两处变异验证现象
决策记录把「验证方式(待跑)」改成已跑的实跑结果与变异验证结论
2026-09-17 18:46:37 +08:00
k88936 de4dfe8538 首屏历史改按订阅回执锚定,回执到达前不读首屏
锚点闸门抽到 directHistoryAnchorGate:开闸、同项目复用、消费后不再等待三件事只有一份实现
App 订阅 effect 复用首屏开好的闸门,success 时 settle 成回执的 lastCompletedItemId,订阅不可用/失败/切项目/清理时 settle 成 null
首屏切片只在新端边界 throughItemId 有值时带上它,非 replace 模式才等闸门,/history 手动重读仍按文件尾
测试 harness 的切片桩支持 throughItemId,setDirectThreadHistory 同时同步回执用的 lastCompletedItemId
新增 directHistoryAnchorGate 单测覆盖复用/消费回退规则,App 集成用例断言首屏锚在回执那条、回执先于首屏读取
2026-09-17 18:46:37 +08:00
k88936 110e9260a7 历史切片补新端锚点:首屏窗口取到订阅回执那条为止
direct_project_history 的分页锚点收敛成 DirectProjectHistoryAnchor:Newest(取文件尾)/ Before(不含锚点,翻页用)/ Through(含锚点,首屏边界用)
read_direct_project_history_slice 命令新增 throughItemId,与 beforeItemId 互斥,两个都传直接报错
新增三条用例:Through 含锚点且挡掉更新的条目、锚点是最老一条时仍把它算进窗口、锚点不存在时失败关闭
既有用例改走显式锚点(Newest / Before),行为不变
变异验证:把 anchor_seen 初始化成 true(等于忽略锚点)后四条锚点用例变红
2026-09-17 18:46:37 +08:00
k88936 fae02694fb 补首屏历史锚点的契约:切片新端边界只认订阅回执的 lastCompletedItemId
ADR 首屏锚点那条写清锚点语义:新端(较新一侧)边界含锚点条目,命令参数 throughItemId,更新条目只从运行态事件来,翻页仍用 firstItemId 的 beforeItemId 不含锚点
里程碑范围与已确认决策同步该口径,验收标准新增一条待验收行(首屏 throughItemId + 回执前不发请求)
实施计划第 6 步补上首屏读取等订阅回执、锚点缺失才退化取文件尾
决策记录新增 2026-09-17 条目,记下 manager 恒 None / commands 磁盘回填 / 前端自 1b40f030e 起不再读取的现状与本次决策
2026-09-17 18:46:37 +08:00
k88936 180a249a68 删掉聊天回读路径里已无人调用的回读函数与前端死类型
删除 Rust 侧 read_direct_tool_calls_at 与 read_direct_turn_stream_at:前端已改走项目对话历史,这两个回读函数不再有调用方
direct_tool_calls / direct_turn_stream 的模块注释与上限常量口径改成写侧,回读命令退役后不再提「回读」
direct_tool_calls 单测改为直接读盘断言落盘内容,删掉只服务已删回读的跳过损坏行 / 合并重复行 / 回读上限三条用例
写前读取的 UTF-8 容错用例改名为 tool_call_pre_read_skips_invalid_utf8_line 并补缺文件返回空表断言
app/types.ts 删除 GameCreatorDirectTurnUpdateEvent 与 TurnStream* 五个只服务旧事件链路的类型
工具卡片方案 §3 由「回读命令」改写为「落盘契约(写侧)」,文首修订说明同步
2026-09-17 18:46:36 +08:00
lhk229 61e1021a4d Merge branch 'master' into fix/chat-status-lost
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
2026-09-17 17:17:57 +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 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 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 656c89e4b2 把 DirectProject 聊天条目的投影从前端收口,Rust 只搬运脱敏原始条目
新增 agent/direct_thread_raw_item.rs:把 Codex 原始 response item 与 app-server item 收敛成同一形状的脱敏原始条目,只做挑字段、脱敏、截断,不再生成卡片的 kind / 标题 / 折叠摘要
删除 agent/direct_chat_entry.rs:工具卡片形状不再由 Rust 生产
历史切片改为返回脱敏原始条目列表,不再在 Rust 侧合并 function_call 与 function_call_output
新增前端 directThreadItemProjection.ts:工具卡片 kind / 标题 / 摘要 / 状态与可见性判定全部在前端完成
directThreadChat reducer 改用同一投影函数处理运行态事件,新增 mergeDirectHistoryItems 走同一份投影与合并规则
DirectThreadHistorySlice 去掉 entries 与 itemTimestamps,改为 items + firstItemId
2026-09-16 18:45:13 +08:00
k88936 0ec1179bf1 移除前端 consume 轮询兜底
- 唤醒只由 notify 驱动,bootstrap 后立刻 consume 一次补齐竞态窗口
- 定时 tick 无法自举:判断活动回合本身依赖事件,唤醒丢失时定时器不会启动
- 主规范、ADR 与实施计划同步删除该条款
2026-09-16 18:17:38 +08:00
k88936 2938a49cac 固化 DirectProject 聊天真相源契约
- 主规范新增首屏锚点、生命周期锚点必进 bootstrap、事件自足与不提供单点快照四条契约
- 新增 ADR 记录单一事实源取舍、删除清单与待验证的 id 空间风险
- 新增里程碑与实施计划,CONTEXT.md 补项目对话历史/运行态事件/聊天投影词条
- 目标文档合同与文档索引同步
2026-09-16 18:05:30 +08:00
k88936 ae0f9376c9 新增 DirectProject 聊天 reducer 深模块
- directThreadChat 把运行态事件与历史切片归并成同一份聊天条目,活动回合只由 turn.started/turn.completed 判定
- item.delta 载荷补 kind,reducer 不再靠 itemId 猜条目类型
- bootstrap 原子替换运行态并保留历史窗口,历史与运行态按 callId ?? itemId 去重合并
- 新增 5 条 reducer 单测
2026-09-16 18:05:10 +08:00
k88936 29d0cbb4df 运行态事件与历史切片共用聊天条目投影
- 新增 direct_chat_entry 深模块,把原始 response item 与 app-server item 投影成同一聊天条目
- item.started/item.completed 载荷改为完整条目,前端不再需要按 itemId 取快照
- 原始 function_call 与 function_call_output 按 callId 并成一张卡片,文本与明细统一脱敏截断
- 历史切片新增 entries 与 first_item_id,分页锚点不再依赖是否出现可显示条目
2026-09-16 18:01:50 +08:00
k88936 721e45f01b 统一 DirectProject 运行态条目的合并身份
- Thread Manager 事件新增 callId,条目身份统一为 callId 优先、其次 itemId
- 工具条目按 callId 归并 started/completed,不再留下永远收不到完成事件的幽灵活跃条目
- 历史锚点仍取 response item id,保证分页锚点能在 project.jsonl 中命中
- 补充跨 id 空间归并的单元测试
2026-09-16 17:45:28 +08:00
65 changed files with 6013 additions and 4094 deletions
+16
View File
@@ -172,6 +172,20 @@ _Avoid_: 多步骤向导、完整规则编辑器、拖拽编辑器
Bark Battle 平台作品闭环按契约与领域规则、后端存储/API、最小前端纵切、投影体验、收口验证的顺序推进。 Bark Battle 平台作品闭环按契约与领域规则、后端存储/API、最小前端纵切、投影体验、收口验证的顺序推进。
_Avoid_: mock 先行堆积、前后端各自发散、先做排行榜 UI _Avoid_: mock 先行堆积、前后端各自发散、先做排行榜 UI
## 项目开发对话(DirectProject
**项目对话历史**:
AGC 本地项目内 Codex 原始对话条目的持久集合,是聊天展示、工具卡片和线程恢复注入的唯一持久事实源。
_Avoid_: 会话缓存、展示态历史、按 UI 需要另存的对话副本
**运行态事件**:
Thread Manager 向订阅者推送的当前回合原始事件流,只服务运行期间与短期断线恢复,不替代项目对话历史。
_Avoid_: 进度通知、快照轮询、第二套历史
**聊天投影**:
把项目对话历史条目与运行态事件转换成消息气泡和工具卡片的读取期转换;不持久化,也不构成事实源。
_Avoid_: 投影缓存文件、已脱敏卡片库、第二套 reducer
## Relationships ## Relationships
- 一个 **汪汪声浪大作战** 单局包含多个 **有效声浪触发** - 一个 **汪汪声浪大作战** 单局包含多个 **有效声浪触发**
@@ -206,3 +220,5 @@ _Avoid_: mock 先行堆积、前后端各自发散、先做排行榜 UI
- “入口闭环”曾可能只指内部 demo 或单个详情 CTA;已解析为 **正式作品入口闭环**,不新增独立专区或活动页。 - “入口闭环”曾可能只指内部 demo 或单个详情 CTA;已解析为 **正式作品入口闭环**,不新增独立专区或活动页。
- “创作编辑”曾可能指多步骤向导或完整编辑器;已解析为 **轻配置编辑流程**,使用单页表单 + 预览卡片完成保存草稿、发布和发布后跳转作品详情。 - “创作编辑”曾可能指多步骤向导或完整编辑器;已解析为 **轻配置编辑流程**,使用单页表单 + 预览卡片完成保存草稿、发布和发布后跳转作品详情。
- “实施顺序”曾可能按 UI 或功能并行发散;已解析为契约/领域规则先行,再做后端存储/API,随后打通最小前端纵切,最后补投影体验与收口验证。 - “实施顺序”曾可能按 UI 或功能并行发散;已解析为契约/领域规则先行,再做后端存储/API,随后打通最小前端纵切,最后补投影体验与收口验证。
- “回合进度事件”曾同时指 Direct turn update 与 Thread Manager 运行态事件;已解析为 AGC 项目开发对话只保留 **运行态事件**
- “哪些消息可显示”曾可能由后端历史分页判断;已解析为可见性判断属于 **聊天投影**,后端只按原始条目分页,前端负责跳过不可显示条目并推进分页锚点。
@@ -21,6 +21,7 @@ mod direct_project_history;
mod direct_project_turn_history; mod direct_project_turn_history;
mod direct_runtime; mod direct_runtime;
mod direct_thread_manager; mod direct_thread_manager;
mod direct_thread_wire;
mod direct_tool_bridge; mod direct_tool_bridge;
mod direct_tool_calls; mod direct_tool_calls;
mod direct_tools_mcp; mod direct_tools_mcp;
@@ -40,7 +41,8 @@ use codex_app_server::*;
pub(crate) use codex_app_server::{ pub(crate) use codex_app_server::{
cancel_direct_codex_turn_at, cancel_direct_codex_turn_at,
direct_codex_canonical_project_identity_for_commands as direct_codex_canonical_project_identity, direct_codex_canonical_project_identity_for_commands as direct_codex_canonical_project_identity,
direct_game_creator_codex_chat_at, direct_game_creator_home_codex_chat, DirectTurnCancelView, direct_game_creator_codex_chat_at, direct_game_creator_home_codex_chat,
direct_thread_id_for_project, DirectTurnCancelView,
}; };
use codex_cli::*; use codex_cli::*;
pub(crate) use codex_cli::{ pub(crate) use codex_cli::{
@@ -55,6 +57,7 @@ pub(crate) use direct_project_history::*;
pub(crate) use direct_project_turn_history::*; pub(crate) use direct_project_turn_history::*;
pub(crate) use direct_runtime::*; pub(crate) use direct_runtime::*;
pub(crate) use direct_thread_manager::*; pub(crate) use direct_thread_manager::*;
pub(crate) use direct_thread_wire::*;
pub(crate) use direct_tool_bridge::*; pub(crate) use direct_tool_bridge::*;
pub(crate) use direct_tool_calls::*; pub(crate) use direct_tool_calls::*;
pub(crate) use direct_tools_mcp::*; pub(crate) use direct_tools_mcp::*;
@@ -20,6 +20,29 @@ pub(crate) fn direct_codex_canonical_project_identity(
)) ))
} }
/// 项目根目录在 Thread Manager 里的线程身份。
///
/// 订阅入口、回合事件写入和"回合被兜底释放"三处必须算出同一个字符串,否则前端会订阅到
/// 一个永不产生事件的空线程。这个字符串**只取决于路径**:能归一就用 canonical 路径,只有
/// 归一本身失败(路径不存在 / 不是目录 / 无法安全解析)才退回调用方给的字符串。
///
/// 这里刻意不读 `.agent/manifest.json`:那次读取是"项目权威身份"(连接池摘要,见
/// `direct_codex_canonical_project_identity`)的要求,而线程 id 只是一个路径 key。把
/// manifest 的瞬时抖动混进线程 id,会让同一项目在"订阅那一刻"与"跑回合那一刻"算出两个
/// 字符串(例如调用方给的是符号链接路径),订阅就绑到一条永远不会有事件的空线程上。
pub(crate) fn direct_thread_id_for_project(root: &std::path::Path) -> String {
let Ok((canonical_root, _)) = resolve_direct_codex_project_authority(root) else {
return root.to_string_lossy().into_owned();
};
canonical_root
.to_str()
.and_then(|value| value.strip_prefix(r"\\?\"))
.map(std::path::Path::new)
.unwrap_or(canonical_root.as_path())
.to_string_lossy()
.into_owned()
}
pub(super) fn direct_codex_os_path_identity_bytes(path: &std::path::Path) -> Vec<u8> { pub(super) fn direct_codex_os_path_identity_bytes(path: &std::path::Path) -> Vec<u8> {
#[cfg(unix)] #[cfg(unix)]
{ {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,4 @@
//! GameAgent 对话「工具调用卡片」的采集持久化与回读 //! GameAgent 对话「工具调用卡片」的采集持久化。
//! //!
//! 契约见 `docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md` //! 契约见 `docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md`
//! Codex app-server 的 `item/started` / `item/completed` 里带着完整的命令 / 文件变更 //! Codex app-server 的 `item/started` / `item/completed` 里带着完整的命令 / 文件变更
@@ -8,11 +8,9 @@
//! 为什么不复用 `project.jsonl`:那条链路的回读只投影 `role ∈ {user, assistant}` 的 //! 为什么不复用 `project.jsonl`:那条链路的回读只投影 `role ∈ {user, assistant}` 的
//! 文本条目,而且会被注入 Codex 上下文。往里面塞新形状既装不下,又有污染模型上下文的风险。 //! 文本条目,而且会被注入 Codex 上下文。往里面塞新形状既装不下,又有污染模型上下文的风险。
use crate::agent::redact_secret_tokens; use super::direct_thread_wire::sanitize_detail_text;
use crate::agent::sanitize_error_context; use crate::config::write_game_creator_private_file;
use crate::config::{prepare_game_creator_private_path_for_read, write_game_creator_private_file};
use crate::project::{enforce_project_permission_policy, project_append_lock_for}; use crate::project::{enforce_project_permission_policy, project_append_lock_for};
use crate::redact_absolute_path_tokens;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use std::collections::BTreeMap; use std::collections::BTreeMap;
@@ -24,7 +22,7 @@ use std::path::{Path, PathBuf};
pub(crate) const DIRECT_TOOL_CALL_RECORD_TYPE: &str = "tool_call_item"; pub(crate) const DIRECT_TOOL_CALL_RECORD_TYPE: &str = "tool_call_item";
/// 条目 schema 版本。 /// 条目 schema 版本。
pub(crate) const DIRECT_TOOL_CALL_SCHEMA_VERSION: &str = "agc-tool-call.v1"; pub(crate) const DIRECT_TOOL_CALL_SCHEMA_VERSION: &str = "agc-tool-call.v1";
/// 回读上限:只保留最近这么多条(按 `updatedAt` / `startedAt` 取最新)。 /// 落盘上限:只保留最近这么多条(按 `updatedAt` / `startedAt` 取最新)。
pub(crate) const DIRECT_TOOL_CALL_LIMIT: usize = 200; pub(crate) const DIRECT_TOOL_CALL_LIMIT: usize = 200;
/// `detail.command` / `detail.output` 的字符上限。 /// `detail.command` / `detail.output` 的字符上限。
const DIRECT_TOOL_CALL_DETAIL_MAX_CHARS: usize = 4000; const DIRECT_TOOL_CALL_DETAIL_MAX_CHARS: usize = 4000;
@@ -86,134 +84,6 @@ fn tool_calls_path(root: &Path) -> PathBuf {
root.join(".agent/conversations/tool-calls.jsonl") root.join(".agent/conversations/tool-calls.jsonl")
} }
/// 项目根目录之后的路径 token:分隔符统一成 `/`,返回 `(消费到的下标, 项目相对路径)`。
fn project_relative_path_segment(value: &str, start: usize) -> (usize, String) {
let mut index = start;
let mut relative = String::new();
while index < value.len() {
let character = value[index..].chars().next().unwrap_or_default();
if matches!(character, '/' | '\\') {
if !relative.is_empty() {
relative.push('/');
}
index += character.len_utf8();
continue;
}
if character.is_whitespace()
|| matches!(
character,
'\'' | '"'
| '`'
| ','
| ';'
| '|'
| '&'
| '('
| ')'
| '['
| ']'
| '{'
| '}'
| '<'
| '>'
| ':'
)
{
break;
}
relative.push(character);
index += character.len_utf8();
}
while relative.ends_with('/') {
relative.pop();
}
(index, relative)
}
/// 把项目根目录前缀换成**项目相对路径**`<root>/game/src/x.ts` → `game/src/x.ts`)。
///
/// 必须排在 `redact_absolute_path_tokens` 之前:后者会把整个绝对路径抹成
/// `<absolute-path>`,之后就再也认不出哪些路径在项目内了。
/// Windows 上同时匹配 `\` 与 `/` 两种分隔符写法,并按大小写不敏感比较(盘符大小写会变)。
fn relativize_project_root_paths(root: &Path, value: &str) -> String {
let root_text = root.to_string_lossy();
let root_text = root_text.trim_end_matches(['/', '\\']);
if root_text.is_empty() {
return value.to_string();
}
let mut needles = [
root_text.to_string(),
root_text.replace('\\', "/"),
root_text.replace('/', "\\"),
]
.into_iter()
.map(|needle| needle.to_ascii_lowercase())
.filter(|needle| !needle.is_empty())
.collect::<Vec<_>>();
needles.sort();
needles.dedup();
let lower = value.to_ascii_lowercase();
let mut output = String::with_capacity(value.len());
let mut cursor = 0usize;
while cursor < value.len() {
let mut hit: Option<(usize, usize)> = None;
for needle in &needles {
let mut search = cursor;
while let Some(relative) = lower[search..].find(needle.as_str()) {
let start = search + relative;
let end = start + needle.len();
let left_is_boundary = start == 0
|| lower[..start].chars().next_back().is_some_and(|character| {
!character.is_alphanumeric() && character != '_' && character != '-'
});
if left_is_boundary && value[end..].starts_with(['/', '\\']) {
if hit.is_none_or(|(best_start, _)| start < best_start) {
hit = Some((start, end));
}
break;
}
search = end;
}
}
let Some((start, end)) = hit else {
break;
};
output.push_str(&value[cursor..start]);
let (consumed, relative) = project_relative_path_segment(value, end);
if relative.is_empty() {
// 只写了项目根目录本身(没有后续路径段):按占位形状处理。
output.push_str("<absolute-path>");
} else {
output.push_str(&relative);
}
cursor = consumed;
}
output.push_str(&value[cursor..]);
output
}
/// 脱敏:项目内绝对路径先归一化成项目相对路径,再依次做绝对路径、密钥前缀与
/// 错误上下文脱敏。
///
/// 顺序不能反:先抹密钥会把 `sk-…` 之类的 token 换成占位符,但绝对路径里的用户名目录
/// 仍然会留下;这里先归一化路径 token,再处理密钥。
///
/// 复用既有 `agent/generation/prompt_context.rs` 的脱敏组合:`sanitize_error_context`
/// 就是 `redact_secret_tokens` + `redact_error_sensitive_assignments` +
/// `redact_error_bearer_values` + `redact_error_config_names` 的既有组合用法,覆盖
/// `Authorization: Bearer …`、`Cookie: …`、`api_key=…`、`client_secret=…` 这类键值凭据;
/// 含 `--password` / `--token` / `--secret` 这类敏感 CLI 标志的行按既有 fail-closed
/// 约定整行替换成 `[redacted sensitive context]`(与 `sanitize_agent_runtime_text` 一致)。
///
/// `pub(crate)`:回合流(`direct_turn_stream`)的文本段复用同一套脱敏,避免两处口径分叉。
pub(crate) fn sanitize_detail_text(root: &Path, value: &str) -> String {
let without_project_root = relativize_project_root_paths(root, value);
let without_absolute = redact_absolute_path_tokens(&without_project_root);
let without_secret = redact_secret_tokens(&without_absolute);
sanitize_error_context(&without_secret)
}
/// 按字符数截断(不切坏 UTF-8),并在真正截断时补省略号。 /// 按字符数截断(不切坏 UTF-8),并在真正截断时补省略号。
fn bounded_chars(value: &str, max_chars: usize) -> String { fn bounded_chars(value: &str, max_chars: usize) -> String {
if value.chars().count() <= max_chars { if value.chars().count() <= max_chars {
@@ -527,15 +397,6 @@ fn normalize_tool_calls(calls: Vec<DirectToolCall>) -> Vec<DirectToolCall> {
normalized normalized
} }
/// 回读:文件缺失返回空数组;单行损坏跳过;按时间正序,最多最近 200 条。
pub(crate) fn read_direct_tool_calls_at(root: &Path) -> Result<Vec<DirectToolCall>, String> {
let path = tool_calls_path(root);
if !prepare_game_creator_private_path_for_read(&path, false, "工具调用历史")? {
return Ok(Vec::new());
}
Ok(normalize_tool_calls(read_tool_call_lines(&path)))
}
/// 状态的「确定性」排序:终态(`completed` / `failed`)优先于 `running`。 /// 状态的「确定性」排序:终态(`completed` / `failed`)优先于 `running`。
fn status_certainty(status: &str) -> u8 { fn status_certainty(status: &str) -> u8 {
match status { match status {
@@ -684,10 +545,22 @@ mod tests {
use super::{ use super::{
direct_tool_call_from_item, direct_tool_call_now_ms, direct_tool_call_status, direct_tool_call_from_item, direct_tool_call_now_ms, direct_tool_call_status,
direct_tool_call_status_changed, persist_direct_tool_call_at, persist_direct_tool_calls_at, direct_tool_call_status_changed, persist_direct_tool_call_at, persist_direct_tool_calls_at,
read_direct_tool_calls_at, sanitize_detail_text, tool_calls_path, DirectToolCall, read_tool_call_lines, sanitize_detail_text, tool_call_from_line, tool_calls_path,
DirectToolCallDetail, DIRECT_TOOL_CALL_LIMIT, DIRECT_TOOL_CALL_SCHEMA_VERSION, DirectToolCall, DirectToolCallDetail, DIRECT_TOOL_CALL_LIMIT,
DIRECT_TOOL_CALL_SCHEMA_VERSION,
}; };
use serde_json::json; use serde_json::json;
use std::path::Path;
/// 写侧用例直接读文件:回读命令退役后不再经过 `normalize_tool_calls` 的合并与裁剪,
/// 断言因此落在「磁盘上到底写了什么」这一层。
fn persisted_tool_calls(root: &Path) -> Vec<DirectToolCall> {
std::fs::read_to_string(tool_calls_path(root))
.unwrap_or_default()
.lines()
.filter_map(tool_call_from_line)
.collect()
}
/// 一行合法的落盘信封(回读用例的夹具)。 /// 一行合法的落盘信封(回读用例的夹具)。
fn tool_call_row(id: &str, started_at: u64, updated_at: u64) -> String { fn tool_call_row(id: &str, started_at: u64, updated_at: u64) -> String {
@@ -812,7 +685,7 @@ mod tests {
.expect("completed tool call"); .expect("completed tool call");
persist_direct_tool_call_at(root.path(), &completed).expect("persist completed"); persist_direct_tool_call_at(root.path(), &completed).expect("persist completed");
let calls = read_direct_tool_calls_at(root.path()).expect("read tool calls"); let calls = persisted_tool_calls(root.path());
assert_eq!(calls.len(), 1, "同一 id 只能有一行"); assert_eq!(calls.len(), 1, "同一 id 只能有一行");
assert_eq!(calls[0].status, "completed"); assert_eq!(calls[0].status, "completed");
assert_eq!(calls[0].started_at, 1000, "startedAt 不被 completed 覆盖"); assert_eq!(calls[0].started_at, 1000, "startedAt 不被 completed 覆盖");
@@ -912,85 +785,6 @@ mod tests {
); );
} }
/// 判据:单行损坏只跳过该行,不整体失败;缺文件返回空数组。
#[test]
fn tool_call_read_skips_corrupted_lines() {
let root = init_tool_call_project("tool-call-corrupt");
let path = tool_calls_path(root.path());
std::fs::create_dir_all(path.parent().expect("parent")).expect("create dir");
let good = serde_json::to_string(&json!({
"type": "tool_call_item",
"payload": {
"schemaVersion": "agc-tool-call.v1",
"id": "item-good",
"turnId": "turn-1",
"kind": "command",
"title": "执行命令",
"summary": "npm run build",
"status": "completed",
"detail": {"command": "npm run build"},
"startedAt": 1,
"updatedAt": 2
}
}))
.expect("serialize good row");
std::fs::write(
&path,
format!("{good}\n{{ not json\n{{\"type\":\"other\",\"payload\":{{}}}}\n{good}\n"),
)
.expect("write fixture");
let missing = tempfile::tempdir().expect("missing dir");
assert!(
read_direct_tool_calls_at(missing.path())
.expect("missing file is empty")
.is_empty(),
"历史文件缺失必须返回空数组"
);
let calls = read_direct_tool_calls_at(root.path()).expect("read with corrupted lines");
assert_eq!(calls.len(), 1, "坏行被跳过,同 id 归并成一条");
assert_eq!(calls[0].id, "item-good");
}
/// 判据:回读按时间正序,且超出上限时保留最新。
#[test]
fn tool_call_read_is_ordered_and_capped() {
let root = init_tool_call_project("tool-call-cap");
let total = DIRECT_TOOL_CALL_LIMIT + 5;
let calls = (0..total)
.map(|index| {
direct_tool_call_from_item(
root.path(),
&json!({
"id": format!("item-{index:04}"),
"type": "commandExecution",
"command": format!("run {index}"),
"startedAtMs": 1000 + index as u64,
}),
"turn-1",
false,
1000 + index as u64,
)
.expect("tool call")
})
.collect::<Vec<_>>();
persist_direct_tool_calls_at(root.path(), &calls).expect("persist batch");
let read = read_direct_tool_calls_at(root.path()).expect("read capped");
assert_eq!(read.len(), DIRECT_TOOL_CALL_LIMIT, "超出上限保留最新 N 条");
assert_eq!(
read.first().expect("first").id,
format!("item-{:04}", total - DIRECT_TOOL_CALL_LIMIT),
"最早被裁掉的是最旧的条目"
);
assert!(
read.windows(2)
.all(|pair| pair[0].timestamp() <= pair[1].timestamp()),
"回读必须按时间正序"
);
}
/// 判据:fileChange 的标题按去重后的变更数量,摘要取首个变更路径。 /// 判据:fileChange 的标题按去重后的变更数量,摘要取首个变更路径。
#[test] #[test]
fn tool_call_file_change_title_counts_unique_paths() { fn tool_call_file_change_title_counts_unique_paths() {
@@ -1189,7 +983,7 @@ mod tests {
persist_direct_tool_call_at(root.path(), &completed).expect("persist completed first"); persist_direct_tool_call_at(root.path(), &completed).expect("persist completed first");
persist_direct_tool_call_at(root.path(), &running).expect("persist stale running"); persist_direct_tool_call_at(root.path(), &running).expect("persist stale running");
let calls = read_direct_tool_calls_at(root.path()).expect("read after stale single write"); let calls = persisted_tool_calls(root.path());
assert_eq!(calls.len(), 1, "同一 id 只能有一行"); assert_eq!(calls.len(), 1, "同一 id 只能有一行");
assert_eq!( assert_eq!(
calls[0].status, "completed", calls[0].status, "completed",
@@ -1201,7 +995,7 @@ mod tests {
// 回合末整批落盘那条路径同样不得回退。 // 回合末整批落盘那条路径同样不得回退。
persist_direct_tool_calls_at(root.path(), std::slice::from_ref(&running)) persist_direct_tool_calls_at(root.path(), std::slice::from_ref(&running))
.expect("persist stale running batch"); .expect("persist stale running batch");
let calls = read_direct_tool_calls_at(root.path()).expect("read after stale batch write"); let calls = persisted_tool_calls(root.path());
assert_eq!( assert_eq!(
calls[0].status, "completed", calls[0].status, "completed",
"整批落盘路径同样不得把 completed 打回 running" "整批落盘路径同样不得把 completed 打回 running"
@@ -1209,28 +1003,6 @@ mod tests {
assert_eq!(calls[0].updated_at, 2000, "整批落盘不得回退 updatedAt"); assert_eq!(calls[0].updated_at, 2000, "整批落盘不得回退 updatedAt");
} }
/// 判据:读回时同 id 的重复行也按 `updatedAt` 单调合并(磁盘上留有旧快照不得回退状态)。
#[test]
fn tool_call_read_merges_duplicate_rows_monotonically() {
let root = init_tool_call_project("tool-call-read-monotonic");
let path = tool_calls_path(root.path());
std::fs::create_dir_all(path.parent().expect("parent")).expect("create dir");
let completed = tool_call_row("item-1", 1000, 2000);
let stale_running = tool_call_row("item-1", 1000, 1000)
.replace("\"status\":\"completed\"", "\"status\":\"running\"");
assert!(stale_running.contains("\"status\":\"running\""));
std::fs::write(&path, format!("{completed}\n{stale_running}\n")).expect("write fixture");
let calls = read_direct_tool_calls_at(root.path()).expect("read duplicate rows");
assert_eq!(calls.len(), 1, "同 id 归并成一条");
assert_eq!(
calls[0].status, "completed",
"磁盘上更旧的快照不得把状态打回 running"
);
assert_eq!(calls[0].updated_at, 2000, "归并保留更新的 updatedAt");
assert_eq!(calls[0].started_at, 1000);
}
/// 判据:项目内绝对路径落成项目相对路径,项目外绝对路径保持既有占位形状。 /// 判据:项目内绝对路径落成项目相对路径,项目外绝对路径保持既有占位形状。
#[test] #[test]
fn tool_call_paths_become_project_relative() { fn tool_call_paths_become_project_relative() {
@@ -1283,9 +1055,10 @@ mod tests {
); );
} }
/// 判据:单行损坏(含非法 UTF-8 字节)只跳过损坏行,后续合法记录必须继续读回 /// 判据:写前读取时单行损坏(含非法 UTF-8 字节)只跳过损坏行,后续合法记录必须继续读回
/// 否则一次截断写入会把整份工具卡片从后续重写里抹掉。
#[test] #[test]
fn tool_call_read_skips_invalid_utf8_line() { fn tool_call_pre_read_skips_invalid_utf8_line() {
let root = init_tool_call_project("tool-call-invalid-utf8"); let root = init_tool_call_project("tool-call-invalid-utf8");
let path = tool_calls_path(root.path()); let path = tool_calls_path(root.path());
std::fs::create_dir_all(path.parent().expect("parent")).expect("create dir"); std::fs::create_dir_all(path.parent().expect("parent")).expect("create dir");
@@ -1298,7 +1071,7 @@ mod tests {
bytes.extend_from_slice(tool_call_row("item-b", 2000, 2000).as_bytes()); bytes.extend_from_slice(tool_call_row("item-b", 2000, 2000).as_bytes());
bytes.push(b'\n'); bytes.push(b'\n');
std::fs::write(&path, &bytes).expect("write invalid utf8 fixture"); std::fs::write(&path, &bytes).expect("write invalid utf8 fixture");
let calls = read_direct_tool_calls_at(root.path()).expect("read with invalid utf8"); let calls = read_tool_call_lines(&path);
assert_eq!( assert_eq!(
calls.len(), calls.len(),
2, 2,
@@ -1318,7 +1091,7 @@ mod tests {
bytes.extend_from_slice(tool_call_row("item-c", 3000, 3000).as_bytes()); bytes.extend_from_slice(tool_call_row("item-c", 3000, 3000).as_bytes());
bytes.push(b'\n'); bytes.push(b'\n');
std::fs::write(&path, &bytes).expect("write truncated utf8 fixture"); std::fs::write(&path, &bytes).expect("write truncated utf8 fixture");
let calls = read_direct_tool_calls_at(root.path()).expect("read with truncated line"); let calls = read_tool_call_lines(&path);
assert_eq!( assert_eq!(
calls.len(), calls.len(),
2, 2,
@@ -1326,10 +1099,16 @@ mod tests {
); );
assert_eq!(calls[0].id, "item-a"); assert_eq!(calls[0].id, "item-a");
assert_eq!(calls[1].id, "item-c"); assert_eq!(calls[1].id, "item-c");
let missing = tempfile::tempdir().expect("missing dir");
assert!(
read_tool_call_lines(&tool_calls_path(missing.path())).is_empty(),
"历史文件缺失时写前读取必须返回空表"
);
} }
/// 判据:200 条上限「按时间保留最新 200 条」,超出时更早回合的卡片会被静默丢弃 /// 判据:落到磁盘上的行同样受 200 条上限约束——「按时间保留最新 200 条」,更早回合的
/// (契约内行为,不是缺陷)。本用例只钉住现状与时间正序。 /// 卡片会被静默丢弃(契约内行为,不是缺陷)。本用例只钉住现状与时间正序。
#[test] #[test]
fn tool_call_cap_drops_oldest_turn_cards() { fn tool_call_cap_drops_oldest_turn_cards() {
let root = init_tool_call_project("tool-call-cap-oldest"); let root = init_tool_call_project("tool-call-cap-oldest");
@@ -1366,7 +1145,7 @@ mod tests {
.expect("newest tool call"); .expect("newest tool call");
persist_direct_tool_call_at(root.path(), &newest).expect("persist newest"); persist_direct_tool_call_at(root.path(), &newest).expect("persist newest");
let read = read_direct_tool_calls_at(root.path()).expect("read capped"); let read = persisted_tool_calls(root.path());
assert_eq!(read.len(), DIRECT_TOOL_CALL_LIMIT, "上限仍是 200 条"); assert_eq!(read.len(), DIRECT_TOOL_CALL_LIMIT, "上限仍是 200 条");
assert_eq!( assert_eq!(
read.last().expect("last").id, read.last().expect("last").id,
@@ -1381,7 +1160,7 @@ mod tests {
assert!( assert!(
read.windows(2) read.windows(2)
.all(|pair| pair[0].timestamp() <= pair[1].timestamp()), .all(|pair| pair[0].timestamp() <= pair[1].timestamp()),
"回读必须按时间正序" "落盘顺序必须按时间正序"
); );
} }
@@ -1,4 +1,4 @@
//! GameAgent 对话「回合流」的采集持久化与回读 //! GameAgent 对话「回合流」的采集持久化。
//! //!
//! 顺序真相放在一处:`<projectRoot>/.agent/conversations/turn-stream.jsonl` 按**出现顺序** //! 顺序真相放在一处:`<projectRoot>/.agent/conversations/turn-stream.jsonl` 按**出现顺序**
//! 记录一个回合里的文本段与工具调用。工具条目只记位置标记(`callId`),工具本身的正文 //! 记录一个回合里的文本段与工具调用。工具条目只记位置标记(`callId`),工具本身的正文
@@ -11,7 +11,7 @@
//! `project.jsonl` 保留原始消息;本流补充文本与工具交替的 item 顺序,不能重复展示两份正文。 //! `project.jsonl` 保留原始消息;本流补充文本与工具交替的 item 顺序,不能重复展示两份正文。
use crate::agent::sanitize_detail_text; use crate::agent::sanitize_detail_text;
use crate::config::{prepare_game_creator_private_path_for_read, write_game_creator_private_file}; use crate::config::write_game_creator_private_file;
use crate::project::{enforce_project_permission_policy, project_append_lock_for}; use crate::project::{enforce_project_permission_policy, project_append_lock_for};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
@@ -339,15 +339,6 @@ pub(crate) fn upsert_direct_turn_stream_item_at(
}) })
} }
/// 回读:文件缺失返回空数组;单行损坏跳过;按 `seq` 正序,最多最后 400 条。
pub(crate) fn read_direct_turn_stream_at(root: &Path) -> Result<Vec<DirectTurnStreamItem>, String> {
let path = turn_stream_path(root);
if !prepare_game_creator_private_path_for_read(&path, false, "回合流历史")? {
return Ok(Vec::new());
}
Ok(normalize_stream_items(read_stream_lines(&path)))
}
/// 追加一段固定身份的文本段(失败说明等):位置排在当前流末尾。 /// 追加一段固定身份的文本段(失败说明等):位置排在当前流末尾。
/// ///
/// 幂等:同一 `(turnId, itemId)` 已经存在时只更新文本与 `updatedAt`(回合重放 / 重复收尾 /// 幂等:同一 `(turnId, itemId)` 已经存在时只更新文本与 `updatedAt`(回合重放 / 重复收尾
@@ -1,7 +1,7 @@
use super::*; use super::*;
use crate::agent::{ use crate::agent::{
direct_codex_canonical_project_identity, read_direct_project_chat_history_at, read_direct_project_chat_history_at, read_direct_project_last_item_id_at,
read_direct_project_last_item_id_at, DirectProjectHistoryAnchor,
}; };
use crate::ui_editor::resource::font::FontAsset; use crate::ui_editor::resource::font::FontAsset;
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
@@ -5677,31 +5677,6 @@ pub(crate) async fn read_agent_runtime_error_detail(
.await .await
.map_err(|error| format!("读取统一错误诊断后台任务失败:{error}"))? .map_err(|error| format!("读取统一错误诊断后台任务失败:{error}"))?
} }
#[tauri::command]
pub(crate) async fn read_direct_tool_calls(
project_path: String,
) -> Result<Vec<DirectToolCall>, String> {
tauri::async_runtime::spawn_blocking(move || {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "conversation.read")?;
read_direct_tool_calls_at(root)
})
.await
.map_err(|error| format!("读取工具调用历史后台任务失败:{error}"))?
}
#[tauri::command]
pub(crate) async fn read_direct_turn_stream(
project_path: String,
) -> Result<Vec<DirectTurnStreamItem>, String> {
tauri::async_runtime::spawn_blocking(move || {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "conversation.read")?;
read_direct_turn_stream_at(root)
})
.await
.map_err(|error| format!("读取回合流历史后台任务失败:{error}"))?
}
#[tauri::command] #[tauri::command]
pub(crate) fn list_game_creator_direct_active_turns( pub(crate) fn list_game_creator_direct_active_turns(
@@ -5716,13 +5691,7 @@ pub(crate) async fn subscribe_direct_project_thread(
tauri::async_runtime::spawn_blocking(move || { tauri::async_runtime::spawn_blocking(move || {
let root = Path::new(project_path.trim()); let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "conversation.read")?; enforce_project_permission_policy(root, "conversation.read")?;
let (canonical_root, _) = direct_codex_canonical_project_identity(root)?; let thread_id = direct_thread_id_for_project(root);
let thread_root = canonical_root
.to_str()
.and_then(|value| value.strip_prefix("\\\\?\\"))
.map(Path::new)
.unwrap_or(canonical_root.as_path());
let thread_id = thread_root.to_string_lossy().into_owned();
let mut bootstrap = subscribe_direct_thread(&thread_id); let mut bootstrap = subscribe_direct_thread(&thread_id);
if bootstrap.last_completed_item_id.is_none() { if bootstrap.last_completed_item_id.is_none() {
bootstrap.last_completed_item_id = read_direct_project_last_item_id_at(root)?; bootstrap.last_completed_item_id = read_direct_project_last_item_id_at(root)?;
@@ -5740,34 +5709,43 @@ pub(crate) fn consume_direct_project_thread(
consume_direct_thread(subscription_id.trim()) consume_direct_thread(subscription_id.trim())
} }
/// 读一屏项目对话历史。
///
/// 窗口两端各由一个锚点给出,两者互斥(都传会报错):`before_item_id` 是**旧端**边界(不含
/// 该条,向后翻页用),`through_item_id` 是**新端**边界(含该条,取 `subscribe` 回执里的
/// `lastCompletedItemId`,比它更新的条目只从运行态事件来);都不传就是文件尾最近的一屏。
#[tauri::command] #[tauri::command]
pub(crate) async fn read_direct_project_history_slice( pub(crate) async fn read_direct_project_history_slice(
project_path: String, project_path: String,
before_item_id: Option<String>, before_item_id: Option<String>,
through_item_id: Option<String>,
limit: Option<usize>, limit: Option<usize>,
messages_only: Option<bool>,
) -> Result<DirectThreadHistorySlice, String> { ) -> Result<DirectThreadHistorySlice, String> {
tauri::async_runtime::spawn_blocking(move || { tauri::async_runtime::spawn_blocking(move || {
let root = Path::new(project_path.trim()); let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "conversation.read")?; enforce_project_permission_policy(root, "conversation.read")?;
let read_slice = if messages_only.unwrap_or(false) { let anchor = match (before_item_id.as_deref(), through_item_id.as_deref()) {
read_direct_project_chat_items_slice_at (Some(_), Some(_)) => {
} else { return Err(
read_direct_project_history_items_slice_at "DirectProject 历史切片只接受一个锚点(beforeItemId / throughItemId"
.to_string(),
)
}
(Some(before), None) => DirectProjectHistoryAnchor::Before(before),
(None, Some(through)) => DirectProjectHistoryAnchor::Through(through),
(None, None) => DirectProjectHistoryAnchor::Newest,
}; };
let (items, has_more, item_timestamps) = let (raw_items, has_more, recorded_at_ms, first_item_id) =
read_slice(root, before_item_id.as_deref(), limit.unwrap_or(20))?; read_direct_project_history_items_slice_at(root, anchor, limit.unwrap_or(20))?;
let oldest_item_id = items let items = direct_thread_items_from_history(root, &raw_items, |item| {
.first() direct_thread_item_identity(item)
.and_then(|item| item.get("id")) .and_then(|identity| recorded_at_ms.get(&identity).copied())
.and_then(serde_json::Value::as_str) .unwrap_or_default()
.filter(|id| !id.is_empty()) });
.map(str::to_string);
Ok(DirectThreadHistorySlice { Ok(DirectThreadHistorySlice {
items, items,
has_more, has_more,
item_timestamps, first_item_id,
oldest_item_id,
}) })
}) })
.await .await
@@ -2647,8 +2647,6 @@ fn main() {
archive_game_creator_agent_session, archive_game_creator_agent_session,
read_local_conversation, read_local_conversation,
read_direct_project_conversation, read_direct_project_conversation,
read_direct_tool_calls,
read_direct_turn_stream,
read_agent_runtime_error_detail, read_agent_runtime_error_detail,
list_game_creator_direct_active_turns, list_game_creator_direct_active_turns,
subscribe_direct_project_thread, subscribe_direct_project_thread,
File diff suppressed because it is too large Load Diff
@@ -14,6 +14,8 @@ export const AGENT_RUN_HISTORY_INITIAL_VISIBLE_COUNT = 20;
export const AGENT_RUN_HISTORY_VISIBLE_STEP = 20; export const AGENT_RUN_HISTORY_VISIBLE_STEP = 20;
export const CONVERSATION_INITIAL_VISIBLE_COUNT = 20; export const CONVERSATION_INITIAL_VISIBLE_COUNT = 20;
export const CONVERSATION_VISIBLE_STEP = 20; export const CONVERSATION_VISIBLE_STEP = 20;
/** 一次翻页操作最多连拉几页:见 ADR「分页锚点取原始条目 id」。 */
export const DIRECT_HISTORY_MAX_PAGES_PER_ACTION = 5;
export const AGENT_CHAT_SCROLL_BOTTOM_THRESHOLD = 48; export const AGENT_CHAT_SCROLL_BOTTOM_THRESHOLD = 48;
export const PROJECT_SUPERVISOR_AGENT_ID = 'project-supervisor'; export const PROJECT_SUPERVISOR_AGENT_ID = 'project-supervisor';
/** /**
+3 -90
View File
@@ -999,8 +999,6 @@ export interface ChatMessage {
agentId?: string | null; agentId?: string | null;
updatedAt?: number; updatedAt?: number;
runtimeOwned?: boolean; runtimeOwned?: boolean;
/** 来自历史回读,不作为尚未落盘的实时消息追加到新历史页末尾。 */
fromHistory?: boolean;
} }
export type DesignAgentInput = export type DesignAgentInput =
@@ -1079,27 +1077,6 @@ export interface AgentProgressEvent {
message: string; message: string;
} }
export type GameCreatorDirectTurnUpdateStatus =
| 'accepted'
| 'running'
| 'streaming'
| 'finalizing'
| 'completed'
| 'failed';
export type GameCreatorDirectTurnActivity =
| 'request-accepted'
| 'preparing'
| 'file-read'
| 'file-write'
| 'game-verify'
| 'command-exec'
| 'controlled-tool'
| 'web-search'
| 'context-compaction'
| 'response-finalization'
| 'none';
export type GameCreatorDirectToolCallKind = export type GameCreatorDirectToolCallKind =
| 'command' | 'command'
| 'file_change' | 'file_change'
@@ -1127,10 +1104,9 @@ export interface GameCreatorDirectToolCallDetail {
/** /**
* 一条工具调用(Codex item 的结构化投影)。 * 一条工具调用(Codex item 的结构化投影)。
* *
* 契约见 `docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md` * 契约见 `docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md`字段形状与
* 字段形状与 Rust 侧 `DirectToolCall`、独立历史文件 * Rust 侧 `DirectToolCall`、独立历史文件 `.agent/conversations/tool-calls.jsonl` 的 payload
* `.agent/conversations/tool-calls.jsonl` 的 payload 一致(这里少 `turnId` 的变体用于 * 一致(DirectProject 聊天卡片用 `Omit<GameCreatorDirectToolCall, 'turnId'>` 这一变体)。
* 事件增量,见下面 `GameCreatorDirectTurnToolCall`)。
*/ */
export interface GameCreatorDirectToolCall { export interface GameCreatorDirectToolCall {
schemaVersion: string; schemaVersion: string;
@@ -1145,69 +1121,6 @@ export interface GameCreatorDirectToolCall {
updatedAt: number; updatedAt: number;
} }
/** 事件里下发的增量条目:与持久化同形,去掉 `turnId`(回合 id 在事件顶层)。 */
export type GameCreatorDirectTurnToolCall = Omit<
GameCreatorDirectToolCall,
'turnId'
>;
export interface GameCreatorDirectTurnUpdateEvent {
projectPath: string;
turnId: string;
sequence: number;
status: GameCreatorDirectTurnUpdateStatus;
activity?: GameCreatorDirectTurnActivity | null;
accumulatedText?: string | null;
/**
* 本回合内**发生变化**的结构化工具调用(只有变化时才带,不是每个 heartbeat 都带全量)。
* 可选:老版本事件没有这个字段,前端拿到 `undefined` 时必须与改造前行为一致。
*/
toolCalls?: GameCreatorDirectTurnToolCall[] | null;
/**
* 本回合当前累计的思考过程(流式,整段替换);拿不到时字段缺席。
*/
reasoningText?: string | null;
/**
* 「文本段 + 工具」的**顺序真相**里本次发生变化的那几条。
*
* 顺序由 `seq`(条目首次出现时钉死)决定,与落盘 `turn-stream.jsonl` 完全同一份数据,
* 前端不再自己猜切点。可选:老版本事件没有这个字段。
*/
streamItems?: TurnStreamItem[] | null;
updatedAt: number;
}
/** 回合流里的一个 `text` 段;`text` 是该段当前累计全文(会随 delta 增长)。 */
export interface TurnStreamTextItem extends TurnStreamItemBase {
kind: 'text';
text: string;
}
/** 回合流里的一个 `tool` 位置标记;工具正文在 `tool-calls.jsonl`(按 `callId` 关联)。 */
export interface TurnStreamToolItem extends TurnStreamItemBase {
kind: 'tool';
callId: string;
}
interface TurnStreamItemBase {
schemaVersion: string;
/** 幂等身份:文本段 `text:<turnId>:<itemId>`、工具 `tool:<turnId>:<callId>`。 */
id: string;
turnId: string;
/** 首次出现的写入序号:**顺序真相**,按它升序渲染。 */
seq: number;
/** 条目首次出现的时刻(Unix 毫秒),同 `seq` 时用它排序。 */
at: number;
updatedAt: number;
}
/**
* 回合流条目(`read_direct_turn_stream` 的返回元素)。
*
* 与 Rust `DirectTurnStreamItem` 同形:`text` 段 ↔ `tool` 位置标记。
*/
export type TurnStreamItem = TurnStreamTextItem | TurnStreamToolItem;
/** `cancel_direct_codex_turn` 的返回值。 */ /** `cancel_direct_codex_turn` 的返回值。 */
export interface DirectTurnCancelView { export interface DirectTurnCancelView {
/** /**
File diff suppressed because it is too large Load Diff
@@ -80,6 +80,7 @@ import {
type ChatComposerDraft, type ChatComposerDraft,
type ChatReference, type ChatReference,
chatReferenceListKey, chatReferenceListKey,
chatReferenceToContentPart,
currentIterationVersionAssets, currentIterationVersionAssets,
dedupeChatReferences, dedupeChatReferences,
refreshResourceReference, refreshResourceReference,
@@ -203,25 +204,7 @@ function collectDraftParts(
if ($isResourceReferenceNode(node)) { if ($isResourceReferenceNode(node)) {
textParts.push(`@${node.__reference.label}`); textParts.push(`@${node.__reference.label}`);
references.push(node.__reference); references.push(node.__reference);
content.push( content.push(chatReferenceToContentPart(node.__reference));
node.__reference.type === 'resource'
? {
type: 'agc_resource_reference',
resourceId: node.__reference.resourceId,
}
: {
type: 'agc_runtime_region_reference',
label: node.__reference.label,
runId: node.__reference.runId,
versionId: node.__reference.versionId,
elementTag: node.__reference.elementTag,
elementRole: node.__reference.elementRole,
text: node.__reference.text,
width: node.__reference.width,
height: node.__reference.height,
resourceIds: node.__reference.resourceIds,
},
);
return; return;
} }
if ($isElementNode(node)) { if ($isElementNode(node)) {
@@ -9,7 +9,7 @@ import {
import { useEffect, useId, useState } from 'react'; import { useEffect, useId, useState } from 'react';
import { AgentMessageContent } from '../../../../../packages/shared/src/components/AgentMessageContent'; import { AgentMessageContent } from '../../../../../packages/shared/src/components/AgentMessageContent';
import type { GameCreatorDirectToolCall } from '../../app/types'; import type { DirectChatToolCard } from './directThreadChat';
import { import {
formatToolCallDuration, formatToolCallDuration,
formatTurnDuration, formatTurnDuration,
@@ -38,7 +38,7 @@ export function ToolCallGroup({
active = false, active = false,
className, className,
}: { }: {
calls: GameCreatorDirectToolCall[]; calls: DirectChatToolCard[];
/** 同一回合用户消息的 `updatedAt`;拿不到就传 0,只显示结束时间。 */ /** 同一回合用户消息的 `updatedAt`;拿不到就传 0,只显示结束时间。 */
userSentAt?: number | null; userSentAt?: number | null;
/** /**
@@ -169,7 +169,7 @@ function ToolCallRow({
call, call,
active, active,
}: { }: {
call: GameCreatorDirectToolCall; call: DirectChatToolCard;
active: boolean; active: boolean;
}) { }) {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
@@ -0,0 +1,59 @@
/**
* 首屏历史锚点闸门:把「订阅回执里的 `lastCompletedItemId`」交给「首屏切片读取」。
*
* 这个 id 是订阅那一刻最后一条已完成条目,也是历史切片与运行态事件的唯一分界:首屏切片只
* 允许取到它为止(含该条),比它更新的条目只能来自运行态事件。回执到达之前首屏必须等它,
* 不能退化成"取文件尾"——那会把回执之后才完成的条目也拉进历史,与运行态事件重叠。
*
* 生命周期:打开项目时首屏读取先开一道闸门(订阅 effect 还没跑),订阅侧复用同一道闸门并在
* 回执到达后 `settle` 它;订阅不可用 / 失败 / 切项目时 settle 成 `null`,首屏退化成取文件尾。
* 一道闸门只服务这次订阅的第一次首屏读取,用过之后置 `consumed`。
*/
export type DirectHistoryAnchorGate = {
projectPath: string;
anchor: Promise<string | null>;
settle: (anchor: string | null) => void;
/** 是否已经被一次首屏读取用掉:同一道闸门只锚定"这次订阅的第一次首屏"。 */
consumed: boolean;
};
/** 为某个项目开一道闸门:`settle` 由订阅侧调用,`anchor` 由首屏读取 `await`。 */
export function openDirectHistoryAnchorGate(
projectPath: string,
): DirectHistoryAnchorGate {
let settle: (anchor: string | null) => void = () => {};
const anchor = new Promise<string | null>((resolve) => {
settle = resolve;
});
return { projectPath, anchor, settle, consumed: false };
}
/** 订阅侧用:已有同项目闸门就复用(首屏可能已经开好),换项目才新开一道。 */
export function reuseOrOpenDirectHistoryAnchorGate(
current: DirectHistoryAnchorGate | null,
projectPath: string,
): DirectHistoryAnchorGate {
if (current && current.projectPath === projectPath) {
return current;
}
return openDirectHistoryAnchorGate(projectPath);
}
/**
* 首屏读取侧用:取这次要等的闸门,`null` 表示这次首屏不该等锚点。
*
* 没有这个项目的闸门(打开项目时订阅 effect 还没跑)就新开一道;同一个订阅下已经用过
* (重开同一个项目)没有新回执可等,返回 `null` 让调用方按当前文件尾取尾屏。
*/
export function directHistoryAnchorGateToWaitFor(
current: DirectHistoryAnchorGate | null,
projectPath: string,
): DirectHistoryAnchorGate | null {
const sameProjectGate =
current && current.projectPath === projectPath ? current : null;
if (!sameProjectGate) {
return openDirectHistoryAnchorGate(projectPath);
}
return sameProjectGate.consumed ? null : sameProjectGate;
}
@@ -0,0 +1,84 @@
/**
* DirectProject 历史分页连拉:一次翻页操作连续取页,直到出现用户看得见的变化。
*
* 可见性判断留在前端聊天投影(后端切片只按原始条目切片):工具卡片与思考文本同样占满一屏,
* 所以一整页完全可能都落进已经渲染的回合里,对用户就是"点了没变化"。判据因此取**合并后
* 聊天投影的回合数增加**(出现新的用户气泡),而不是"这一页里有没有能渲染的条目"。
*
* 这里是该判据的唯一出处:首屏与「显示更早」共用同一条循环,别在调用点各写一份。
*/
import { DIRECT_HISTORY_MAX_PAGES_PER_ACTION } from '../../app/constants';
import {
type DirectChatEntry,
mergeHistoryEntries,
projectDirectHistoryItems,
} from './directThreadChat';
import type {
DirectThreadHistorySlice,
DirectThreadItem,
} from './directThreadEvents';
import { buildDirectChatTurns } from './directTurnPresentation';
export type DirectHistoryPages = {
/** 累积到的条目,保持文件顺序(旧 → 新)。 */
items: DirectThreadItem[];
/** 后端声明的"还有更早的历史";取不动时收口为 false。 */
hasMore: boolean;
/** 下一屏的锚点;取不动或失败时停在最后一个可用的锚点上。 */
firstItemId: string | null;
/** 中断原因;`null` 表示正常停止。 */
error: unknown;
};
function visibleTurnCount(entries: readonly DirectChatEntry[]): number {
return buildDirectChatTurns({ entries }).length;
}
/** 取页是"从新往旧"的,拼回文件顺序要整个翻过来。 */
function inFileOrder(pages: readonly (readonly DirectThreadItem[])[]) {
return [...pages].reverse().flat();
}
export async function readDirectHistoryPages({
existingEntries,
beforeItemId,
readSlice,
}: {
/** 当前聊天视图里的条目:判据必须和用户看到的是同一份。 */
existingEntries: readonly DirectChatEntry[];
beforeItemId: string | null;
readSlice: (beforeItemId: string | null) => Promise<DirectThreadHistorySlice>;
}): Promise<DirectHistoryPages> {
const baseTurnCount = visibleTurnCount(existingEntries);
const pages: DirectThreadItem[][] = [];
let anchor = beforeItemId;
let hasMore = false;
let error: unknown = null;
for (let page = 0; page < DIRECT_HISTORY_MAX_PAGES_PER_ACTION; page += 1) {
let slice: DirectThreadHistorySlice;
try {
slice = await readSlice(anchor);
} catch (thrown) {
// 已经取到的页照常交给调用方:第 N 页失败不该丢掉前 N-1 页。
error = thrown;
break;
}
pages.push([...slice.items]);
hasMore = slice.hasMore;
const nextAnchor = slice.firstItemId;
if (slice.items.length === 0 || !nextAnchor || nextAnchor === anchor) {
// 锚点不前进就再也取不到更早的页:继续只会重复拿回同一个窗口。按"取不动了"收口。
hasMore = false;
break;
}
anchor = nextAnchor;
if (!hasMore) break;
const merged = mergeHistoryEntries(
projectDirectHistoryItems(inFileOrder(pages)),
existingEntries,
);
if (visibleTurnCount(merged) > baseTurnCount) break;
}
return { items: inFileOrder(pages), hasMore, firstItemId: anchor, error };
}
@@ -0,0 +1,272 @@
/**
* DirectProject 聊天 reducer:把运行态事件与历史切片归并成同一份聊天条目。
*
* 事实源只有一个——项目对话历史;运行态事件只负责"当前回合"。顺序 = 历史文件顺序 +
* 运行态独有条目。这里不做可见性判断(那是投影的事),也不认任何回合身份:DirectProject
* 同一时刻只有一个回合在跑,`turn.started` / `turn.completed` 只切换"是否还在跑"这一个布尔。
*/
import type { GameCreatorDirectToolCall } from '../../app/types';
import type {
DirectThreadConsumeResult,
DirectThreadEvent,
DirectThreadHistorySlice,
DirectThreadItem,
DirectThreadSubscriptionBootstrap,
} from './directThreadEvents';
import { projectDirectThreadItem } from './directThreadItemProjection';
export type DirectChatEntryKind = 'message' | 'reasoning' | 'tool';
/** 聊天卡片里的工具形状:持久化卡片去掉回合身份(Rust 侧已经不下发 turn id)。 */
export type DirectChatToolCard = Omit<GameCreatorDirectToolCall, 'turnId'>;
/** 聊天视图里的一条条目;运行态事件与历史切片共用的唯一形状。 */
export type DirectChatEntry = {
itemId: string;
kind: DirectChatEntryKind;
role?: 'user' | 'assistant' | null;
text?: string | null;
toolCall?: DirectChatToolCard | null;
at?: number;
};
export type DirectThreadChatState = {
/** 最新回合是否还在跑;只由生命周期事件的先后决定。 */
turnRunning: boolean;
/** 历史切片条目,保持文件顺序。 */
history: DirectChatEntry[];
/** 当前回合的运行态条目,保持到达顺序;回合结束即并入历史并清空。 */
live: DirectChatEntry[];
};
export function emptyDirectThreadChatState(): DirectThreadChatState {
return {
turnRunning: false,
history: [],
live: [],
};
}
function longerText(
left: string | null | undefined,
right: string | null | undefined,
): string | null {
const a = typeof left === 'string' ? left : '';
const b = typeof right === 'string' ? right : '';
// 正文只增不减:增量往同一段落追加,完成快照可能比累计更长(漏过几条 delta)。
return b.length > a.length ? b : a;
}
function mergeToolStatus(
left: DirectChatToolCard['status'] | null | undefined,
right: DirectChatToolCard['status'] | null | undefined,
): DirectChatToolCard['status'] {
// 只有终态才算数:先到的 `running` 允许被后到的完成 / 失败覆盖,反过来不行。
if (left === 'running' || !left) return right ?? left ?? 'running';
return left;
}
function mergeToolCard(
left: DirectChatToolCard | null,
right: DirectChatToolCard | null,
): DirectChatToolCard | null {
if (!left) return right;
if (!right) return left;
return {
...left,
kind: left.kind && left.kind !== 'other' ? left.kind : right.kind,
title: left.title?.trim() ? left.title : right.title,
summary: left.summary?.trim() ? left.summary : right.summary,
status: mergeToolStatus(left.status, right.status),
detail: {
command: left.detail.command ?? right.detail.command,
output: left.detail.output ?? right.detail.output,
changes: left.detail.changes?.length
? left.detail.changes
: right.detail.changes,
},
startedAt: left.startedAt > 0 ? left.startedAt : right.startedAt,
updatedAt: Math.max(left.updatedAt, right.updatedAt),
};
}
/**
* 先到的快照赢,后到的只补空字段。
*
* 三个例外只有"后到信息一定更全"时才成立:正文取更长的一份、工具状态允许从 `running`
* 升级到终态、`updatedAt` 取较新的时间。其余字段一律先到先用,后到的空值不得抹掉它。
*/
export function mergeDirectChatEntry(
existing: DirectChatEntry,
incoming: DirectChatEntry,
): DirectChatEntry {
return {
itemId: existing.itemId || incoming.itemId,
kind:
existing.kind === 'tool' || incoming.kind === 'tool'
? 'tool'
: existing.kind,
role: existing.role ?? incoming.role ?? null,
text: longerText(existing.text, incoming.text),
toolCall: mergeToolCard(
existing.toolCall ?? null,
incoming.toolCall ?? null,
),
at: existing.at || incoming.at,
};
}
function upsertLiveEntry(
state: DirectThreadChatState,
entry: DirectChatEntry,
): DirectThreadChatState {
const index = state.live.findIndex(
(existing) => existing.itemId === entry.itemId,
);
if (index < 0) {
return { ...state, live: [...state.live, entry] };
}
const existing = state.live[index];
if (!existing) {
return { ...state, live: [...state.live, entry] };
}
const live = [...state.live];
live[index] = mergeDirectChatEntry(existing, entry);
return { ...state, live };
}
function appendLiveText(
state: DirectThreadChatState,
event: Extract<DirectThreadEvent, { type: 'item.delta' }>,
): DirectThreadChatState {
const itemId = event.itemId.trim();
if (!itemId || !event.delta) return state;
const reasoning = event.kind === 'reasoning';
const existing = state.live.find((entry) => entry.itemId === itemId);
return upsertLiveEntry(state, {
itemId,
kind: reasoning ? 'reasoning' : 'message',
role: reasoning ? null : 'assistant',
text: `${existing?.text ?? ''}${event.delta}`,
});
}
export function reduceDirectThreadEvent(
state: DirectThreadChatState,
event: DirectThreadEvent,
): DirectThreadChatState {
switch (event.type) {
case 'turn.started':
return { ...state, turnRunning: true };
case 'turn.completed':
// 回合结束:条目已经落盘,运行态并入历史后清空,避免同一条目渲染两次。
return {
...state,
turnRunning: false,
history: mergeHistoryEntries(state.history, state.live),
live: [],
};
case 'item.delta':
return appendLiveText(state, event);
case 'item.started':
case 'item.completed': {
const entry = projectDirectThreadItem(event.item);
return entry ? upsertLiveEntry(state, entry) : state;
}
case 'request':
// 审批 / 提问只影响面板交互,不并入聊天条目。
return state;
default:
return state;
}
}
export function reduceDirectThreadEvents(
state: DirectThreadChatState,
events: readonly DirectThreadEvent[],
): DirectThreadChatState {
return events.reduce(reduceDirectThreadEvent, state);
}
/**
* bootstrap 是运行态的唯一权威:游标已在队尾,返回的事件就是此刻要处理的事件。
*
* 订阅身份与首屏历史锚点(`subscriptionId` / `lastCompletedItemId`)是订阅循环自己的局部
* 事实,不进聊天状态:这里只把 bootstrap 事件 reduce 进现有状态。
*/
export function resolveDirectThreadBootstrap(
state: DirectThreadChatState,
bootstrap: DirectThreadSubscriptionBootstrap,
): DirectThreadChatState {
return reduceDirectThreadEvents(state, bootstrap.events);
}
/** 事件顺序 = 游标顺序;调用方只需要把 `consume` 的结果喂进来。 */
export function applyDirectThreadConsumeResult(
state: DirectThreadChatState,
result: DirectThreadConsumeResult,
): DirectThreadChatState {
return reduceDirectThreadEvents(state, result.events);
}
/** 同一身份的条目合并,先到者在前:历史在前、运行态在后,运行态只补空。 */
export function mergeHistoryEntries(
leading: readonly DirectChatEntry[],
trailing: readonly DirectChatEntry[],
): DirectChatEntry[] {
const byId = new Map<string, number>();
const entries: DirectChatEntry[] = [];
for (const entry of [...leading, ...trailing]) {
const index = byId.get(entry.itemId);
if (index === undefined) {
byId.set(entry.itemId, entries.length);
entries.push(entry);
continue;
}
const existing = entries[index];
if (existing) entries[index] = mergeDirectChatEntry(existing, entry);
}
return entries;
}
/** 历史切片条目 → 聊天条目:可见性判定的唯一入口,分页判据也读这一份。 */
export function projectDirectHistoryItems(
items: readonly DirectThreadItem[],
): DirectChatEntry[] {
return items
.map((item) => projectDirectThreadItem(item))
.filter((entry): entry is DirectChatEntry => Boolean(entry));
}
/**
* 历史切片并入:切片是脱敏原始条目,投影规则与运行态完全同一份。
*
* 同一调用的调用与输出在这里按身份合并成一张卡片,而不是在 Rust 侧合并。
*/
export function mergeDirectHistoryItems(
state: DirectThreadChatState,
items: readonly DirectThreadItem[],
): DirectThreadChatState {
return {
...state,
history: mergeHistoryEntries(
projectDirectHistoryItems(items),
state.history,
),
};
}
export function mergeDirectThreadHistorySlice(
state: DirectThreadChatState,
slice: DirectThreadHistorySlice,
): DirectThreadChatState {
return mergeDirectHistoryItems(state, slice.items);
}
/** 聊天投影输入:历史顺序 + 运行态覆盖;运行态独有条目排在最后。 */
export function selectDirectChatEntries(
state: DirectThreadChatState,
): DirectChatEntry[] {
return mergeHistoryEntries(state.history, state.live);
}
@@ -1,121 +1,17 @@
import type { /**
ChatMessage, * DirectProject 运行态事件的线上类型。
LocalConversationMessageRecord, *
} from '../../app/types'; * 类型由 Rust 侧 ts-rs 导出(改完 Rust 模型后跑 `cargo test export_bindings`),这里只做
* 入口转发:前端不再自己抄一份形状,字段增删必须改 Rust。
*/
export type DirectThreadRawEvent = { export type {
seq: number; DirectThreadConsumeResult,
type: string; DirectThreadDeltaKind,
turnId: string; DirectThreadEvent,
itemId?: string; DirectThreadFileChange,
payload: Record<string, unknown>; DirectThreadHistorySlice,
}; DirectThreadItem,
DirectThreadRequestKind,
export type DirectThreadSubscriptionBootstrap = { DirectThreadSubscriptionBootstrap,
subscriptionId: string; } from './generated';
lastCompletedItemId: string | null;
events: DirectThreadRawEvent[];
};
export type DirectThreadConsumeResult = {
events: DirectThreadRawEvent[];
};
export type DirectThreadHistorySlice = {
items: unknown[];
hasMore: boolean;
itemTimestamps?: Record<string, number>;
oldestItemId?: string | null;
};
/** 游标取原始响应,而非过滤后的聊天消息;拒绝不能前进的页,避免静默反复回读。 */
export function directThreadHistoryPage(
slice: DirectThreadHistorySlice,
previousCursor: string | null = null,
) {
const first = slice.items[0];
const firstId =
first && typeof first === 'object' && 'id' in first
? (first as { id?: unknown }).id
: null;
const cursor =
slice.oldestItemId ??
(typeof firstId === 'string' && firstId ? firstId : null);
if (slice.hasMore && (!cursor || cursor === previousCursor)) {
throw new Error('对话历史分页游标未前进,请重新读取项目历史');
}
return {
messages: directThreadHistoryItemsToMessages(
slice.items,
slice.itemTimestamps,
),
hasMore: slice.hasMore,
cursor,
};
}
/** 保留当前实时/已显示版本;原始身份相同的回读消息不能插入第二次。 */
export function prependDirectHistoryMessages(
current: readonly ChatMessage[],
older: readonly ChatMessage[],
): ChatMessage[] {
const ids = new Set(
current.flatMap((message) =>
message.messageId ? [message.messageId] : [],
),
);
const additions = older.filter((message) => {
if (!message.messageId) return true;
if (ids.has(message.messageId)) return false;
ids.add(message.messageId);
return true;
});
return [...additions, ...current];
}
export function directThreadHistoryItemsToMessages(
items: unknown[],
itemTimestamps: Readonly<Record<string, number>> = {},
): LocalConversationMessageRecord[] {
return items.flatMap((raw) => {
if (!raw || typeof raw !== 'object') return [];
const item = raw as Record<string, unknown>;
const role = item.role;
if (role !== 'user' && role !== 'assistant') return [];
const messageRole = role as 'user' | 'assistant';
const content = Array.isArray(item.content)
? item.content
.map((part) =>
part && typeof part === 'object' && 'text' in part
? (part as { text?: unknown }).text
: null,
)
.filter((text): text is string => typeof text === 'string')
.join('')
: '';
if (!content) return [];
const messageId = typeof item.id === 'string' ? item.id : undefined;
return [
{
schemaVersion: 'agc-direct-project-context.v1',
role: messageRole,
content,
agentId: null,
messageId,
updatedAt: messageId ? (itemTimestamps[messageId] ?? 0) : 0,
},
];
});
}
/** 只有这些状态表示仍持有活动回合;Provider 回放的终态不是活动快照。 */
export function isDirectTurnInProgress(
status: string | null | undefined,
): status is 'accepted' | 'running' | 'streaming' | 'finalizing' {
return (
status === 'accepted' ||
status === 'running' ||
status === 'streaming' ||
status === 'finalizing'
);
}
@@ -0,0 +1,269 @@
/**
* DirectProject「原始条目 → 聊天条目」投影。
*
* 输入是 Rust 侧 ts-rs 导出的 `DirectThreadItem`(脱敏后的 Codex 原始条目),工具卡片的
* `kind`、标题、折叠摘要、状态判定和可见性全部在这里完成。运行态事件与历史切片走同一个
* 函数,因此实时与回读不可能出现两套口径。
*/
import type {
GameCreatorDirectToolCallChange,
GameCreatorDirectToolCallDetail,
GameCreatorDirectToolCallKind,
GameCreatorDirectToolCallStatus,
} from '../../app/types';
import type { DirectChatEntry, DirectChatToolCard } from './directThreadChat';
import type { DirectThreadItem } from './directThreadEvents';
/** 折叠态摘要上限,与卡片契约一致。 */
const TOOL_SUMMARY_MAX_CHARS = 120;
const FAILED_ITEM_STATUS = new Set([
'failed',
'declined',
'cancelled',
'canceled',
'aborted',
]);
function firstLine(value: string): string {
const [line = ''] = value.split('\n');
const trimmed = line.trim();
return trimmed.length > TOOL_SUMMARY_MAX_CHARS
? `${trimmed.slice(0, TOOL_SUMMARY_MAX_CHARS)}`
: trimmed;
}
function toolKindFromFunctionName(name: string): GameCreatorDirectToolCallKind {
switch (name) {
case 'exec_command':
case 'shell':
case 'exec':
return 'command';
case 'apply_patch':
case 'write_file':
case 'edit_file':
case 'create_file':
return 'file_change';
case 'web_search':
case 'web_search_preview':
return 'web_search';
default:
return 'mcp_tool';
}
}
function toolStatus(
status: string | null,
exitCode: number | null,
): GameCreatorDirectToolCallStatus {
if (status === 'completed') return 'completed';
if (status && FAILED_ITEM_STATUS.has(status)) return 'failed';
// Codex 的退出码约定:非 0 即失败;缺席时按「已完成」处理。
if (typeof exitCode === 'number') {
return exitCode === 0 ? 'completed' : 'failed';
}
return status ? 'running' : 'completed';
}
function toolTitle(
kind: GameCreatorDirectToolCallKind,
changes: readonly GameCreatorDirectToolCallChange[],
): string {
switch (kind) {
case 'command':
return '执行命令';
case 'file_change': {
const paths = new Set(changes.map((change) => change.path));
return paths.size > 0 ? `编辑 ${paths.size} 个文件` : '编辑文件';
}
case 'web_search':
return '联网检索';
case 'context_compaction':
return '整理上下文';
default:
return '调用工具';
}
}
function fileChanges(
item: Extract<DirectThreadItem, { itemType: 'fileChange' }>,
): GameCreatorDirectToolCallChange[] {
return item.changes
.filter((change) => change.path.trim().length > 0)
.map((change) => ({
path: change.path,
kind: change.kind || 'update',
}));
}
type ToolCardInput = {
itemId: string;
at: number;
kind: GameCreatorDirectToolCallKind;
/** 输出条目只带输出:标题与摘要留空,交给先到的调用快照。 */
outputOnly?: boolean;
tool?: string;
command?: string;
output?: string;
status: GameCreatorDirectToolCallStatus;
changes?: GameCreatorDirectToolCallChange[];
};
function buildToolCard(input: ToolCardInput): DirectChatToolCard | null {
const changes = input.changes ?? [];
const detail: GameCreatorDirectToolCallDetail = {};
if (input.command && !input.outputOnly) detail.command = input.command;
if (input.output) detail.output = input.output;
if (changes.length > 0) detail.changes = changes;
// 没有命令 / 输出 / 文件明细的条目不渲染成卡片:一张空卡片对用户没有信息量。
if (!detail.command && !detail.output && !detail.changes?.length) return null;
const summarySource =
(input.kind === 'mcp_tool' ? (input.tool ?? '') : '') ||
detail.command ||
changes[0]?.path ||
input.tool ||
'';
return {
schemaVersion: 'agc-tool-call.v1',
id: input.itemId,
kind: input.kind,
title: input.outputOnly ? '' : toolTitle(input.kind, changes),
summary: input.outputOnly ? '' : firstLine(summarySource),
status: input.status,
detail,
startedAt: input.at,
updatedAt: input.at,
};
}
function toolCardFromItem(item: DirectThreadItem): DirectChatToolCard | null {
switch (item.itemType) {
case 'function_call':
return buildToolCard({
itemId: item.itemId,
at: item.at,
kind: toolKindFromFunctionName(item.name),
tool: item.name,
command: item.arguments,
status: 'running',
});
case 'function_call_output':
return buildToolCard({
itemId: item.itemId,
at: item.at,
kind: 'other',
outputOnly: true,
output: item.output,
status: 'completed',
});
case 'commandExecution':
return buildToolCard({
itemId: item.itemId,
at: item.at,
kind: 'command',
command: item.command,
output: item.output ?? '',
status: toolStatus(item.status, item.exitCode),
});
case 'fileChange':
return buildToolCard({
itemId: item.itemId,
at: item.at,
kind: 'file_change',
changes: fileChanges(item),
status: 'completed',
});
case 'mcpToolCall':
return buildToolCard({
itemId: item.itemId,
at: item.at,
kind: 'mcp_tool',
tool: item.tool,
command: item.arguments,
output: item.output ?? '',
status: toolStatus(item.status, null),
});
case 'webSearch':
return buildToolCard({
itemId: item.itemId,
at: item.at,
kind: 'web_search',
command: item.query ?? '',
output: item.output ?? '',
status: 'completed',
});
case 'contextCompaction':
return buildToolCard({
itemId: item.itemId,
at: item.at,
kind: 'context_compaction',
command: '整理上下文',
status: 'completed',
});
default:
return null;
}
}
/**
* 原始条目投影成聊天条目;不属于聊天内容的条目返回 `null`。
*
* 可见性判定只在这里:系统 / 开发者 message、无正文的空条目、未识别的 item 类型都不进
* 聊天视图。`other` 是 Rust 原样透传的未知类型,要不要显示属于前端可见性决策,当前不显示。
*/
export function projectDirectThreadItem(
item: DirectThreadItem | null | undefined,
): DirectChatEntry | null {
if (!item) return null;
// 身份是条目唯一的主键:拿不到身份的载荷既不能渲染也不能合并,只丢弃这一条。
const itemId = typeof item.itemId === 'string' ? item.itemId.trim() : '';
if (!itemId) return null;
switch (item.itemType) {
case 'message': {
const role =
item.role === 'user'
? 'user'
: item.role === 'assistant'
? 'assistant'
: null;
if (!role || !item.text.trim()) return null;
return {
itemId,
kind: 'message',
role,
text: item.text,
toolCall: null,
at: item.at,
};
}
case 'reasoning': {
if (!item.text.trim()) return null;
return {
itemId,
kind: 'reasoning',
role: null,
text: item.text,
toolCall: null,
at: item.at,
};
}
case 'other':
// TODO(direct-thread): 未识别类型目前不显示;要让它们出现只改这里,别回 Rust 加白名单。
return null;
default: {
const toolCall = toolCardFromItem(item);
if (!toolCall) return null;
return {
itemId,
kind: 'tool',
role: null,
text: null,
toolCall,
at: item.at,
};
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,4 @@
// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { DirectCodexUserRuntimeRegionPart } from './DirectCodexUserRuntimeRegionPart'; import type { DirectCodexUserRuntimeRegionPart } from './DirectCodexUserRuntimeRegionPart';
export type DirectCodexUserContentPart = export type DirectCodexUserContentPart =
@@ -1,5 +1,9 @@
// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { DirectCodexUserMessageItem } from './DirectCodexUserMessageItem'; import type { DirectCodexUserMessageItem } from './DirectCodexUserMessageItem';
export type DirectCodexUserItem = DirectCodexUserMessageItem; /**
* DirectProject 本轮 user input 的唯一结构化入口。
*/
export type DirectCodexUserItem = {
type: 'message';
} & DirectCodexUserMessageItem;

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