magic-agent 2.4.3 → 2.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (207) hide show
  1. package/CLIENT-CONTRACT.md +467 -41
  2. package/README.md +15 -14
  3. package/client-ui/pc/app.css +74 -0
  4. package/client-ui/pc/js/04-task-list.js +5 -1
  5. package/client-ui/pc/js/07-chat-stream-render.js +3 -3
  6. package/client-ui/pc/js/90-glue.js +40 -2
  7. package/client-ui/pc/js/92-composer.js +19 -4
  8. package/client-ui/pc/js/93-rail.js +16 -5
  9. package/client-ui/shared/conv/02-media-kinds.js +2 -2
  10. package/client-ui/shared/conv/03-chat-message.js +247 -23
  11. package/client-ui/shared/conv/06-ask-answer.js +75 -1
  12. package/client-ui/shared/conv/09-stream-apply.js +72 -7
  13. package/client-ui/shared/conv/19-attachments.js +157 -6
  14. package/client-ui/shared/conv/27-conv-ledger.js +5 -2
  15. package/client-ui/shared/conv/29-msg-variants.js +217 -0
  16. package/client-ui/shared/conv/conv.css +329 -174
  17. package/client-ui/shared/conv/dom-map.md +1 -0
  18. package/client-ui/shared/conversation.css +13 -2
  19. package/client-ui/shared/conversation.js +373 -50
  20. package/client-ui/shared/transport-http.js +14 -0
  21. package/client-ui-mobile/apps.css +16 -0
  22. package/client-ui-mobile/apps.js +406 -0
  23. package/client-ui-mobile/index.html +5554 -639
  24. package/client-ui-mobile/reqs.css +110 -1
  25. package/client-ui-mobile/reqs.js +533 -74
  26. package/client-ui-mobile/reqshot.js +505 -0
  27. package/client-ui-mobile/scripts.css +67 -0
  28. package/client-ui-mobile/scripts.js +599 -0
  29. package/client-ui-mobile/vendor/cose-base.js +3214 -0
  30. package/client-ui-mobile/vendor/cytoscape-fcose.js +1549 -0
  31. package/client-ui-mobile/vendor/cytoscape.min.js +32 -0
  32. package/client-ui-mobile/vendor/layout-base.js +5230 -0
  33. package/cmd/magic-client/browsectl.go +199 -0
  34. package/cmd/magic-client/clientctl.go +69 -10
  35. package/cmd/magic-client/main.go +210 -32
  36. package/internal/agent/ask.go +45 -0
  37. package/internal/agent/ask_test.go +39 -0
  38. package/internal/agent/attachments_test.go +6 -2
  39. package/internal/agent/capability.go +7 -1
  40. package/internal/agent/claude.go +20 -15
  41. package/internal/agent/codebuddy.go +125 -34
  42. package/internal/agent/codebuddy_account_test.go +54 -0
  43. package/internal/agent/codebuddy_stream_test.go +103 -0
  44. package/internal/agent/credits.go +713 -0
  45. package/internal/agent/credits_minimax_test.go +97 -0
  46. package/internal/agent/credits_test.go +211 -0
  47. package/internal/agent/custom_models.go +155 -0
  48. package/internal/agent/custom_models_test.go +125 -0
  49. package/internal/agent/dsh.go +123 -49
  50. package/internal/agent/dsh_caps_cache_test.go +184 -0
  51. package/internal/agent/dsh_resume.go +157 -7
  52. package/internal/agent/dsh_test.go +108 -6
  53. package/internal/agent/engine.go +37 -1
  54. package/internal/agent/engine_base.go +21 -17
  55. package/internal/agent/engine_base_test.go +2 -2
  56. package/internal/agent/engine_test.go +47 -86
  57. package/internal/agent/env.go +173 -1
  58. package/internal/agent/env_test.go +114 -0
  59. package/internal/agent/fallback.go +313 -0
  60. package/internal/agent/fallback_test.go +340 -0
  61. package/internal/agent/launch.go +43 -1
  62. package/internal/agent/limitnotice.go +207 -1
  63. package/internal/agent/limitnotice_test.go +140 -0
  64. package/internal/agent/llmhttp.go +46 -4
  65. package/internal/agent/llmhttp_test.go +157 -0
  66. package/internal/agent/models.go +53 -1
  67. package/internal/agent/models_test.go +20 -5
  68. package/internal/agent/pidalive_windows_test.go +40 -0
  69. package/internal/agent/runcmd.go +26 -0
  70. package/internal/agent/runcmd_windows.go +36 -5
  71. package/internal/agent/stream.go +166 -6
  72. package/internal/agent/stream_test.go +25 -38
  73. package/internal/agent/stream_timeline_test.go +2 -37
  74. package/internal/agent/streamjson.go +119 -7
  75. package/internal/agent/tools_suffix_test.go +6 -3
  76. package/internal/agent/trae.go +50 -243
  77. package/internal/agent/trae_exec.go +433 -0
  78. package/internal/agent/trae_exec_test.go +181 -0
  79. package/internal/agent/wraplaunch_test.go +113 -0
  80. package/internal/cli/ask.go +168 -37
  81. package/internal/cli/ask_test.go +1 -1
  82. package/internal/cli/checkin.go +77 -0
  83. package/internal/cli/clientcmd.go +3 -2
  84. package/internal/cli/control.go +46 -5
  85. package/internal/cli/control_test.go +65 -0
  86. package/internal/cli/engines_test.go +103 -0
  87. package/internal/cli/events.go +15 -0
  88. package/internal/cli/events_test.go +50 -0
  89. package/internal/cli/judge.go +104 -0
  90. package/internal/cli/quotas.go +125 -0
  91. package/internal/cli/requirements.go +51 -0
  92. package/internal/cli/root.go +157 -11
  93. package/internal/cli/session.go +75 -3
  94. package/internal/cli/session_test.go +82 -3
  95. package/internal/cli/sessionlog.go +6 -0
  96. package/internal/cli/sessionlog_test.go +22 -2
  97. package/internal/cli/suggest.go +7 -5
  98. package/internal/cli/team.go +37 -9
  99. package/internal/cli/team_test.go +39 -0
  100. package/internal/client/ab.go +372 -0
  101. package/internal/client/ab_test.go +287 -0
  102. package/internal/client/apk.go +8 -0
  103. package/internal/client/apk_test.go +59 -0
  104. package/internal/client/apps.go +1078 -0
  105. package/internal/client/apps_test.go +516 -0
  106. package/internal/client/browse.go +84 -36
  107. package/internal/client/browse_test.go +107 -9
  108. package/internal/client/browseinput.go +481 -0
  109. package/internal/client/browseinput_test.go +216 -0
  110. package/internal/client/corecmd.go +23 -0
  111. package/internal/client/corecmd_other.go +8 -0
  112. package/internal/client/corecmd_windows.go +24 -0
  113. package/internal/client/coreexe.go +196 -0
  114. package/internal/client/coreexe_test.go +161 -0
  115. package/internal/client/coreupgrade.go +866 -0
  116. package/internal/client/coreupgrade_test.go +561 -0
  117. package/internal/client/daemon.go +297 -33
  118. package/internal/client/daemon_test.go +33 -0
  119. package/internal/client/engines_checkin_test.go +57 -0
  120. package/internal/client/fallback_flags_test.go +64 -0
  121. package/internal/client/feishu.go +1259 -0
  122. package/internal/client/feishu_test.go +649 -0
  123. package/internal/client/health_identity_test.go +84 -0
  124. package/internal/client/hostenv.go +68 -0
  125. package/internal/client/hostenv_test.go +37 -0
  126. package/internal/client/jobs.go +518 -24
  127. package/internal/client/jobs_auto.go +571 -0
  128. package/internal/client/jobs_auto_test.go +431 -0
  129. package/internal/client/jobs_test.go +299 -2
  130. package/internal/client/knowledge.go +854 -0
  131. package/internal/client/knowledge_test.go +510 -0
  132. package/internal/client/lan.go +3 -3
  133. package/internal/client/plugins.go +5 -1
  134. package/internal/client/prefs.go +67 -0
  135. package/internal/client/procstart_linux.go +59 -0
  136. package/internal/client/publicurl.go +82 -0
  137. package/internal/client/quotas_merge_test.go +107 -0
  138. package/internal/client/requirements.go +207 -2
  139. package/internal/client/requirements_shots_test.go +200 -0
  140. package/internal/client/requirements_test.go +103 -0
  141. package/internal/client/restart.go +288 -0
  142. package/internal/client/restart_handoff_test.go +230 -0
  143. package/internal/client/restart_test.go +290 -0
  144. package/internal/client/scripts.go +1047 -0
  145. package/internal/client/scripts_decode_implausible.go +38 -0
  146. package/internal/client/scripts_decode_other.go +31 -0
  147. package/internal/client/scripts_decode_test.go +130 -0
  148. package/internal/client/scripts_decode_windows.go +182 -0
  149. package/internal/client/scripts_test.go +739 -0
  150. package/internal/client/service.go +717 -97
  151. package/internal/client/service_test.go +217 -1
  152. package/internal/client/sessions_cache.go +148 -0
  153. package/internal/client/sessions_cache_test.go +163 -0
  154. package/internal/client/sessions_fold.go +165 -0
  155. package/internal/client/sessions_fold_test.go +204 -0
  156. package/internal/client/singleinstance.go +470 -0
  157. package/internal/client/singleinstance_test.go +607 -0
  158. package/internal/client/suggest.go +105 -2
  159. package/internal/client/suggest_test.go +86 -0
  160. package/internal/client/team_flags.go +25 -11
  161. package/internal/client/team_flags_test.go +39 -0
  162. package/internal/client/testmain_test.go +88 -0
  163. package/internal/client/workspace_default_test.go +158 -0
  164. package/internal/client/workspacelockenv_test.go +54 -0
  165. package/internal/config/config.go +48 -0
  166. package/internal/judge/judge.go +315 -0
  167. package/internal/requirements/bootstrap.go +16 -0
  168. package/internal/requirements/consolidate.go +100 -9
  169. package/internal/requirements/doc.go +154 -3
  170. package/internal/requirements/graph.go +138 -0
  171. package/internal/requirements/memory.go +7 -0
  172. package/internal/requirements/modules.go +312 -0
  173. package/internal/requirements/modules_test.go +373 -0
  174. package/internal/requirements/relations.go +155 -0
  175. package/internal/requirements/relations_test.go +231 -0
  176. package/internal/requirements/requirements.go +10 -0
  177. package/internal/requirements/shots.go +418 -0
  178. package/internal/requirements/shots_test.go +387 -0
  179. package/internal/session/multiclient_test.go +368 -0
  180. package/internal/session/session.go +175 -11
  181. package/internal/session/session_test.go +7 -0
  182. package/internal/session/status.go +21 -0
  183. package/internal/session/status_test.go +28 -0
  184. package/internal/session/title.go +16 -0
  185. package/internal/session/title_test.go +26 -0
  186. package/internal/session/workspace_lock.go +265 -28
  187. package/internal/suggest/suggest.go +23 -1
  188. package/internal/team/event.go +3 -1
  189. package/internal/team/execute.go +21 -6
  190. package/internal/team/fallback_route_test.go +80 -0
  191. package/internal/team/planner.go +15 -3
  192. package/internal/team/route.go +89 -17
  193. package/internal/team/team.go +18 -4
  194. package/npm/apk/magic-agent-mobile-release.apk +0 -0
  195. package/npm/apk/version.json +6 -3
  196. package/npm/dist/darwin-arm64/magic-agent +0 -0
  197. package/npm/dist/darwin-arm64/magic-client +0 -0
  198. package/npm/dist/darwin-x64/magic-agent +0 -0
  199. package/npm/dist/darwin-x64/magic-client +0 -0
  200. package/npm/dist/linux-arm64/magic-agent +0 -0
  201. package/npm/dist/linux-arm64/magic-client +0 -0
  202. package/npm/dist/linux-x64/magic-agent +0 -0
  203. package/npm/dist/linux-x64/magic-client +0 -0
  204. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  205. package/npm/dist/win32-x64/magic-client.exe +0 -0
  206. package/npm/install.js +11 -0
  207. package/package.json +14 -3
