magic-agent 2.4.3 → 2.6.0

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 (153) hide show
  1. package/CLIENT-CONTRACT.md +284 -30
  2. package/README.md +15 -14
  3. package/client-ui/pc/app.css +32 -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 +37 -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 +241 -23
  11. package/client-ui/shared/conv/06-ask-answer.js +75 -1
  12. package/client-ui/shared/conv/09-stream-apply.js +63 -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 +81 -0
  16. package/client-ui/shared/conv/conv.css +79 -19
  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 +336 -50
  20. package/client-ui/shared/transport-http.js +14 -0
  21. package/client-ui-mobile/index.html +2932 -333
  22. package/client-ui-mobile/reqs.css +102 -0
  23. package/client-ui-mobile/reqs.js +485 -70
  24. package/client-ui-mobile/reqshot.js +505 -0
  25. package/client-ui-mobile/scripts.css +21 -0
  26. package/client-ui-mobile/scripts.js +539 -0
  27. package/client-ui-mobile/vendor/cose-base.js +3214 -0
  28. package/client-ui-mobile/vendor/cytoscape-fcose.js +1549 -0
  29. package/client-ui-mobile/vendor/cytoscape.min.js +32 -0
  30. package/client-ui-mobile/vendor/layout-base.js +5230 -0
  31. package/cmd/magic-client/browsectl.go +199 -0
  32. package/cmd/magic-client/clientctl.go +33 -9
  33. package/cmd/magic-client/main.go +57 -26
  34. package/internal/agent/ask.go +45 -0
  35. package/internal/agent/ask_test.go +39 -0
  36. package/internal/agent/attachments_test.go +6 -2
  37. package/internal/agent/capability.go +7 -1
  38. package/internal/agent/claude.go +20 -15
  39. package/internal/agent/codebuddy.go +125 -34
  40. package/internal/agent/codebuddy_account_test.go +54 -0
  41. package/internal/agent/codebuddy_stream_test.go +103 -0
  42. package/internal/agent/credits.go +713 -0
  43. package/internal/agent/credits_minimax_test.go +97 -0
  44. package/internal/agent/credits_test.go +211 -0
  45. package/internal/agent/custom_models.go +155 -0
  46. package/internal/agent/custom_models_test.go +125 -0
  47. package/internal/agent/dsh.go +123 -49
  48. package/internal/agent/dsh_resume.go +13 -2
  49. package/internal/agent/dsh_test.go +108 -6
  50. package/internal/agent/engine.go +37 -1
  51. package/internal/agent/engine_base.go +21 -17
  52. package/internal/agent/engine_base_test.go +2 -2
  53. package/internal/agent/engine_test.go +47 -86
  54. package/internal/agent/fallback.go +313 -0
  55. package/internal/agent/fallback_test.go +340 -0
  56. package/internal/agent/launch.go +43 -1
  57. package/internal/agent/llmhttp.go +46 -4
  58. package/internal/agent/llmhttp_test.go +157 -0
  59. package/internal/agent/models.go +53 -1
  60. package/internal/agent/models_test.go +20 -5
  61. package/internal/agent/pidalive_windows_test.go +40 -0
  62. package/internal/agent/runcmd.go +26 -0
  63. package/internal/agent/runcmd_windows.go +36 -5
  64. package/internal/agent/stream.go +136 -6
  65. package/internal/agent/stream_test.go +25 -38
  66. package/internal/agent/stream_timeline_test.go +2 -37
  67. package/internal/agent/streamjson.go +108 -6
  68. package/internal/agent/tools_suffix_test.go +6 -3
  69. package/internal/agent/trae.go +50 -243
  70. package/internal/agent/trae_exec.go +433 -0
  71. package/internal/agent/trae_exec_test.go +181 -0
  72. package/internal/agent/wraplaunch_test.go +113 -0
  73. package/internal/cli/ask.go +160 -37
  74. package/internal/cli/ask_test.go +1 -1
  75. package/internal/cli/checkin.go +77 -0
  76. package/internal/cli/clientcmd.go +3 -2
  77. package/internal/cli/control.go +46 -5
  78. package/internal/cli/control_test.go +65 -0
  79. package/internal/cli/events.go +4 -0
  80. package/internal/cli/quotas.go +125 -0
  81. package/internal/cli/requirements.go +51 -0
  82. package/internal/cli/root.go +122 -5
  83. package/internal/cli/session.go +6 -0
  84. package/internal/cli/suggest.go +7 -5
  85. package/internal/cli/team.go +37 -9
  86. package/internal/cli/team_test.go +39 -0
  87. package/internal/client/ab.go +372 -0
  88. package/internal/client/ab_test.go +287 -0
  89. package/internal/client/browse.go +84 -36
  90. package/internal/client/browse_test.go +107 -9
  91. package/internal/client/browseinput.go +481 -0
  92. package/internal/client/browseinput_test.go +216 -0
  93. package/internal/client/corecmd.go +23 -0
  94. package/internal/client/corecmd_other.go +8 -0
  95. package/internal/client/corecmd_windows.go +24 -0
  96. package/internal/client/daemon.go +90 -17
  97. package/internal/client/daemon_test.go +33 -0
  98. package/internal/client/engines_checkin_test.go +57 -0
  99. package/internal/client/fallback_flags_test.go +64 -0
  100. package/internal/client/jobs.go +294 -21
  101. package/internal/client/jobs_test.go +299 -2
  102. package/internal/client/knowledge.go +854 -0
  103. package/internal/client/knowledge_test.go +510 -0
  104. package/internal/client/lan.go +3 -3
  105. package/internal/client/quotas_merge_test.go +107 -0
  106. package/internal/client/requirements.go +198 -1
  107. package/internal/client/requirements_shots_test.go +200 -0
  108. package/internal/client/restart.go +134 -0
  109. package/internal/client/restart_test.go +151 -0
  110. package/internal/client/scripts.go +1047 -0
  111. package/internal/client/scripts_decode_other.go +27 -0
  112. package/internal/client/scripts_decode_test.go +125 -0
  113. package/internal/client/scripts_decode_windows.go +206 -0
  114. package/internal/client/scripts_test.go +726 -0
  115. package/internal/client/service.go +322 -18
  116. package/internal/client/service_test.go +51 -0
  117. package/internal/client/suggest.go +105 -2
  118. package/internal/client/suggest_test.go +86 -0
  119. package/internal/client/team_flags.go +25 -11
  120. package/internal/client/team_flags_test.go +39 -0
  121. package/internal/requirements/bootstrap.go +16 -0
  122. package/internal/requirements/consolidate.go +100 -9
  123. package/internal/requirements/doc.go +154 -3
  124. package/internal/requirements/graph.go +138 -0
  125. package/internal/requirements/memory.go +7 -0
  126. package/internal/requirements/modules.go +312 -0
  127. package/internal/requirements/modules_test.go +373 -0
  128. package/internal/requirements/relations.go +155 -0
  129. package/internal/requirements/relations_test.go +231 -0
  130. package/internal/requirements/requirements.go +10 -0
  131. package/internal/requirements/shots.go +418 -0
  132. package/internal/requirements/shots_test.go +387 -0
  133. package/internal/session/session.go +92 -0
  134. package/internal/suggest/suggest.go +23 -1
  135. package/internal/team/event.go +3 -1
  136. package/internal/team/execute.go +21 -6
  137. package/internal/team/fallback_route_test.go +80 -0
  138. package/internal/team/planner.go +15 -3
  139. package/internal/team/route.go +89 -17
  140. package/internal/team/team.go +18 -4
  141. package/npm/apk/magic-agent-mobile-release.apk +0 -0
  142. package/npm/apk/version.json +1 -5
  143. package/npm/dist/darwin-arm64/magic-agent +0 -0
  144. package/npm/dist/darwin-arm64/magic-client +0 -0
  145. package/npm/dist/darwin-x64/magic-agent +0 -0
  146. package/npm/dist/darwin-x64/magic-client +0 -0
  147. package/npm/dist/linux-arm64/magic-agent +0 -0
  148. package/npm/dist/linux-arm64/magic-client +0 -0
  149. package/npm/dist/linux-x64/magic-agent +0 -0
  150. package/npm/dist/linux-x64/magic-client +0 -0
  151. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  152. package/npm/dist/win32-x64/magic-client.exe +0 -0
  153. package/package.json +8 -2
