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
@@ -0,0 +1,165 @@
1
+ package client
2
+
3
+ // sessions_fold.go - 会话清单按**会话**折叠(同一会话的多轮记录合并成一行)。
4
+ //
5
+ // 2026-10-26,用户报「会话列表现在显示很慢」的收尾一项。同一份登记表的实测:
6
+ //
7
+ // 全量 411 行 221.6KB
8
+ // 去重 109 行 58.1KB 降幅 73.8%
9
+ //
10
+ // 也就是说列表里**四分之三的行是同一个会话的不同轮次**。它们对列表页毫无用处:
11
+ // 左栏一屏最多画 60 行(QA_RAIL_MAX),407 行意味着用户要求的最新会话可能压根排不进
12
+ // 那 60 行;移动端更是为此写了一段 40 行的并查集(client-ui-mobile/index.html 的 UF /
13
+ // GROUP_KEY),就为了把「同一会话的两轮」藏掉。列表是**会话**视图,轮次是详情页的事。
14
+ //
15
+ // 为什么折在插件这一层而不是 core 的 `--sessions`:
16
+ // - `--sessions` 是**登记表**(run 级)的对外契约,掌天瓶桌面壳与手工 CLI 都在直接
17
+ // exec 它;改成会话级会动到别人的读法,收益却集中在「HTTP 列表接口每 5 秒传 211KB」
18
+ // 这一条路上 —— 折在这里收益全拿、影响面最小。
19
+ // - core 那边真要改,应该另开一条会话级命令,而不是把登记表本身改掉。
20
+ //
21
+ // ⚠️ 折叠必须**保守**,三条硬约束:
22
+ // ① 没有 session_id 的行不动(引擎还没报 id 的首轮 / 旧记录)—— 它们本来就是独立一行,
23
+ // 而且互相之间无从判断是不是同一会话。
24
+ // ② 被别的行当父指针引用的行**必须保留**。客户端认亲靠 `parent_run_id` 反查同一份清单
25
+ // (移动端 KEY2SID[parent_run_id]),折掉父行会让团队子任务变成一条孤儿顶层会话。
26
+ // ③ 组里有一行活着就显示那一行 —— 否则「正在跑」的那轮被折掉,列表上这条会话就
27
+ // 变回「已完成」,用户点了会发现还在跑(停下按钮也没了)。
28
+ //
29
+ // 折叠后 has_log / title_summarized 取**组内或**:这两个字段回答的是「这条会话有没有
30
+ // 历史可回看」「标题是不是真归纳过」,属于会话级属性,不该因为挑中了某一轮就变成 false
31
+ // (踩过:挑中最早那轮 → 明明有历史却标成「无历史」,点开看不出坏在哪)。
32
+
33
+ import (
34
+ "strings"
35
+
36
+ "github.com/darren/magic-agent/internal/session"
37
+ )
38
+
39
+ // foldSessionRuns 把同一会话的多轮记录折成一行,保持原有顺序。
40
+ //
41
+ // rows 是 core `--sessions` 解出来的原始行(新的在前)。返回新切片,不修改入参 ——
42
+ // 入参是缓存里的原始输出解出来的,改它会污染后面的请求(缓存存的是字节,每次请求
43
+ // 各自 Unmarshal,但保持函数无副作用更容易推理)。
44
+ func foldSessionRuns(rows []map[string]any) []map[string]any {
45
+ if len(rows) < 2 {
46
+ return rows
47
+ }
48
+ /* 被引用的父 id:既要认 run_id 也要认 session_id(团队主会话每轮的 session_id 会换,
49
+ 子任务的两根父指针分别指向不同轮,见 client-ui-mobile 的 UF 注释)。 */
50
+ referenced := make(map[string]bool, len(rows))
51
+ for _, r := range rows {
52
+ if v := idField(r, "parent_run_id"); v != "" {
53
+ referenced[v] = true
54
+ }
55
+ if v := idField(r, "parent_session_id"); v != "" {
56
+ referenced[v] = true
57
+ }
58
+ }
59
+ /* 每组挑一条显示(组按 session_id 分)。 */
60
+ type pick struct {
61
+ idx int
62
+ live bool
63
+ }
64
+ best := make(map[string]pick, len(rows))
65
+ keep := make([]bool, len(rows))
66
+ for i, r := range rows {
67
+ sid := idField(r, "session_id")
68
+ if sid == "" {
69
+ keep[i] = true // 约束 ①:没有会话 id 的行各自独立
70
+ continue
71
+ }
72
+ live := isLiveRow(r)
73
+ cur, ok := best[sid]
74
+ switch {
75
+ case !ok:
76
+ best[sid] = pick{idx: i, live: live}
77
+ case live && !cur.live:
78
+ // 约束 ③:活着的轮一定显示(哪怕它比别的轮旧 —— 活着就是当前状态)。
79
+ best[sid] = pick{idx: i, live: live}
80
+ case live == cur.live:
81
+ // 同档取更新的那轮。core 已按 started_at 倒序,所以「先遇到的」就是新的;
82
+ // 这里仍显式比较一次,免得将来 core 改了排序就静默挑了旧的。
83
+ if idField(r, "started_at") > idField(rows[cur.idx], "started_at") {
84
+ best[sid] = pick{idx: i, live: live}
85
+ }
86
+ }
87
+ }
88
+ for _, p := range best {
89
+ keep[p.idx] = true
90
+ }
91
+ /* 约束 ②:父行必须留 —— 与上面的挑法独立,可能额外多留几行。 */
92
+ for i, r := range rows {
93
+ if keep[i] {
94
+ continue
95
+ }
96
+ if referenced[idField(r, "run_id")] || referenced[idField(r, "session_id")] {
97
+ keep[i] = true
98
+ }
99
+ }
100
+ /* 组内或:会话级属性不该因为挑中某一轮而丢失(见文件头)。 */
101
+ logged := make(map[string]bool, len(best))
102
+ summarized := make(map[string]bool, len(best))
103
+ for _, r := range rows {
104
+ sid := idField(r, "session_id")
105
+ if sid == "" {
106
+ continue
107
+ }
108
+ if b, _ := r["has_log"].(bool); b {
109
+ logged[sid] = true
110
+ }
111
+ if b, _ := r["title_summarized"].(bool); b {
112
+ summarized[sid] = true
113
+ }
114
+ }
115
+ out := make([]map[string]any, 0, len(best)+2)
116
+ for i, r := range rows {
117
+ if !keep[i] {
118
+ continue
119
+ }
120
+ if sid := idField(r, "session_id"); sid != "" {
121
+ r = cloneRow(r)
122
+ if logged[sid] {
123
+ r["has_log"] = true
124
+ }
125
+ if summarized[sid] {
126
+ r["title_summarized"] = true
127
+ }
128
+ }
129
+ out = append(out, r)
130
+ }
131
+ return out
132
+ }
133
+
134
+ // cloneRow 浅拷贝一行,供折叠时改字段用(不改缓存解出来的原对象)。
135
+ func cloneRow(r map[string]any) map[string]any {
136
+ cp := make(map[string]any, len(r))
137
+ for k, v := range r {
138
+ cp[k] = v
139
+ }
140
+ return cp
141
+ }
142
+
143
+ // isLiveRow 这一行是不是「还活着」的会话。
144
+ //
145
+ // 判据与核心始终一致:status(会话级统一状态)优先,缺失回落 state(这一轮的状态)。
146
+ // 用 alive 字段不够 —— 它是 core 按 status==running 算出来的,而 idle / waiting_approval
147
+ // 同样是活着的进程(常驻会话、等审批),也要优先显示。
148
+ func isLiveRow(r map[string]any) bool {
149
+ if b, ok := r["alive"].(bool); ok && b {
150
+ return true
151
+ }
152
+ if session.IsLiveState(idField(r, "status")) {
153
+ return true
154
+ }
155
+ return session.IsLiveState(idField(r, "state"))
156
+ }
157
+
158
+ // idField 取一个**标识类**字段:缺失 / 类型不对都当空串,并去掉首尾空白。
159
+ //
160
+ // 为什么不直接用 strField(jobs_auto.go):那个是「把任意值 JSON 化」的通用取值,
161
+ // 不去空白;这里取的是 session_id / run_id / parent_* 这些要拿来做等值比较的键,
162
+ // 带个尾随空格就会被当成另一个会话(分组错乱、认亲认不到,且完全看不出来)。
163
+ func idField(r map[string]any, key string) string {
164
+ return strings.TrimSpace(strField(r, key))
165
+ }
@@ -0,0 +1,204 @@
1
+ package client
2
+
3
+ // sessions_fold_test.go - 会话清单折叠(sessions_fold.go)的边界测试。
4
+ //
5
+ // 重点钉三件事:① 同会话多轮只留一行;② 被当父指针引用的行不能折掉;
6
+ // ③ 活着的轮优先(否则列表上「正在跑」退回「已完成」,停止按钮也跟着消失)。
7
+ // 另外钉住 has_log / title_summarized 的「组内或」—— 挑中哪一轮不该改变这两个会话级属性。
8
+
9
+ import (
10
+ "encoding/json"
11
+ "testing"
12
+ )
13
+
14
+ // foldRow 造一行 core `--sessions` 形状的数据(只给折叠用到的字段)。
15
+ func foldRow(sid, run string, started string, extra map[string]any) map[string]any {
16
+ r := map[string]any{
17
+ "session_id": sid,
18
+ "run_id": run,
19
+ "started_at": started,
20
+ "state": "done",
21
+ "status": "done",
22
+ "has_log": false,
23
+ }
24
+ for k, v := range extra {
25
+ r[k] = v
26
+ }
27
+ return r
28
+ }
29
+
30
+ func foldRunIDs(rows []map[string]any) []string {
31
+ out := make([]string, 0, len(rows))
32
+ for _, r := range rows {
33
+ out = append(out, idField(r, "run_id"))
34
+ }
35
+ return out
36
+ }
37
+
38
+ func foldHas(rows []map[string]any, run string) map[string]any {
39
+ for _, r := range rows {
40
+ if idField(r, "run_id") == run {
41
+ return r
42
+ }
43
+ }
44
+ return nil
45
+ }
46
+
47
+ // 同一会话多轮 → 只留一行(新的那轮),顺序不变。
48
+ func TestFoldSessionRunsKeepsNewestPerSession(t *testing.T) {
49
+ rows := []map[string]any{
50
+ foldRow("s1", "r3", "2026-10-26 12:00:00", nil),
51
+ foldRow("s1", "r2", "2026-10-26 11:00:00", nil),
52
+ foldRow("s1", "r1", "2026-10-26 10:00:00", nil),
53
+ foldRow("s2", "r9", "2026-10-26 09:00:00", nil),
54
+ }
55
+ got := foldSessionRuns(rows)
56
+ if want := []string{"r3", "r9"}; !equalStrings(foldRunIDs(got), want) {
57
+ t.Fatalf("折叠结果 = %v,want %v", foldRunIDs(got), want)
58
+ }
59
+ if got[0]["session_id"] != "s1" {
60
+ t.Errorf("留下的那条应该是新一轮: %v", got[0])
61
+ }
62
+ }
63
+
64
+ // 没有 session_id 的行**不折**:引擎还没报 id 的首轮、旧记录,各自独立一行。
65
+ func TestFoldSessionRunsKeepsRowsWithoutSessionID(t *testing.T) {
66
+ rows := []map[string]any{
67
+ foldRow("", "r2", "2026-10-26 12:00:00", nil),
68
+ foldRow("", "r1", "2026-10-26 11:00:00", nil),
69
+ foldRow("s1", "r0", "2026-10-26 10:00:00", nil),
70
+ }
71
+ got := foldSessionRuns(rows)
72
+ if want := []string{"r2", "r1", "r0"}; !equalStrings(foldRunIDs(got), want) {
73
+ t.Fatalf("无 session_id 的行被折掉了: %v,want %v", foldRunIDs(got), want)
74
+ }
75
+ }
76
+
77
+ // 活着的轮一定显示,哪怕它不是最新的一轮。
78
+ //
79
+ // 场景:新一轮已经起了行(但还在排队 / 尚未开始计时),旧一轮还在 idle(常驻会话)——
80
+ // 按「取最新」会挑到排队那条,列表上这条会话就显示成「已完成」。
81
+ func TestFoldSessionRunsPrefersLiveRun(t *testing.T) {
82
+ rows := []map[string]any{
83
+ foldRow("s1", "r-new", "2026-10-26 12:00:00", map[string]any{"state": "queued", "status": "queued"}),
84
+ foldRow("s1", "r-live", "2026-10-26 11:00:00", map[string]any{"state": "idle", "status": "idle"}),
85
+ }
86
+ got := foldSessionRuns(rows)
87
+ if len(got) != 1 {
88
+ t.Fatalf("应折成 1 行,实得 %d: %v", len(got), foldRunIDs(got))
89
+ }
90
+ if idField(got[0], "run_id") != "r-live" {
91
+ t.Errorf("活着的轮 (idle) 应优先于更新的排队轮,实得 %s", idField(got[0], "run_id"))
92
+ }
93
+ }
94
+
95
+ // alive 字段单独也能表达「活着」(core 按 status==running 算的)。
96
+ func TestFoldSessionRunsHonoursAliveFlag(t *testing.T) {
97
+ rows := []map[string]any{
98
+ foldRow("s1", "r-new", "2026-10-26 12:00:00", nil),
99
+ foldRow("s1", "r-alive", "2026-10-26 11:00:00", map[string]any{"alive": true}),
100
+ }
101
+ got := foldSessionRuns(rows)
102
+ if len(got) != 1 || idField(got[0], "run_id") != "r-alive" {
103
+ t.Fatalf("alive 的轮应优先,实得 %v", foldRunIDs(got))
104
+ }
105
+ }
106
+
107
+ // 被父指针引用的行必须保留 —— 客户端认亲靠同一份清单里的 parent_run_id 反查,
108
+ // 折掉父行会让团队子任务变成一条孤儿顶层会话(移动端 KEY2SID 查不到就自己当顶层)。
109
+ func TestFoldSessionRunsKeepsReferencedParents(t *testing.T) {
110
+ rows := []map[string]any{
111
+ // 主会话 s1 两轮:r-parent 是子任务认的父,r-newest 是最新那轮。
112
+ foldRow("s1", "r-newest", "2026-10-26 12:00:00", nil),
113
+ foldRow("s1", "r-parent", "2026-10-26 11:00:00", nil),
114
+ foldRow("c1", "r-kid", "2026-10-26 11:30:00", map[string]any{
115
+ "parent_run_id": "r-parent",
116
+ }),
117
+ }
118
+ got := foldSessionRuns(rows)
119
+ ids := foldRunIDs(got)
120
+ if foldHas(got, "r-parent") == nil {
121
+ t.Fatalf("被引用的父行被折掉了,子会话会变孤儿: %v", ids)
122
+ }
123
+ if foldHas(got, "r-newest") == nil {
124
+ t.Errorf("组内显示的那行也该在: %v", ids)
125
+ }
126
+ if foldHas(got, "r-kid") == nil {
127
+ t.Errorf("子会话自己不能被折: %v", ids)
128
+ }
129
+ }
130
+
131
+ // parent_session_id 同样算引用(团队主会话每轮换 session_id,子任务两种指针都可能有)。
132
+ func TestFoldSessionRunsKeepsParentSessionRefs(t *testing.T) {
133
+ rows := []map[string]any{
134
+ foldRow("s1", "r-newest", "2026-10-26 12:00:00", nil),
135
+ foldRow("s1", "r-parent", "2026-10-26 11:00:00", nil),
136
+ foldRow("c1", "r-kid", "2026-10-26 11:30:00", map[string]any{
137
+ "parent_session_id": "s1",
138
+ "parent_run_id": "r-parent",
139
+ }),
140
+ }
141
+ got := foldSessionRuns(rows)
142
+ if foldHas(got, "r-parent") == nil {
143
+ t.Fatalf("按 parent_session_id 认亲时父行同样要留: %v", foldRunIDs(got))
144
+ }
145
+ }
146
+
147
+ // has_log / title_summarized 取组内或:挑中哪一轮不该让「有历史」「标题归纳过」变假。
148
+ func TestFoldSessionRunsMergesSessionLevelFlags(t *testing.T) {
149
+ rows := []map[string]any{
150
+ foldRow("s1", "r-new", "2026-10-26 12:00:00", map[string]any{
151
+ "has_log": false, "title_summarized": false,
152
+ }),
153
+ foldRow("s1", "r-old", "2026-10-26 11:00:00", map[string]any{
154
+ "has_log": true, "title_summarized": true,
155
+ }),
156
+ }
157
+ got := foldSessionRuns(rows)
158
+ if len(got) != 1 {
159
+ t.Fatalf("应折成 1 行,实得 %d", len(got))
160
+ }
161
+ if b, _ := got[0]["has_log"].(bool); !b {
162
+ t.Error("组里有一轮留下了历史 → 这行必须 has_log=true(否则界面上标成「无历史」)")
163
+ }
164
+ if b, _ := got[0]["title_summarized"].(bool); !b {
165
+ t.Error("组里有一轮归纳过标题 → 这行必须 title_summarized=true(否则客户端每轮重打归纳)")
166
+ }
167
+ }
168
+
169
+ // 折叠不许改到缓存解出来的原对象(缓存存的是字节,这里保持「无副作用」更好推理)。
170
+ func TestFoldSessionRunsDoesNotMutateInput(t *testing.T) {
171
+ rows := []map[string]any{
172
+ foldRow("s1", "r-new", "2026-10-26 12:00:00", map[string]any{"has_log": false}),
173
+ foldRow("s1", "r-old", "2026-10-26 11:00:00", map[string]any{"has_log": true}),
174
+ }
175
+ before, _ := json.Marshal(rows)
176
+ _ = foldSessionRuns(rows)
177
+ after, _ := json.Marshal(rows)
178
+ if string(before) != string(after) {
179
+ t.Fatalf("入参被改了:\n before=%s\n after =%s", before, after)
180
+ }
181
+ }
182
+
183
+ // 空 / 单行不该出错,也不该额外分配(走短路)。
184
+ func TestFoldSessionRunsShortCircuits(t *testing.T) {
185
+ if got := foldSessionRuns(nil); got != nil {
186
+ t.Errorf("nil 应原样返回,实得 %v", got)
187
+ }
188
+ one := []map[string]any{foldRow("s1", "r1", "2026-10-26 12:00:00", nil)}
189
+ if got := foldSessionRuns(one); len(got) != 1 || idField(got[0], "run_id") != "r1" {
190
+ t.Errorf("单行应原样返回,实得 %v", foldRunIDs(got))
191
+ }
192
+ }
193
+
194
+ func equalStrings(a, b []string) bool {
195
+ if len(a) != len(b) {
196
+ return false
197
+ }
198
+ for i := range a {
199
+ if a[i] != b[i] {
200
+ return false
201
+ }
202
+ }
203
+ return true
204
+ }