@@ -22,6 +22,7 @@ package cli
22
22
 
23
23
  import (
24
24
  "fmt"
25
+ "io"
25
26
  "strings"
26
27
 
27
28
  "github.com/spf13/cobra"
@@ -69,6 +70,23 @@ func runRequirements(cmd *cobra.Command, opts *askOptions) error {
69
70
  return printJSON(cmd.OutOrStdout(), out)
70
71
  }
71
72
 
73
+ // runRequirementsGraph 会话↔需求关系图谱:`--requirements-graph all|<ws_id|目录>`。
74
+ //
75
+ // 只读(与 --requirements 同族,客户端每刷一次都调,绝不能触发 LLM)。
76
+ // 聚合 relations.jsonl + 文档目录 → {ok,root,workspaces:[{id,name,dir,nodes,edges}]}。
77
+ func runRequirementsGraph(cmd *cobra.Command, opts *askOptions) error {
78
+ where := strings.TrimSpace(opts.reqGraph)
79
+ if where == "*" {
80
+ where = "all"
81
+ }
82
+ b, err := requirements.BuildGraph(where)
83
+ if err != nil {
84
+ return fmt.Errorf("--requirements-graph %s: %w", where, err)
85
+ }
86
+ _, err = cmd.OutOrStdout().Write(append(b, '\n'))
87
+ return err
88
+ }
89
+
72
90
  // runConsolidateRequirements 立即归纳一轮:`--consolidate-requirements <ws_id|目录|.>`。
73
91
  //
74
92
  // `.` = 当前目录(与 `-w` 的默认语义一致:用户站在哪个项目里,整理的就是哪个项目)。
@@ -103,6 +121,35 @@ func runConsolidateRequirements(cmd *cobra.Command, opts *askOptions) error {
103
121
  return printJSON(cmd.OutOrStdout(), res)
104
122
  }
105
123
 
124
+ // runRequirementShot 给一个功能点追加一张「页面完成情况」截图:
125
+ // `--requirement-shot [<ws|dir>]` + `--requirement-name` / `--requirement-caption`。
126
+ //
127
+ // PNG 内容走 **stdin**(不走命令行参数):图片动辄几百 KB,放进 argv 会撞 Windows
128
+ // 命令行长度上限,也会把二进制搅进进程列表。stdin 的好处是调用方可以管线接。
129
+ func runRequirementShot(cmd *cobra.Command, opts *askOptions) error {
130
+ where := strings.TrimSpace(opts.reqShot)
131
+ if where == "." {
132
+ where = ""
133
+ }
134
+ name := strings.TrimSpace(opts.reqShotName)
135
+ if name == "" {
136
+ return &usageError{fmt.Errorf("--requirement-shot 需要 --requirement-name <功能点名>")}
137
+ }
138
+ png, err := io.ReadAll(io.LimitReader(cmd.InOrStdin(), requirements.MaxShotBytes+1))
139
+ if err != nil {
140
+ return fmt.Errorf("--requirement-shot: 读取 stdin 失败: %w", err)
141
+ }
142
+ res, err := requirements.AttachShot(where, opts.reqModule, name, opts.reqShotCap, png)
143
+ if err != nil && !requirements.IsBusy(err) {
144
+ if res.Error == "" {
145
+ res.Error = err.Error()
146
+ }
147
+ _ = printJSON(cmd.OutOrStdout(), res)
148
+ return fmt.Errorf("--requirement-shot %s: %s", where, err)
149
+ }
150
+ return printJSON(cmd.OutOrStdout(), res)
151
+ }
152
+
106
153
  // bindRequirementFlags 注册需求聚合的 flags。
107
154
  //
108
155
  // --requirements / --consolidate-requirements 的值都可以留空(空串是合法输入:
@@ -110,6 +157,7 @@ func runConsolidateRequirements(cmd *cobra.Command, opts *askOptions) error {
110
157
  func bindRequirementFlags(cmd *cobra.Command, opts *askOptions) {
111
158
  f := cmd.PersistentFlags()
112
159
  f.StringVar(&opts.requirements, "requirements", "", "列出需求清单(工作区 → 模块 → 功能点,JSON:{ok,root,workspaces})。值留空 = 列全部;给 ws id 或目录 = 只列那一个。只读,不调模型")
160
+ f.StringVar(&opts.reqGraph, "requirements-graph", "", "会话↔需求关系图谱(JSON:{ok,root,workspaces:[{nodes,edges}]},nodes 带 type=session|feature)。值留空 = 全部;只读,不调模型")
113
161
  f.StringVar(&opts.consolidateReqs, "consolidate-requirements", "", "立即把会话记忆归纳成需求文档(会调模型)。值留空 = 当前目录;给 ws id 或目录 = 归纳那一个")
114
162
  f.BoolVar(&opts.reqForce, "requirement-force", false, "配合 --consolidate-requirements:没有未归纳的记忆也跑一轮(手动「立即整理」)")
115
163
  f.BoolVar(&opts.reqAll, "requirement-all", false, "配合 --consolidate-requirements:全量重放历史记忆(回填 / 滚动校正)。仍走增量合并,只增不减,不会推翻已有内容")
@@ -118,4 +166,7 @@ func bindRequirementFlags(cmd *cobra.Command, opts *askOptions) {
118
166
  f.StringVar(&opts.reqModule, "requirement-module", "", "配合 --consolidate-requirements:只整理这个模块(空 = 整个空间)。不推进空间游标,不触发首次目录初始化")
119
167
  f.DurationVar(&opts.reqTimeout, "requirement-timeout", requirements.DefaultTimeout, "配合 --consolidate-requirements:单次模型调用超时(默认归纳 4m / 首次目录初始化 9m)")
120
168
  f.BoolVar(&opts.reqAuto, "requirement-auto", false, "配合 --consolidate-requirements:自动归纳(不触发首次按目录初始化;插件轮末 / 每日兜底用)")
169
+ f.StringVar(&opts.reqShot, "requirement-shot", "", "给功能点追加一张「页面完成情况」截图(PNG 走 stdin)。值留空 = 当前目录;给 ws id 或目录 = 那个空间")
170
+ f.StringVar(&opts.reqShotName, "requirement-name", "", "配合 --requirement-shot:功能点名(= 文档名)")
171
+ f.StringVar(&opts.reqShotCap, "requirement-caption", "", "配合 --requirement-shot:截图的标注说明")
121
172
  }
@@ -33,6 +33,7 @@ package cli
33
33
  // --temperature <t> 采样温度(仅 llm 引擎透传 -o temperature,其余忽略)
34
34
  // --engines 列出引擎、可用性与支持的模型(JSON 数组;--json 兼容保留)
35
35
  // --no-models 配合 --engines:跳过模型探测(只列引擎,快)
36
+ // --quotas 只查各引擎账号的额度与每日签到(不探测引擎/不启动 CLI,秒级)
36
37
  // --stop <id> 停止指定会话/运行(session_id 或 run_id),杀掉它的引擎进程组
37
38
  // --delete-session <id> 删除一条会话:登记记录 + 事件历史 + 标题覆盖一起删,**全机生效**
38
39
  // (各端读同一份登记表,一端删了处处都看不到)。幂等
@@ -185,7 +186,7 @@ llm 引擎(包装 simonw/LLM CLI,模型/密钥由它自管):
185
186
  每个可用引擎的模型清单都从它自己的权威入口现取现算,CLI 升级/新注册模型后自动跟随:
186
187
  claude ~/.claude/settings.json(顶层 model + env 里 ANTHROPIC_*MODEL[*_NAME])
187
188
  codebuddy codebuddy --help 里 --model 自带的 "Currently supported: (...)" 清单
188
- trae trae-cli models --json
189
+ trae traex models --json
189
190
  llm llm models
190
191
  codex codex debug models
191
192
  openclaw openclaw models list --json
@@ -202,7 +203,7 @@ llm 引擎(包装 simonw/LLM CLI,模型/密钥由它自管):
202
203
  codex npm install -g @openai/codex
203
204
  openclaw npm install -g openclaw@latest
204
205
  dsh npm i -g @deepseek-ai/dsh
205
- trae sh -c "$(curl -L https://trae.cn/trae-cli/install.sh)"
206
+ trae sh -c "$(curl -fsSL https://trae.cn/trae-cli/install_v2.sh)" # Windows: irm https://trae.cn/trae-cli/install_v2.ps1 | iex
206
207
  llm python3 -m venv ~/.llm-venv && ~/.llm-venv/bin/pip install llm
207
208
  命令一律取自各 CLI 的官方安装方式;**没有可执行安装路径的引擎不给该字段**
208
209
  (codebuddy / codebuddy-ai 是桌面端 GUI 应用,arkclaw 靠配置文件),
@@ -468,6 +469,9 @@ codebuddy-gateway 引擎(CodeBuddy Code HTTP 网关,走 webhook + SSE;凭
468
469
  case flagChanged(cmd, "repair-models"):
469
470
  // 同上:`--repair-models ""` 要走参数校验,而不是掉进「提问」分支报 empty prompt。
470
471
  return runRepairModels(cmd, opts)
472
+ case flagChanged(cmd, "checkin"):
473
+ // 同上:`--checkin ""` 要走参数校验。
474
+ return runCheckin(cmd, opts)
471
475
  case opts.listSessions:
472
476
  return runSessions(cmd, opts)
473
477
  case flagChanged(cmd, "session-log"):
@@ -482,6 +486,9 @@ codebuddy-gateway 引擎(CodeBuddy Code HTTP 网关,走 webhook + SSE;凭
482
486
  case flagChanged(cmd, "suggest-followups"):
483
487
  // 值可以是 "-"(stdin),所以用「传过没有」判,而不是「值非空」。
484
488
  return runSuggestFollowups(cmd, opts)
489
+ case flagChanged(cmd, "judge-conflict"):
490
+ // 同上:值可以是 "-"(stdin)。
491
+ return runJudgeConflict(cmd, opts)
485
492
  case flagChanged(cmd, "title-force"):
486
493
  // --title-force 只在 --summarize-title 下成立:单给它必然是调用方误解了
487
494
  // (想要「强制重新归纳」却没给 id)。明说,不静默忽略。
@@ -500,9 +507,15 @@ codebuddy-gateway 引擎(CodeBuddy Code HTTP 网关,走 webhook + SSE;凭
500
507
  case flagChanged(cmd, "requirements"):
501
508
  // 读清单:值留空 = 列全部工作区(`--requirements ""` 也是合法调用)。
502
509
  return runRequirements(cmd, opts)
510
+ case flagChanged(cmd, "requirements-graph"):
511
+ // 读图谱:值留空 = 全部工作区。只读,不调模型。
512
+ return runRequirementsGraph(cmd, opts)
503
513
  case flagChanged(cmd, "consolidate-requirements"):
504
514
  // 写:跑一轮归纳。值留空 = 当前目录。
505
515
  return runConsolidateRequirements(cmd, opts)
516
+ case flagChanged(cmd, "requirement-shot"):
517
+ // 写:给功能点追加一张完成情况截图(PNG 走 stdin)。
518
+ return runRequirementShot(cmd, opts)
506
519
  case flagChanged(cmd, "requirement-force"), flagChanged(cmd, "requirement-all"),
507
520
  flagChanged(cmd, "requirement-engine"), flagChanged(cmd, "requirement-model"),
508
521
  flagChanged(cmd, "requirement-timeout"), flagChanged(cmd, "requirement-auto"):
@@ -510,6 +523,10 @@ codebuddy-gateway 引擎(CodeBuddy Code HTTP 网关,走 webhook + SSE;凭
510
523
  // 静默忽略就等于「传了参数什么都没发生」。
511
524
  return &usageError{fmt.Errorf("--requirement-force / -all / -engine / -model / -timeout / -auto " +
512
525
  "只在 --consolidate-requirements 下成立:请配合它使用")}
526
+ case flagChanged(cmd, "requirement-name"), flagChanged(cmd, "requirement-caption"):
527
+ // 这两个是 --requirement-shot 的修饰项(截图落在哪个功能点、标注写什么)。
528
+ return &usageError{fmt.Errorf("--requirement-name / --requirement-caption " +
529
+ "只在 --requirement-shot 下成立:请配合它使用")}
513
530
  case flagChanged(cmd, "stop"):
514
531
  // 用「传过没有」而不是「值非空」判断:`--stop ""` 也要走参数校验
515
532
  //(exit 2 提示要 id),而不是掉进「提问」分支报 empty prompt。
@@ -523,6 +540,8 @@ codebuddy-gateway 引擎(CodeBuddy Code HTTP 网关,走 webhook + SSE;凭
523
540
  return runContract(cmd, opts)
524
541
  case opts.engines:
525
542
  return runEngines(cmd, opts)
543
+ case opts.quotas:
544
+ return runQuotas(cmd)
526
545
  case opts.team:
527
546
  // 团队模式必须排在 --stream 之前:它自己就是流式路径的一种运行模式
528
547
  //(同一套 ready / seq / result 收尾),但内部编排完全不同。
@@ -701,20 +720,28 @@ var (
701
720
  // key 一般用引擎名(codebuddy / codebuddy-ai / trae …);命中且未过期的缓存直接返回,
702
721
  // 不触碰 CLI / 文件。底层走 probeEngineModels / probeEngineCredits(可被测试替换)。
703
722
  // ListModels 出错或返回空清单时**不缓存**(视为瞬时态,下次重探)。
704
- func cachedModelList(ctx context.Context, key string, l agent.ModelLister) (models []string, credits map[string]string, note string) {
723
+ //
724
+ // noSource 区分两种"拿不到模型"的语义(见 agent.ErrNoModelSource):
725
+ // - true = 引擎**本来就不提供**模型清单(arkclaw 按 claw_id 绑模型、
726
+ // codebuddy-gateway 用网关进程的模型)—— 它不是故障,调用方不该因此隐藏引擎;
727
+ // - false = 有来源但这次探测失败(CLI 退出码非 0 / 空清单)—— 可以隐藏或置灰。
728
+ //
729
+ // 以前两者只靠 note 字符串表达,客户端只能看"note 是否为空"一刀切,
730
+ // 把 arkclaw / MagicAI / codebuddy-gateway 一起隐藏了(2026-10 报障)。
731
+ func cachedModelList(ctx context.Context, key string, l agent.ModelLister) (models []string, credits map[string]string, note string, noSource bool) {
705
732
  modelListCacheMu.Lock()
706
733
  if it, ok := modelListCache[key]; ok && time.Now().Before(it.expiresAt) {
707
734
  modelListCacheMu.Unlock()
708
- return it.models, it.credits, ""
735
+ return it.models, it.credits, "", false
709
736
  }
710
737
  modelListCacheMu.Unlock()
711
738
 
712
739
  models, err := probeEngineModels(ctx, l)
713
740
  if err != nil {
714
- return nil, nil, err.Error()
741
+ return nil, nil, err.Error(), errors.Is(err, agent.ErrNoModelSource)
715
742
  }
716
743
  if len(models) == 0 {
717
- return nil, nil, "no models reported"
744
+ return nil, nil, "no models reported", false
718
745
  }
719
746
  if cl, ok := l.(agent.ModelCreditLister); ok {
720
747
  credits = probeEngineCredits(ctx, cl)
@@ -726,7 +753,7 @@ func cachedModelList(ctx context.Context, key string, l agent.ModelLister) (mode
726
753
  expiresAt: time.Now().Add(modelListCacheTTL),
727
754
  }
728
755
  modelListCacheMu.Unlock()
729
- return models, credits, ""
756
+ return models, credits, "", false
730
757
  }
731
758
 
732
759
  // clearModelListCache 清空模型清单缓存(测试用,避免用例之间互相命中缓存)。
@@ -767,7 +794,7 @@ func flagChanged(cmd *cobra.Command, name string) bool {
767
794
  // 实测只有「受理帧 + 整段正文」,见 internal/agent/arkclaw_stream.go
768
795
  //
769
796
  // 模型**一律现取现算、不硬编码**:各引擎用自己 CLI/配置文件里的权威入口
770
- // (trae-cli models / llm models / codex debug models / openclaw models list /
797
+ // (traex models / llm models / codex debug models / openclaw models list /
771
798
  // codebuddy --help / claude settings.json)。探测要真的启动 CLI(只读子命令),
772
799
  // 故并发执行并套 agent.DefaultModelProbeTimeout;--no-models 可整段跳过(只列引擎,快)。
773
800
  // --json 为兼容旧调用保留(本函数不再区分,恒为 JSON)。
@@ -818,11 +845,53 @@ func emitEngineRows(cmd *cobra.Command, opts *askOptions, envelope bool) error {
818
845
  Version string `json:"version,omitempty"`
819
846
  Models []string `json:"models,omitempty"`
820
847
  ModelsNote string `json:"models_note,omitempty"`
848
+ /* ModelsSource 模型清单的**来源语义**(machine-readable,取代"靠 models_note
849
+ 猜"的做法;只增字段不升 contractVersion):
850
+ dynamic —— models 可用;
851
+ none —— 引擎本身不提供模型选择(模型绑在 claw_id / 网关进程上),
852
+ **不是故障**:调用方应保留这一行并显示「引擎默认」;
853
+ failed —— 有动态来源但本次探测失败(CLI 非 0 退出等),可隐藏或置灰。
854
+ 旧 core 没有该字段,调用方按 models_note 前缀兜底(见 shared/conversation.js)。 */
855
+ ModelsSource string `json:"models_source,omitempty"`
821
856
  // ModelCredits 各模型的积分倍率(machine-readable,model → 倍率数字字符串,
822
857
  // 如 "fast-model":"0.34",源于客户端 "x0.34 credits";与 models 同源同链探测,
823
858
  // 仅实现了 agent.ModelCreditLister 的引擎给出 —— 目前 codebuddy / codebuddy-ai)。
824
859
  // 客户端不给倍率的模型(custom-local)不出现在 map 里。
825
860
  ModelCredits map[string]string `json:"model_credits,omitempty"`
861
+ // ModelVendors 各模型的供应商展示名(model → 供应商,如 "Volcengine");
862
+ // 与 models 同形同链,键已剥 `custom-local:` 前缀。只用于**选择器的第二行**,
863
+ // 不参与 -m / 落库。仅实现 agent.ModelVendorLister 的引擎给出(目前 codebuddy 系),
864
+ // 其余引擎省略该字段(新增字段,不升 contractVersion)。
865
+ ModelVendors map[string]string `json:"model_vendors,omitempty"`
866
+ // CreditLeft 账号当前**剩余积分**(decimal 字符串,如 "5939.2";单位 credits)。
867
+ // 仅实现了 agent.CreditLister 的引擎给出(codebuddy / codebuddy-ai),
868
+ // 各自读自己票据(codebuddy-ai 读隔离目录),查不到就省略该字段。
869
+ // 新增(2026-10-24,只增字段不升 contractVersion)。
870
+ CreditLeft string `json:"credit_left,omitempty"`
871
+ // CreditSuffix CreditLeft / ModelQuota 的单位后缀(缺省「积分」)。llm 的
872
+ // MiniMax 套餐额度是百分比,给 "%" —— 客户端据此显示「5h 98% · 周 53%」。
873
+ // 新增(2026-10-25,只增字段不升 contractVersion)。
874
+ CreditSuffix string `json:"credit_suffix,omitempty"`
875
+ // ModelQuota 按**模型**给出的套餐额度快照(model id → {interval_left, weekly_left},
876
+ // 两个窗口的剩余百分比,单位见 credit_suffix)。仅实现了 agent.ModelQuotaLister
877
+ // 的引擎给出 —— 目前是 llm(MiniMax 套餐;只挂在走 MiniMax 端点的模型上)。
878
+ // 新增(2026-10-25,只增字段不升 contractVersion)。
879
+ ModelQuota map[string]agent.QuotaWindows `json:"model_quota,omitempty"`
880
+ // CheckinToday 今天是否已领每日积分(true / false);拿不到状态时**省略**该字段
881
+ // (nil 指针),客户端据此决定要不要画「领取」按钮。
882
+ // 新增(2026-10-24,只增字段不升 contractVersion)。
883
+ CheckinToday *bool `json:"checkin_today,omitempty"`
884
+ // CheckinCredit 今日可领积分(字符串,如 "100";活动没开 / 拿不到时省略)。
885
+ CheckinCredit string `json:"checkin_credit,omitempty"`
886
+ // LoggedIn 该账号槽位是否已登录(仅实现 agent.LoginChecker 的多账号引擎给出:
887
+ // codebuddy / codebuddy-ai)。客户端据此**只显示已登录的账号** ——
888
+ // 「登录了几个账号就显示几个」,而不是固定两行。
889
+ // 新增(2026-10-25,只增字段不升 contractVersion);老 core 没有该字段时客户端按「显示」处理。
890
+ LoggedIn *bool `json:"logged_in,omitempty"`
891
+ // Account 该账号槽位登录的**账号昵称**(如 "Darren" / "玲";仅 codebuddy 系给出,
892
+ // 来自票据里的 account.nickname)。选择器把它画在引擎名下面,一眼看出这行是哪个号。
893
+ // 新增(2026-10-25,只增字段不升 contractVersion)。
894
+ Account string `json:"account,omitempty"`
826
895
  // Workspace 该引擎「指定工作目录」的落地方式(machine-readable,供调用方决定是否下发):
827
896
  // flag:-C 原生 flag(codex);cwd 子进程 cwd(claude/codebuddy/trae);
828
897
  // none 无落地通道(llm/arkclaw/openclaw/codebuddy-gateway,参数会被忽略)
@@ -896,6 +965,16 @@ func emitEngineRows(cmd *cobra.Command, opts *askOptions, envelope bool) error {
896
965
  if ok {
897
966
  r.Bin, r.Note = note, ""
898
967
  }
968
+ /* 多账号引擎(codebuddy 系):这个槽位有没有登录。没登录的槽位客户端不显示,
969
+ 所以「登录了几个账号就显示几个」;探测本身不依赖它(模型清单照旧给)。 */
970
+ if lc, okl := e.(agent.LoginChecker); okl {
971
+ loggedIn := lc.LoggedIn()
972
+ r.LoggedIn = &loggedIn
973
+ }
974
+ /* 账号昵称:选择器里两个 codebuddy 槽位只差引擎名,标上「这行是哪个号」。 */
975
+ if al, okl := e.(agent.AccountLister); okl {
976
+ r.Account = al.AccountName()
977
+ }
899
978
  /* 安装 / 升级命令:**可用时也给**(同一条命令重跑一遍 = 升级到最新版)。
900
979
  没有可执行安装路径的引擎留空 —— 见 InstallCommandOf 的说明,不编命令。 */
901
980
  r.Install = agent.InstallCommandOf(e.Name())
@@ -914,20 +993,66 @@ func emitEngineRows(cmd *cobra.Command, opts *askOptions, envelope bool) error {
914
993
  rows[i].Version = agent.EngineVersionOf(cmd.Context(), bin)
915
994
  }(i, r.Bin)
916
995
  }
996
+ /* 账号剩余额度(codebuddy 系积分 / llm 的 MiniMax 套餐百分比):只读本机票据
997
+ 或 models.json + 一次只读请求。与模型探测并发跑;查不到就不给该字段。 */
998
+ if cl, okc := e.(agent.CreditLister); okc {
999
+ eng := e
1000
+ wg.Add(1)
1001
+ go func(i int, l agent.CreditLister) {
1002
+ defer wg.Done()
1003
+ left := l.CreditLeft(cmd.Context())
1004
+ rows[i].CreditLeft = left
1005
+ if left != "" {
1006
+ if u, oku := eng.(agent.CreditUnitLister); oku {
1007
+ rows[i].CreditSuffix = u.CreditUnit()
1008
+ }
1009
+ }
1010
+ }(i, cl)
1011
+ }
1012
+ /* 模型级套餐额度(llm 的 MiniMax 模型):同一账号一份快照,挂到每个走该端点的模型。 */
1013
+ if mq, okm := e.(agent.ModelQuotaLister); okm {
1014
+ eng := e
1015
+ wg.Add(1)
1016
+ go func(i int, l agent.ModelQuotaLister) {
1017
+ defer wg.Done()
1018
+ q := l.ModelQuota(cmd.Context())
1019
+ if len(q) == 0 {
1020
+ return
1021
+ }
1022
+ rows[i].ModelQuota = q
1023
+ if u, oku := eng.(agent.CreditUnitLister); oku {
1024
+ rows[i].CreditSuffix = u.CreditUnit()
1025
+ }
1026
+ }(i, mq)
1027
+ }
1028
+ /* 每日签到状态(同上,仅 codebuddy 系):今天是否已领 + 可领多少,
1029
+ 客户端据此画「领取」按钮;拿不到就省略两个字段。 */
1030
+ if ck, okc := e.(agent.Checkiner); okc {
1031
+ wg.Add(1)
1032
+ go func(i int, c agent.Checkiner) {
1033
+ defer wg.Done()
1034
+ if info, known := c.CheckinStatus(cmd.Context()); known {
1035
+ claimed := info.CheckedToday
1036
+ rows[i].CheckinToday = &claimed
1037
+ rows[i].CheckinCredit = info.DailyCredit
1038
+ }
1039
+ }(i, ck)
1040
+ }
917
1041
  if !ok {
918
1042
  continue
919
1043
  }
920
1044
  lister := agent.ModelListerOf(e)
921
1045
  if lister == nil {
922
1046
  rows[i].ModelsNote = "no dynamic model source"
1047
+ rows[i].ModelsSource = "none"
923
1048
  continue
924
1049
  }
925
1050
  wg.Add(1)
926
- go func(i int, name string, l agent.ModelLister) {
1051
+ go func(i int, name string, l agent.ModelLister, eng agent.Engine) {
927
1052
  defer wg.Done()
928
1053
  ctx, cancel := context.WithTimeout(cmd.Context(), agent.DefaultModelProbeTimeout)
929
1054
  defer cancel()
930
- models, credits, note := cachedModelList(ctx, name, l)
1055
+ models, credits, note, noSource := cachedModelList(ctx, name, l)
931
1056
  // codebuddy 系:展示时剥掉自定义模型 id 的 "custom-local:" 前缀(噪点);
932
1057
  // -m 实际传值时由引擎按裸名补回前缀(见 codebuddyCore.modelArg),
933
1058
  // 自定义模型依旧可用。
@@ -938,15 +1063,36 @@ func emitEngineRows(cmd *cobra.Command, opts *askOptions, envelope bool) error {
938
1063
  switch {
939
1064
  case note != "":
940
1065
  rows[i].ModelsNote = note
1066
+ // 语义型"没有模型源"≠ 探测失败:前者保留可选项(模型由 claw_id /
1067
+ // 网关进程决定),后者才让调用方隐藏。
1068
+ if noSource {
1069
+ rows[i].ModelsSource = "none"
1070
+ } else {
1071
+ rows[i].ModelsSource = "failed"
1072
+ }
941
1073
  case len(models) == 0:
942
1074
  rows[i].ModelsNote = "no models reported"
1075
+ rows[i].ModelsSource = "failed"
943
1076
  default:
944
1077
  rows[i].Models = models
1078
+ rows[i].ModelsSource = "dynamic"
945
1079
  if credits != nil {
946
1080
  rows[i].ModelCredits = credits
947
1081
  }
948
1082
  }
949
- }(i, e.Name(), lister)
1083
+ /* 供应商展示名(选择器第二行):与 models 同形,键同样剥前缀。
1084
+ 拿不到就整个省略 —— 界面据此决定画不画第二行(dsh 的第二行是路由,
1085
+ 走的是另一条「id 里自带 route/」的路径,与此无关)。 */
1086
+ if vl := agent.ModelVendorListOf(eng); vl != nil {
1087
+ vendors := vl.ModelVendors(ctx)
1088
+ if name == "codebuddy" || name == "codebuddy-ai" {
1089
+ vendors = agent.StripCustomLocalPrefixCredits(vendors)
1090
+ }
1091
+ if len(vendors) > 0 {
1092
+ rows[i].ModelVendors = vendors
1093
+ }
1094
+ }
1095
+ }(i, e.Name(), lister, e)
950
1096
  }
951
1097
  wg.Wait()
952
1098
  if !envelope {
@@ -385,25 +385,95 @@ func runSessions(cmd *cobra.Command, opts *askOptions) error {
385
385
  // 所以非流式跑出来的会话没有历史可回放 —— 客户端要拿它判断「这条点开有东西看吗」,
386
386
  // 而让客户端自己去拼日志路径就是把本仓库的内部布局当接口用。
387
387
  HasLog bool `json:"has_log"`
388
+ /* WorkspaceLocked 这条会话所在的目录当前是不是被**别人**占着工作空间锁
389
+ (2026-10-26,多客户端仲裁)。
390
+
391
+ 为什么要有这个字段:core 现在默认对同 workspace 串行(见 workspace_lock.go),
392
+ 但**等待发生在 Begin 里**,客户端在发请求的那一刻看不到「我要等」——
393
+ 用户看到的只是「点了没反应 / 转很久」。把这个判断上移到列表里,
394
+ 客户端就能在按下发送之前就说「这个目录正被手机端占着,已排队」。
395
+ ⚠️ 只增字段,老客户端忽略即可(不升契约版本)。 */
396
+ WorkspaceLocked bool `json:"workspace_locked,omitempty"`
397
+ // LockHolder 占着这个目录的那一端是谁(pid / client / source / run_id)。
398
+ // 只在 WorkspaceLocked 为真时给;用户的追问永远是「谁在占」,只有 pid 答不了。
399
+ LockHolder *session.LockHolder `json:"lock_holder,omitempty"`
388
400
  }
389
401
  logDir, _ := sessionLogDir()
402
+ /* 锁的查询按**目录**去重:一屏几十条会话大多在同一个 workspace 上,
403
+ 逐行读一次锁文件既浪费又要处理「同一目录答案不一致」的边界。
404
+ 这里先收集涉及的目录,各查一次。 */
405
+ lockOf := map[string]*session.LockHolder{}
406
+ lockOfWorkspace := func(ws string) *session.LockHolder {
407
+ key, _ := session.LockKey(ws)
408
+ if h, ok := lockOf[key]; ok {
409
+ return h
410
+ }
411
+ var out *session.LockHolder
412
+ if h, ok := session.LockHolderFor(ws); ok && h.PID != os.Getpid() {
413
+ /* 排除自己:列表页说「你自己占着这个目录」是噪音 ——
414
+ 用户关心的是「我要不要等别人」。 */
415
+ out = &h
416
+ }
417
+ lockOf[key] = out
418
+ return out
419
+ }
420
+ /* 标题表与状态表各取**一次**快照(2026-10-26 修):
421
+ 这两个查询原本逐行调用,而每次调用都会让底层把整张表深拷贝一遍(缓存不能交给
422
+ 调用方乱改)+ 加一次锁。407 行 = 814 次全表拷贝。列表页要的只是 O(1) 查表,
423
+ 拷一次就够 —— 语义不变(TitleOf / StatusOf 与 TitleForAny / StatusForAny 同口径)。 */
424
+ titles := session.TitleSnapshot()
425
+ statuses := session.StatusSnapshot()
390
426
  rows := make([]row, 0, len(records))
391
427
  for _, r := range records {
392
428
  /* 标题的键可能是 session_id,也可能是 run_id(首轮归纳时引擎还没吐 id,见
393
429
  session.TitleForAny)—— 两个都认,且**一次取定**,免得两个客户端因为
394
430
  「谁先拿到 id」而显示不同标题。取不到才回落 prompt_head(= 还没归纳)。 */
395
- title := session.TitleForAny(r.SessionID, r.RunID)
431
+ title := session.TitleOf(titles, r.SessionID, r.RunID)
396
432
  summarized := title != ""
397
433
  if title == "" {
398
434
  title = r.PromptHead
399
435
  }
436
+ hasLog := session.HasSessionLogIn(logDir, r.SessionID, r.RunID)
437
+ /* 排队占位(服务端作业队列预登记,state=queued)还没有会话日志,归纳必然失败。
438
+ 这里直接报「已定稿」,客户端就不会每轮列表刷新都去打一次 --summarize-title。
439
+ 真开跑后 core 那条记录照常走归纳。 */
440
+ if r.State == session.StateQueued {
441
+ summarized = true
442
+ }
443
+ /* 结构性不可能归纳:**已经有 session_id、却没有事件日志**。
444
+ 这种会话每一次归纳都必然失败,重试一万次结果一样,所以直接报「已定稿」,
445
+ 客户端别再去打那一发(否则它永远停在「待归纳」,每次刷新重来一遍)。
446
+
447
+ 为什么必然失败(见 runSummarizeTitle):归纳的输入必须是**首问**,首问只从
448
+ 事件日志里取;日志读不到时唯一的兜底是 prompt_head,而那条兜底**只在还没有
449
+ session_id 时**才允许(有 id = 至少第二轮,prompt_head 记的是当前这轮追问,
450
+ 拿它归纳等于把标题改成最新追问 —— 正是「标题每次追问都在变」那个老问题)。
451
+ 于是 `sessionID != "" && 无日志` 落进「读不到首问」的硬失败分支。
452
+
453
+ 什么时候会落到这个组合:引擎当时是非流式跑的(日志只在 --stream 时写)、
454
+ 或日志被清理而登记还在。两者都不会自己好转 —— 除非用户明确要求 `--title-force`
455
+ 重来(客户端的手动「重新归纳」按钮照常能打)。 */
456
+ if !summarized && r.SessionID != "" && !hasLog {
457
+ summarized = true
458
+ }
400
459
  /* 统一状态:一条会话一个值(各轮的行给出同一个),决定客户端画「进行中 / 已完成」。
401
460
  没有条目(改造前的老记录)才回落这一轮的 state。alive 也**按它算** ——
402
461
  否则一行里 status=idle、alive=true 自相矛盾,客户端又会分叉。 */
403
- status := session.StatusForAny(r.SessionID, r.RunID)
462
+ status := session.StatusOf(statuses, r.SessionID, r.RunID)
404
463
  if status == "" {
405
464
  status = r.State
406
465
  }
466
+ /* 这个会话所在目录当前被谁占着(空 workspace 用 cwd 兜底,与锁的归一一致)。
467
+ 只有**活着的**会话才关心:已收尾的会话不给这个字段,免得列表上到处是
468
+ 「锁被占着」的陈旧提示(锁文件可能因为进程刚退出还没删)。 */
469
+ var holder *session.LockHolder
470
+ if session.IsLiveState(status) {
471
+ ws := r.Workspace
472
+ if ws == "" {
473
+ ws = r.Cwd
474
+ }
475
+ holder = lockOfWorkspace(ws)
476
+ }
407
477
  rows = append(rows, row{
408
478
  RunID: r.RunID,
409
479
  SessionID: r.SessionID,
@@ -425,7 +495,9 @@ func runSessions(cmd *cobra.Command, opts *askOptions) error {
425
495
  TitleSummarized: summarized,
426
496
  StartedAt: r.StartedAt.Format("2006-01-02 15:04:05"),
427
497
  UpdatedAt: r.UpdatedAt.Format("2006-01-02 15:04:05"),
428
- HasLog: session.HasSessionLogIn(logDir, r.SessionID, r.RunID),
498
+ HasLog: hasLog,
499
+ WorkspaceLocked: holder != nil,
500
+ LockHolder: holder,
429
501
  })
430
502
  }
431
503
  return printJSON(cmd.OutOrStdout(), rows)
@@ -157,10 +157,20 @@ func TestAskRegistersSession(t *testing.T) {
157
157
  if r.Alive {
158
158
  t.Error("已结束的会话不该标 alive")
159
159
  }
160
- // 记录文件确实落在指定目录
160
+ /* 记录文件确实落在指定目录。
161
+ ⚠️ 只数 `.json`(2026-10-26):工作空间锁默认启用之后,同一个目录里还会多出
162
+ `locks/` 子目录(见 internal/session/workspace_lock.go)。原来按目录项数断言
163
+ 「= 1」会因为多了这个子目录而假失败 —— 它数的是「目录里有几样东西」,
164
+ 而这里想验的是「登记记录落在哪」,两者本来就不是一回事。 */
161
165
  entries, _ := os.ReadDir(dir)
162
- if len(entries) != 1 {
163
- t.Errorf("目录里文件数 = %d want 1", len(entries))
166
+ nJSON := 0
167
+ for _, e := range entries {
168
+ if !e.IsDir() && strings.HasSuffix(e.Name(), ".json") {
169
+ nJSON++
170
+ }
171
+ }
172
+ if nJSON != 1 {
173
+ t.Errorf("目录里登记记录数 = %d want 1(entries=%v)", nJSON, entries)
164
174
  }
165
175
  }
166
176
 
@@ -339,3 +349,72 @@ func mustSessions(t *testing.T) string {
339
349
  }
340
350
  return out
341
351
  }
352
+
353
+ // TestSessionsMarksUnsortedWhenSummarizeImpossible 有 session_id 却没有事件日志的会话,
354
+ // 归纳**必然失败**(见 runSummarizeTitle 里「读不到首问」那条硬失败分支),列表要直接
355
+ // 报「已定稿」—— 否则客户端每轮刷新都去打一发 --summarize-title,永远是失败,永远重来。
356
+ //
357
+ // 为什么这个标记必须由 core 给:客户端只看得到 title(恒非空,回落 prompt_head)与
358
+ // title_summarized,它**没有**能力判断「这个组合注定失败」——那依赖归纳的输入从哪来,
359
+ // 是 CLI 的内部知识。让客户端自己猜就会分叉。
360
+ func TestSessionsMarksUnsortedWhenSummarizeImpossible(t *testing.T) {
361
+ dir := t.TempDir()
362
+ t.Setenv(session.EnvDir, dir)
363
+ oldLog := SessionLogDirOverride
364
+ defer func() { SessionLogDirOverride = oldLog }()
365
+ SessionLogDirOverride = t.TempDir() // 空目录:任何会话都没有事件日志
366
+
367
+ /* 造两条记录:一条会拿到 session_id(引擎吐 id,归纳必然读不到首问),
368
+ 一条不吐 id(首轮,prompt_head 就是首问,归纳**有**机会成功)。
369
+ 用真实的调用链造(假引擎 + --save-registry),不手写记录文件 ——
370
+ writeRecord 没导出,而且手写的记录容易和 Read 侧的归一规则脱节。 */
371
+ registerFake(&sidEngine{name: "fake-hard-sid", text: "ok", sid: "sid-hard-fail"})
372
+ if _, _, err := runAskCmd(t, "", "--save-registry", "-e", "fake-hard-sid", "有 id 没日志"); err != nil {
373
+ t.Fatalf("造带 id 的记录失败: %v", err)
374
+ }
375
+ registerFake(&stringEngine{name: "fake-no-sid", text: "ok"})
376
+ if _, _, err := runAskCmd(t, "", "--save-registry", "-e", "fake-no-sid", "没 id 也没日志"); err != nil {
377
+ t.Fatalf("造无 id 的记录失败: %v", err)
378
+ }
379
+
380
+ var rows []struct {
381
+ RunID string `json:"run_id"`
382
+ SessionID string `json:"session_id"`
383
+ TitleSummarized bool `json:"title_summarized"`
384
+ HasLog bool `json:"has_log"`
385
+ }
386
+ out := mustSessions(t)
387
+ if err := json.Unmarshal([]byte(out), &rows); err != nil {
388
+ t.Fatalf("--sessions 输出不是 JSON:%v", err)
389
+ }
390
+ bySID := map[string]struct {
391
+ TitleSummarized bool
392
+ HasLog bool
393
+ }{}
394
+ for _, r := range rows {
395
+ bySID[r.SessionID] = struct {
396
+ TitleSummarized bool
397
+ HasLog bool
398
+ }{r.TitleSummarized, r.HasLog}
399
+ }
400
+
401
+ hard, ok := bySID["sid-hard-fail"]
402
+ if !ok {
403
+ t.Fatalf("有 session_id 的那条没出现在列表里:%s", out)
404
+ }
405
+ if hard.HasLog {
406
+ t.Error("前置条件不成立:这条本来就没有日志")
407
+ }
408
+ if !hard.TitleSummarized {
409
+ t.Error("有 session_id + 无日志 = 归纳必然失败,列表必须报 title_summarized=true(别再让客户端重试)")
410
+ }
411
+
412
+ // 没有 session_id 的那条:SessionID 为空串。
413
+ soft, ok := bySID[""]
414
+ if !ok {
415
+ t.Fatalf("没有 session_id 的那条没出现在列表里:%s", out)
416
+ }
417
+ if soft.TitleSummarized {
418
+ t.Error("没有 session_id 的会话归纳有机会成功(prompt_head 就是首问),不该被标成已定稿")
419
+ }
420
+ }
@@ -118,6 +118,12 @@ type sessionEvent struct {
118
118
  // Error / Reason 异常收尾的原因(仅 turn_failed),与事件流同源。
119
119
  Error string `json:"error,omitempty"`
120
120
  Reason string `json:"reason,omitempty"`
121
+ /* 失败分类(2026-10-25,仅 turn_failed):与事件流同名同义(见 events.go 的
122
+ streamEventPayload)。落盘它是为了让**历史回放画出同一张卡** —— 额度用尽
123
+ 那一轮翻回去看,仍应是「额度卡」而不是普通失败卡。 */
124
+ ErrorKind string `json:"error_kind,omitempty"`
125
+ ErrorCode string `json:"error_code,omitempty"`
126
+ ResetAt string `json:"reset_at,omitempty"`
121
127
  // Attachments 用户提问携带的附件(仅 kind:"user" 有)。引擎事件里没有附件信息
122
128
  // (`image` 事件只对应助手**产出**),所以只有落在这里,历史回放才复原得出
123
129
  // 「那一轮用了哪张图」—— 移动端据此在历史气泡里画出缩略图。
@@ -216,10 +216,30 @@ func TestSessionWriterRealisticStream(t *testing.T) {
216
216
  if len(evs) != 7 {
217
217
  t.Fatalf("ReadSessionLog 期望 7 条,实得 %d", len(evs))
218
218
  }
219
+
220
+ /* 额度卡三项要**落盘**(2026-10-25):只走实时流不带它们,用户翻历史时那一轮就退回
221
+ 普通失败卡 —— 「当时看到的」和「事后回看的」长成两张脸,是本仓库反复踩过的那类问题。 */
222
+ sw2, err := newSessionWriterAuto(sessionLogDir)
223
+ if err != nil {
224
+ t.Fatal(err)
225
+ }
226
+ sw2.setSessionID("sess-quota")
227
+ sw2.push(sessionEvent{
228
+ Kind: "turn_failed", Status: "failed",
229
+ Error: "429 You have exceeded the 5-hour usage quota.", Reason: "429 You have exceeded the 5-hour usage quota.",
230
+ ErrorKind: "quota", ErrorCode: "429", ResetAt: "2026-10-25T15:07:27+08:00",
231
+ })
232
+ drain(t, sw2)
233
+ back, err := ReadSessionLog("", "sess-quota")
234
+ if err != nil || len(back) != 1 {
235
+ t.Fatalf("读回额度那一轮失败:%v(%d 条)", err, len(back))
236
+ }
237
+ if back[0].ErrorKind != "quota" || back[0].ErrorCode != "429" || back[0].ResetAt != "2026-10-25T15:07:27+08:00" {
238
+ t.Errorf("落盘丢了额度字段:%+v", back[0])
239
+ }
219
240
  }
220
241
 
221
- /* ── 2026-10-15:日志不再等 session_id 才创建 ──
222
- 用户要求:「不等终点事件就应该创建会话落库,这样多个客户端马上就能看到正在跑的会话」。
242
+ /* ── 2026-10-15:日志不再等 session_id 才创建 ── 用户要求:「不等终点事件就应该创建会话落库,这样多个客户端马上就能看到正在跑的会话」。
223
243
  此前日志要等引擎吐 session_id 才知道文件名 → 正在跑的会话读出来永远是空历史;
224
244
  引擎整轮不给 id 时(dsh 的 headless 回退轮)更是收工就把内存 buffer 丢光。 */
225
245