@@ -0,0 +1,387 @@
1
+ package requirements
2
+
3
+ // shots_test.go - 「页面完成情况」截图的落盘、文档追加与往返保持。
4
+ //
5
+ // 重点盯三件事:
6
+ // · 追加截图**不动**用户手改过的内容(只加两行,不整篇重渲染);
7
+ // · 归纳重写文档时**保住**截图段(否则用户攒的完成情况会被抹掉);
8
+ // · 落盘路径是**正斜杠**(Windows 的反斜杠在 markdown 里是转义符)。
9
+
10
+ import (
11
+ "os"
12
+ "path/filepath"
13
+ "strconv"
14
+ "strings"
15
+ "testing"
16
+ )
17
+
18
+ // fakePNG 造一个最小 PNG(只需要文件头合法,core 不解码像素)。
19
+ func fakePNG() []byte {
20
+ return append(append([]byte{}, pngMagic...), 0x00, 0x01, 0x02, 0x03)
21
+ }
22
+
23
+ // 造一篇已存在的功能点文档,返回工作区 id 与文档路径。
24
+ func seedFeature(t *testing.T, module, name string) (string, string) {
25
+ t.Helper()
26
+ root := t.TempDir()
27
+ t.Setenv(EnvDir, filepath.Join(root, "requirements"))
28
+ id := "ws-abc1234567"
29
+ dir := filepath.Join(featuresDir(id), module)
30
+ if err := os.MkdirAll(dir, 0o700); err != nil {
31
+ t.Fatal(err)
32
+ }
33
+ file := filepath.Join(dir, SlugifyFeature(name)+".md")
34
+ doc := RenderDoc(name, Doc{
35
+ Desc: "一个功能点",
36
+ Points: []string{"要点一"},
37
+ Conclusions: []string{"结论一"},
38
+ Final: "**目标**:一个功能点",
39
+ })
40
+ if err := os.WriteFile(file, []byte(doc), 0o600); err != nil {
41
+ t.Fatal(err)
42
+ }
43
+ return id, file
44
+ }
45
+
46
+ func TestAttachShotAppendsToDoc(t *testing.T) {
47
+ id, file := seedFeature(t, "任务列表", "需求页")
48
+ res, err := AttachShot(id, "任务列表", "需求页", "改成双列布局", fakePNG())
49
+ if err != nil {
50
+ t.Fatalf("追加截图失败: %v", err)
51
+ }
52
+ if !res.OK || res.Shots != 1 {
53
+ t.Fatalf("结果不对: %+v", res)
54
+ }
55
+ if !strings.Contains(res.Path, "/") || strings.Contains(res.Path, `\`) {
56
+ t.Errorf("截图路径应是正斜杠: %q", res.Path)
57
+ }
58
+ if _, err := os.Stat(filepath.FromSlash(res.Path)); err != nil {
59
+ t.Errorf("截图文件不在盘上: %v", err)
60
+ }
61
+ raw, err := os.ReadFile(file)
62
+ if err != nil {
63
+ t.Fatal(err)
64
+ }
65
+ text := string(raw)
66
+ if !strings.Contains(text, "## 页面完成情况") {
67
+ t.Fatalf("文档里没有新增段落:\n%s", text)
68
+ }
69
+ if !strings.Contains(text, "改成双列布局") {
70
+ t.Errorf("文档里没有标注说明:\n%s", text)
71
+ }
72
+ // 原有内容必须还在。
73
+ if !strings.Contains(text, "## 需求点") || !strings.Contains(text, "要点一") {
74
+ t.Errorf("原有内容被弄丢了:\n%s", text)
75
+ }
76
+ // 段的位置:在来源对话之前。
77
+ if strings.Index(text, "## 页面完成情况") > strings.Index(text, "## 来源对话") {
78
+ t.Errorf("新段应在来源对话之前:\n%s", text)
79
+ }
80
+ }
81
+
82
+ func TestAttachShotKeepsUserEdits(t *testing.T) {
83
+ // 用户在两次归纳之间手改了文档(加了一个自定义段 + 改了措辞)。
84
+ // 追加截图**不能**把那些内容抹掉 —— 这就是「文本级插入」的意义。
85
+ id, file := seedFeature(t, "任务列表", "需求页")
86
+ raw, _ := os.ReadFile(file)
87
+ edited := string(raw) + "\n## 我自己加的备注\n这段是用户手写的\n"
88
+ if err := os.WriteFile(file, []byte(edited), 0o600); err != nil {
89
+ t.Fatal(err)
90
+ }
91
+ if _, err := AttachShot(id, "任务列表", "需求页", "标注", fakePNG()); err != nil {
92
+ t.Fatalf("追加截图失败: %v", err)
93
+ }
94
+ after, _ := os.ReadFile(file)
95
+ if !strings.Contains(string(after), "## 我自己加的备注") {
96
+ t.Errorf("用户手写的内容被抹掉了:\n%s", after)
97
+ }
98
+ if !strings.Contains(string(after), "这段是用户手写的") {
99
+ t.Errorf("用户手写的内容被抹掉了:\n%s", after)
100
+ }
101
+ }
102
+
103
+ func TestAttachShotTwiceKeepsBoth(t *testing.T) {
104
+ id, file := seedFeature(t, "任务列表", "需求页")
105
+ if _, err := AttachShot(id, "任务列表", "需求页", "第一张", fakePNG()); err != nil {
106
+ t.Fatal(err)
107
+ }
108
+ res, err := AttachShot(id, "任务列表", "需求页", "第二张", fakePNG())
109
+ if err != nil {
110
+ t.Fatalf("第二次追加失败: %v", err)
111
+ }
112
+ if res.Shots != 2 {
113
+ t.Errorf("应有 2 张截图,实际 %d", res.Shots)
114
+ }
115
+ raw, _ := os.ReadFile(file)
116
+ text := string(raw)
117
+ if !strings.Contains(text, "第一张") || !strings.Contains(text, "第二张") {
118
+ t.Errorf("两张截图都该在文档里:\n%s", text)
119
+ }
120
+ // 占位符不该留着。
121
+ if strings.Contains(text, "(暂无)\n## 来源对话") {
122
+ t.Errorf("有图后不该还留着占位符:\n%s", text)
123
+ }
124
+ }
125
+
126
+ func TestAttachShotErrors(t *testing.T) {
127
+ id, _ := seedFeature(t, "任务列表", "需求页")
128
+ // 找不到功能点。
129
+ if _, err := AttachShot(id, "任务列表", "不存在的功能", "x", fakePNG()); err == nil {
130
+ t.Error("找不到功能点应当报错")
131
+ }
132
+ // 模块对不上。
133
+ if _, err := AttachShot(id, "别的模块", "需求页", "x", fakePNG()); err == nil {
134
+ t.Error("模块对不上应当报错")
135
+ }
136
+ // 不是 PNG。
137
+ if _, err := AttachShot(id, "任务列表", "需求页", "x", []byte("not a png")); err == nil {
138
+ t.Error("非 PNG 应当报错")
139
+ }
140
+ // 空内容。
141
+ if _, err := AttachShot(id, "任务列表", "需求页", "x", nil); err == nil {
142
+ t.Error("空内容应当报错")
143
+ }
144
+ // 超限。
145
+ if _, err := AttachShot(id, "任务列表", "需求页", "x", make([]byte, MaxShotBytes+1)); err == nil {
146
+ t.Error("超限应当报错")
147
+ }
148
+ // 功能点名空。
149
+ if _, err := AttachShot(id, "任务列表", " ", "x", fakePNG()); err == nil {
150
+ t.Error("功能点名空应当报错")
151
+ }
152
+ }
153
+
154
+ func TestDocRoundTripKeepsShots(t *testing.T) {
155
+ // 归纳重写文档时(ParseDoc → RenderDoc)要保住截图与页面地址。
156
+ d := Doc{
157
+ Desc: "描述",
158
+ Points: []string{"要点"},
159
+ Conclusions: []string{"结论"},
160
+ PageURL: "https://example.com/page",
161
+ Shots: []Shot{
162
+ {Path: "C:/Users/x/a b (v2)/1.png", Caption: "改完了", At: 1793000000000},
163
+ },
164
+ }
165
+ out := RenderDoc("需求页", d)
166
+ back := ParseDoc(out)
167
+ if back.PageURL != d.PageURL {
168
+ t.Errorf("页面地址丢了: %q", back.PageURL)
169
+ }
170
+ if len(back.Shots) != 1 {
171
+ t.Fatalf("截图丢了: %+v", back.Shots)
172
+ }
173
+ if back.Shots[0].Path != d.Shots[0].Path {
174
+ t.Errorf("截图路径不对: %q", back.Shots[0].Path)
175
+ }
176
+ if back.Shots[0].Caption != "改完了" {
177
+ t.Errorf("标注说明不对: %q", back.Shots[0].Caption)
178
+ }
179
+ if back.Shots[0].At != d.Shots[0].At {
180
+ t.Errorf("截图时间不对: %d", back.Shots[0].At)
181
+ }
182
+ }
183
+
184
+ func TestRenderDocWritesShotsSection(t *testing.T) {
185
+ out := RenderDoc("需求页", Doc{Points: []string{"要点"}})
186
+ if !strings.Contains(out, "## 页面完成情况") {
187
+ t.Errorf("空文档也该有这一段(占位):\n%s", out)
188
+ }
189
+ if !strings.Contains(out, "(暂无)") {
190
+ t.Errorf("空段该写占位:\n%s", out)
191
+ }
192
+ }
193
+
194
+ func TestValidPageURL(t *testing.T) {
195
+ good := []string{"https://example.com", "http://a.b/c?d=1", "HTTPS://X.COM"}
196
+ bad := []string{"", "无", "待定", "见上文", "C:/Users/x", "ftp://x", "javascript:alert(1)"}
197
+ for _, u := range good {
198
+ if !validPageURL(u) {
199
+ t.Errorf("应认: %q", u)
200
+ }
201
+ }
202
+ for _, u := range bad {
203
+ if validPageURL(u) {
204
+ t.Errorf("不该认: %q", u)
205
+ }
206
+ }
207
+ }
208
+
209
+ func TestConsolidateKeepsShots(t *testing.T) {
210
+ // 假模型:返回一个已有功能点的增量(只有 desc)。
211
+ // 断言:重写文档后截图段仍在、页面地址仍在。
212
+ root := t.TempDir()
213
+ t.Setenv(EnvDir, filepath.Join(root, "requirements"))
214
+ id := "ws-abc1234567"
215
+ dir := filepath.Join(featuresDir(id), "任务列表")
216
+ if err := os.MkdirAll(dir, 0o700); err != nil {
217
+ t.Fatal(err)
218
+ }
219
+ file := filepath.Join(dir, "需求页.md")
220
+ doc := RenderDoc("需求页", Doc{
221
+ Desc: "旧描述",
222
+ Points: []string{"要点"},
223
+ PageURL: "https://example.com/p",
224
+ Shots: []Shot{{Path: "C:/x/1.png", Caption: "已改", At: 1793000000000}},
225
+ Conclusions: []string{},
226
+ })
227
+ if err := os.WriteFile(file, []byte(doc), 0o600); err != nil {
228
+ t.Fatal(err)
229
+ }
230
+ merger := newDocMerger(id)
231
+ w := writeFeatureItem(id, featureOut{
232
+ Module: "任务列表",
233
+ Name: "需求页",
234
+ Desc: "新描述",
235
+ }, &writeCtx{merger: merger, kind: "归纳"})
236
+ if !w.ok {
237
+ t.Fatal("写入失败")
238
+ }
239
+ raw, _ := os.ReadFile(file)
240
+ back := ParseDoc(string(raw))
241
+ if len(back.Shots) != 1 {
242
+ t.Fatalf("归纳把截图抹掉了:\n%s", raw)
243
+ }
244
+ if back.Shots[0].Caption != "已改" {
245
+ t.Errorf("标注说明丢了: %+v", back.Shots)
246
+ }
247
+ if back.PageURL != "https://example.com/p" {
248
+ t.Errorf("页面地址丢了: %q", back.PageURL)
249
+ }
250
+ if back.Desc != "新描述" {
251
+ t.Errorf("描述没更新: %q", back.Desc)
252
+ }
253
+ }
254
+
255
+ func TestConsolidateKeepsPageURLWhenModelEmpty(t *testing.T) {
256
+ // 本轮模型没给 page_url(空串)→ 保留旧的,不能被空值抹掉。
257
+ root := t.TempDir()
258
+ t.Setenv(EnvDir, filepath.Join(root, "requirements"))
259
+ id := "ws-abc1234567"
260
+ dir := filepath.Join(featuresDir(id), "任务列表")
261
+ _ = os.MkdirAll(dir, 0o700)
262
+ file := filepath.Join(dir, "需求页.md")
263
+ doc := RenderDoc("需求页", Doc{Desc: "旧", PageURL: "https://example.com/keep"})
264
+ _ = os.WriteFile(file, []byte(doc), 0o600)
265
+ merger := newDocMerger(id)
266
+ w := writeFeatureItem(id, featureOut{Module: "任务列表", Name: "需求页", Desc: "新"}, &writeCtx{merger: merger, kind: "归纳"})
267
+ if !w.ok {
268
+ t.Fatal("写入失败")
269
+ }
270
+ raw, _ := os.ReadFile(file)
271
+ if back := ParseDoc(string(raw)); back.PageURL != "https://example.com/keep" {
272
+ t.Errorf("空 page_url 不该抹掉旧地址: %q", back.PageURL)
273
+ }
274
+ }
275
+
276
+ func TestConsolidateRejectsBadPageURL(t *testing.T) {
277
+ // 模型给了个非法地址(不是 http/https)→ 保留旧的,不写进去。
278
+ root := t.TempDir()
279
+ t.Setenv(EnvDir, filepath.Join(root, "requirements"))
280
+ id := "ws-abc1234567"
281
+ dir := filepath.Join(featuresDir(id), "任务列表")
282
+ _ = os.MkdirAll(dir, 0o700)
283
+ file := filepath.Join(dir, "需求页.md")
284
+ doc := RenderDoc("需求页", Doc{Desc: "旧", PageURL: "https://example.com/keep"})
285
+ _ = os.WriteFile(file, []byte(doc), 0o600)
286
+ merger := newDocMerger(id)
287
+ w := writeFeatureItem(id, featureOut{Module: "任务列表", Name: "需求页", Desc: "新", PageURL: "javascript:alert(1)"},
288
+ &writeCtx{merger: merger, kind: "归纳"})
289
+ if !w.ok {
290
+ t.Fatal("写入失败")
291
+ }
292
+ raw, _ := os.ReadFile(file)
293
+ if back := ParseDoc(string(raw)); back.PageURL != "https://example.com/keep" {
294
+ t.Errorf("非法地址不该写进去: %q", back.PageURL)
295
+ }
296
+ }
297
+
298
+ func TestAttachShotDedupesSamePath(t *testing.T) {
299
+ // 同一张图被写进文档两遍 → 段里只该有一行(留最后那条)。
300
+ // 触发方式:把同一行手工塞进文档,再追加一张**同名**的图。
301
+ id, file := seedFeature(t, "任务列表", "需求页")
302
+ if _, err := AttachShot(id, "任务列表", "需求页", "第一张", fakePNG()); err != nil {
303
+ t.Fatalf("追加截图失败: %v", err)
304
+ }
305
+ raw, _ := os.ReadFile(file)
306
+ // 把已有那行原样复制一遍(模拟重放/手工编辑造成的重复)。
307
+ lines := strings.Split(string(raw), "\n")
308
+ dup := ""
309
+ for _, l := range lines {
310
+ if strings.Contains(l, "第一张") {
311
+ dup = l
312
+ break
313
+ }
314
+ }
315
+ if dup == "" {
316
+ t.Fatalf("文档里没找到截图行:\n%s", raw)
317
+ }
318
+ edited := strings.Replace(string(raw), dup, dup+"\n"+dup, 1)
319
+ if err := os.WriteFile(file, []byte(edited), 0o600); err != nil {
320
+ t.Fatal(err)
321
+ }
322
+ // 再追加一张新图 —— insertShot 应顺手把重复行去掉。
323
+ res, err := AttachShot(id, "任务列表", "需求页", "第二张", fakePNG())
324
+ if err != nil {
325
+ t.Fatalf("追加截图失败: %v", err)
326
+ }
327
+ if res.Shots != 2 {
328
+ t.Errorf("去重后应有 2 张(两张不同路径),实际 %d", res.Shots)
329
+ }
330
+ after, _ := os.ReadFile(file)
331
+ if n := strings.Count(string(after), "第一张"); n != 1 {
332
+ t.Errorf("重复的截图行没去掉,出现 %d 次:\n%s", n, after)
333
+ }
334
+ }
335
+
336
+ func TestTrimShotsDedupesAndTrims(t *testing.T) {
337
+ shots := []Shot{
338
+ {Path: "C:/x/1.png", Caption: "a"},
339
+ {Path: "C:/x/2.png", Caption: "b"},
340
+ {Path: "C:/x/1.png", Caption: "a2"},
341
+ }
342
+ out := TrimShots(shots)
343
+ if len(out) != 2 {
344
+ t.Fatalf("去重后应有 2 条,实际 %d: %+v", len(out), out)
345
+ }
346
+ // 留最后一条的**内容**,但位置不动(首次出现的位置)。
347
+ if out[0].Path != "C:/x/1.png" || out[0].Caption != "a2" {
348
+ t.Errorf("该留最后一条的内容(位置不动),实际 %+v", out[0])
349
+ }
350
+ if out[1].Path != "C:/x/2.png" {
351
+ t.Errorf("第二条不对: %+v", out[1])
352
+ }
353
+ // 超上限时丢最旧的。
354
+ many := make([]Shot, 0, MaxShots+5)
355
+ for i := 0; i < MaxShots+5; i++ {
356
+ many = append(many, Shot{Path: "C:/x/" + strconv.Itoa(i) + ".png"})
357
+ }
358
+ got := TrimShots(many)
359
+ if len(got) != MaxShots {
360
+ t.Fatalf("该裁到 %d 条,实际 %d", MaxShots, len(got))
361
+ }
362
+ if got[0].Path != "C:/x/5.png" {
363
+ t.Errorf("该丢最旧的 5 条,实际第一条 %q", got[0].Path)
364
+ }
365
+ }
366
+
367
+ func TestRenderDocShotLinesAreCounted(t *testing.T) {
368
+ // RenderDoc 写的是列表项 `- ![…](…)`;这行必须能被 countShots 数出来,
369
+ // 也必须能被 ParseDoc 解析回来 —— 否则归纳重渲染后截图就「消失」了。
370
+ out := RenderDoc("需求页", Doc{
371
+ PageURL: "https://example.com/p",
372
+ Shots: []Shot{{Path: "C:/x/1.png", Caption: "改完了", At: 1793000000000}},
373
+ })
374
+ if !strings.Contains(out, "- ![改完了](C:/x/1.png)") {
375
+ t.Errorf("RenderDoc 的截图形状不对:\n%s", out)
376
+ }
377
+ if n := countShots(strings.Split(out, "\n")); n != 1 {
378
+ t.Errorf("countShots 数不出 RenderDoc 写的截图(得 %d):\n%s", n, out)
379
+ }
380
+ back := ParseDoc(out)
381
+ if len(back.Shots) != 1 {
382
+ t.Fatalf("ParseDoc 解析不回来: %+v", back.Shots)
383
+ }
384
+ if back.Shots[0].Caption != "改完了" || back.Shots[0].Path != "C:/x/1.png" {
385
+ t.Errorf("解析结果不对: %+v", back.Shots[0])
386
+ }
387
+ }
@@ -133,6 +133,10 @@ const (
133
133
  StateCancelled = "cancelled" // 被 --stop 取消
134
134
  StateInterrupted = "interrupted" // 当前轮被打断(会话仍可继续)
135
135
  StateGone = "gone" // 记录里是 running,但进程已不在(自然退出/被别处杀掉)
136
+ /* queued 已受理、还在作业队列里等(**不是**活着:没有进程,也没有会话日志)。
137
+ 由服务端作业队列预登记(见 RegisterQueued),真开跑时抹掉、交给 core 自己的
138
+ running 记录。列表页据此把「排在前面的那几条」画出来。 */
139
+ StateQueued = "queued"
136
140
  )
137
141
 
138
142
  // isLiveState 这个状态是否代表「会话/进程还活着」(running / waiting_approval / idle)。
@@ -526,6 +530,78 @@ func Begin(o BeginOptions) (*Handle, error) {
526
530
  return h, nil
527
531
  }
528
532
 
533
+ // RegisterQueued 预登记一条「已受理、还没开跑」的会话(作业队列用)。
534
+ //
535
+ // 为什么(2026-10-24 用户:「没跑也先登记啊」):串行模式下作业在服务端排队,
536
+ // 此前只有真开跑(core 的 Begin)才写登记表 —— 排在后面的几条既不在 --sessions 里,
537
+ // 也不在任何客户端的列表上,看起来像「发完凭空消失」。现在受理即登记(state=queued),
538
+ // 谁排在队里、排第几,列表页从这一刻就能看见;真开跑 / 终结时由队列调 RemoveQueued
539
+ // 抹掉占位,交给 core 自己的那条 running 记录。
540
+ //
541
+ // 口径:
542
+ // - RunID 用客户端的幂等键(request_id;没有就用 job id):客户端据此把本地那条
543
+ // 「等待中」和这条登记行认成同一条,不会画两行。core 的 run_id 是 run-… 前缀,
544
+ // 两者不会撞。
545
+ // - PID 恒 0、state=queued:读侧(normalizeDead / externalBusy / Stop)都按
546
+ // 「非活着」处理,不占工作空间锁、不会被误判成在跑。
547
+ // - 覆盖写是幂等的:队列每推进一次状态就同步一次。
548
+ func RegisterQueued(rec Record) error {
549
+ dir := Dir()
550
+ rec.RunID = strings.TrimSpace(rec.RunID)
551
+ if dir == "" || rec.RunID == "" {
552
+ return fmt.Errorf("register queued: 缺少登记目录或 run_id")
553
+ }
554
+ /* RunID 会直接当文件名用(<run_id>.json),而排队占位的 id 来自客户端的
555
+ request_id —— 不能让 `../x`、带分隔符的 id 逃出登记目录。作业队列侧已经
556
+ 做过一次安全化(见 client 的 queuedSessionID),这里再挡一道。 */
557
+ if !validRecordName(rec.RunID) {
558
+ return fmt.Errorf("register queued: 非法 run_id %q", rec.RunID)
559
+ }
560
+ if err := os.MkdirAll(dir, 0o700); err != nil {
561
+ return err
562
+ }
563
+ rec.State = StateQueued
564
+ rec.PID = 0
565
+ rec.KillGroup = false
566
+ rec.PromptHead = head(rec.PromptHead, 80)
567
+ if rec.StartedAt.IsZero() {
568
+ rec.StartedAt = time.Now()
569
+ }
570
+ rec.UpdatedAt = time.Now()
571
+ data, err := json.MarshalIndent(rec, "", " ")
572
+ if err != nil {
573
+ return err
574
+ }
575
+ path := filepath.Join(dir, rec.RunID+".json")
576
+ tmp := path + ".tmp"
577
+ if err := os.WriteFile(tmp, data, 0o600); err != nil {
578
+ return err
579
+ }
580
+ if err := os.Rename(tmp, path); err != nil {
581
+ return err
582
+ }
583
+ setStatus(rec)
584
+ return nil
585
+ }
586
+
587
+ // RemoveQueued 抹掉排队占位(开跑 / 终结 / 取消时由作业队列调用)。
588
+ // 没有占位文件就什么都不做 —— 也**不要**去清状态表:那条键可能已经是真实会话的了。
589
+ func RemoveQueued(runID string) {
590
+ runID = strings.TrimSpace(runID)
591
+ dir := Dir()
592
+ if runID == "" || dir == "" || !validRecordName(runID) {
593
+ return
594
+ }
595
+ path := filepath.Join(dir, runID+".json")
596
+ if _, err := os.Stat(path); err != nil {
597
+ return
598
+ }
599
+ if err := os.Remove(path); err != nil {
600
+ return
601
+ }
602
+ dropStatuses(map[string]bool{runID: true})
603
+ }
604
+
529
605
  // supersedeSiblings 停掉同一会话上**还活着**的上一次运行。
530
606
  //
531
607
  // 为什么(2026-10-21,用户:「两边的回话状态不一致,一边结束了,一边还在显示运行中」):
@@ -582,6 +658,22 @@ func head(s string, n int) string {
582
658
  return string(r[:n]) + "…"
583
659
  }
584
660
 
661
+ // validRecordName 登记文件名是否安全:只允许字母 / 数字 / - _ .,且不能是 . / ..。
662
+ // 只给排队占位用 —— 它的 id 可能来自客户端(request_id),不能直接拼进路径。
663
+ func validRecordName(id string) bool {
664
+ if id == "" || id == "." || id == ".." || len(id) > 96 {
665
+ return false
666
+ }
667
+ for _, r := range id {
668
+ switch {
669
+ case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_', r == '.':
670
+ default:
671
+ return false
672
+ }
673
+ }
674
+ return true
675
+ }
676
+
585
677
  // cwdOf 取进程当前目录;拿不到就留空(需求归纳按 workspace 归属时会退回用
586
678
  // Workspace 字段,两者都没有就只是不参与按目录的自动分组,不影响会话本身)。
587
679
  func cwdOf() string {
@@ -13,7 +13,8 @@
13
13
  // 去重、形态校验,挡掉「AI 的回答」冒充「用户的话」。
14
14
  //
15
15
  // 与 `--summarize-title`(internal/session/title.go::Summarize)的分工:那条把会话首问压成
16
- // 标题、结果落进标题表;这条只产出一组**临时**建议,不落盘、不缓存 —— 下一轮结束就重算。
16
+ // 标题、结果落进标题表;这条只产出一组**临时**建议,core 侧不落盘 —— 同一轮的重复请求
17
+ // 由 magic-client 插件层缓存(internal/client/suggest.go),下一轮问答换了内容自然重算。
17
18
  package suggest
18
19
 
19
20
  import (
@@ -51,6 +52,12 @@ type Request struct {
51
52
  Engine string
52
53
  // Model 模型标识(空 = 引擎默认)。
53
54
  Model string
55
+ // FallbackEngine / FallbackModel 用户配的备用引擎(2026-10-24)。
56
+ //
57
+ // 主引擎失败时**优先**试它,再落到内置的 llm 兜底;空 = 直接用内置兜底。
58
+ // 这样界面上「建议引擎」也能配主 / 备,与默认引擎、团队模式同一套语义。
59
+ FallbackEngine string
60
+ FallbackModel string
54
61
  // Count 最多几条(<=0 = DefaultCount)。
55
62
  Count int
56
63
  // Timeout 单次调用超时(<=0 = DefaultTimeout)。
@@ -205,6 +212,21 @@ func Generate(ctx context.Context, in Request) (Result, error) {
205
212
  if err == nil {
206
213
  return res, nil
207
214
  }
215
+ /* 用户配的备用引擎:优先于内置 llm 兜底(2026-10-24)。它更贴近用户的意图
216
+ (例如「建议用 codebuddy,额度不够就换 dsh」),llm 只是最后的保底。 */
217
+ if fb := strings.TrimSpace(in.FallbackEngine); fb != "" && !strings.EqualFold(fb, name) {
218
+ if agent.Lookup(fb) != nil {
219
+ remain := timeout - time.Since(start)
220
+ if remain < 2*time.Second {
221
+ remain = 2 * time.Second
222
+ }
223
+ fbIn := in
224
+ fbIn.Model = strings.TrimSpace(in.FallbackModel)
225
+ if fbRes, ferr := tryGenerate(cctx, fbIn, fb, remain, start); ferr == nil {
226
+ return fbRes, nil
227
+ }
228
+ }
229
+ }
208
230
  if name == DefaultEngine {
209
231
  return Result{}, err
210
232
  }
@@ -6,7 +6,9 @@ package team
6
6
  // · 计划 → 一条 notice(一行提示 + 计划文档路径),任务本身在画布上表现为
7
7
  // 后续的委派卡,不需要一张额外的「计划卡」;
8
8
  // · 每个子任务 → tool_use(name=Task) 开卡 + 带 Parent 的子事件 + tool_result 收卡;
9
- // · 降级/汇总 → notice。
9
+ // · 降级 → `fallback` 帧(2026-10-26):谁换成了谁,客户端据此把子任务标成
10
+ // 「备用作答」(以前只有一行 notice,主备到底生效没有无从判断);
11
+ // · 汇总 → notice。
10
12
  //
11
13
  // 于是客户端(三端共用同一份渲染内核)**零改动**就能画出团队模式 —— 它看起来
12
14
  // 就是主代理连续委派了几次。要加「计划总览卡」时再补新 kind,那是一次独立的
@@ -153,7 +153,11 @@ func runTask(ctx context.Context, opts Options, plan Plan, t Task, runDir string
153
153
  got.ID, got.Kind, got.Title = t.ID, t.EffectiveKind(), t.Title
154
154
  got.Role = t.Role
155
155
  got.OutputPath = OutputPath(runDir, t.ID)
156
- got.Engine, got.Model = eng.Name(), route.Model
156
+ // 引擎标签以**尝试自己记的**为准:runAttempt 在备用接上时已改成备用那一路,
157
+ // 这里若再盖回主引擎,用户就看不到「这一行是备用给的」。
158
+ if got.Engine == "" {
159
+ got.Engine, got.Model = eng.Name(), route.Model
160
+ }
157
161
  got.Attempts = attempt
158
162
  r = got
159
163
 
@@ -235,7 +239,7 @@ func runAttempt(ctx context.Context, opts Options, t Task, runDir string, route
235
239
  board.setSession(t.ID, id, "")
236
240
  }
237
241
 
238
- res, err := streamer.Stream(tctx, agent.Request{
242
+ res, fbInfo, err := agent.StreamWithFallback(tctx, eng, agent.Request{
239
243
  Engine: eng.Name(),
240
244
  Model: route.Model,
241
245
  // 角色片段追加在主系统提示词之后(见 role.go);未设角色时与原来一致。
@@ -249,7 +253,11 @@ func runAttempt(ctx context.Context, opts Options, t Task, runDir string, route
249
253
  Permission: permissionFor(opts, t),
250
254
  PermissionOptions: opts.PermissionOptions,
251
255
  OnSessionID: setSID,
252
- Messages: []agent.Message{{Role: "user", Content: prompt}},
256
+ // 主引擎只有过程、没有正文时降级到这一路的备用(额度用尽 / 单模型故障)。
257
+ // 降级发生时 agent 层已经发过 fallback 帧(下面原样交给子会话)。
258
+ FallbackEngine: route.FallbackEngine,
259
+ FallbackModel: route.FallbackModel,
260
+ Messages: []agent.Message{{Role: "user", Content: prompt}},
253
261
  }, func(ev agent.StreamEvent) {
254
262
  if ev.SessionID != "" {
255
263
  setSID(ev.SessionID)
@@ -270,6 +278,12 @@ func runAttempt(ctx context.Context, opts Options, t Task, runDir string, route
270
278
  }
271
279
  board.touch(t.ID, ev)
272
280
  })
281
+ /* 备用接上了:任务结果如实记成「谁给出的答案」(主备没生效时用户只能看到一个
282
+ 失败的子任务,分不清是没配还是配了没试)。只改展示字段,State 由下面的
283
+ err 决定 —— 备用成功时 err 必然为 nil。 */
284
+ if fbInfo.Used {
285
+ r.Engine, r.Model = fbInfo.Engine, fbInfo.Model
286
+ }
273
287
 
274
288
  if err != nil {
275
289
  r.State = TaskFailed
@@ -396,8 +410,7 @@ func completeTask(ctx context.Context, opts Options, eng agent.Engine, route Rou
396
410
  r.Engine, r.Model = eng.Name(), route.Model
397
411
  rctx, cancel := context.WithTimeout(ctx, taskTimeout(opts))
398
412
  defer cancel()
399
- runner := &agent.Runner{Engine: eng, Timeout: taskTimeout(opts)}
400
- resp, err := runner.Run(rctx, agent.Request{
413
+ resp, _, err := agent.RunWithFallback(rctx, eng, agent.Request{
401
414
  Engine: eng.Name(),
402
415
  Model: route.Model,
403
416
  SystemPrompt: systemPromptFor(opts, t),
@@ -406,8 +419,10 @@ func completeTask(ctx context.Context, opts Options, eng agent.Engine, route Rou
406
419
  Reasoning: opts.Reasoning,
407
420
  Permission: permissionFor(opts, t),
408
421
  PermissionOptions: opts.PermissionOptions,
422
+ FallbackEngine: route.FallbackEngine,
423
+ FallbackModel: route.FallbackModel,
409
424
  Messages: []agent.Message{{Role: "user", Content: prompt}},
410
- })
425
+ }, agent.RunOptions{Timeout: taskTimeout(opts)}, nil)
411
426
  r.SessionID = resp.SessionID
412
427
  r.Text = resp.Text
413
428
  collectDeliverables(TaskDir(runDir, t.ID), t.Deliverables, &r)