magic-agent 2.4.2 → 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 (156) hide show
  1. package/CLIENT-CONTRACT.md +290 -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 +7 -10
  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 +193 -7
  105. package/internal/client/lan_test.go +110 -0
  106. package/internal/client/quotas_merge_test.go +107 -0
  107. package/internal/client/requirements.go +198 -1
  108. package/internal/client/requirements_shots_test.go +200 -0
  109. package/internal/client/restart.go +134 -0
  110. package/internal/client/restart_test.go +151 -0
  111. package/internal/client/scripts.go +1047 -0
  112. package/internal/client/scripts_decode_other.go +27 -0
  113. package/internal/client/scripts_decode_test.go +125 -0
  114. package/internal/client/scripts_decode_windows.go +206 -0
  115. package/internal/client/scripts_test.go +726 -0
  116. package/internal/client/service.go +322 -18
  117. package/internal/client/service_test.go +51 -0
  118. package/internal/client/suggest.go +105 -2
  119. package/internal/client/suggest_test.go +86 -0
  120. package/internal/client/team_flags.go +25 -11
  121. package/internal/client/team_flags_test.go +39 -0
  122. package/internal/requirements/bootstrap.go +16 -0
  123. package/internal/requirements/consolidate.go +100 -9
  124. package/internal/requirements/doc.go +154 -3
  125. package/internal/requirements/graph.go +138 -0
  126. package/internal/requirements/memory.go +7 -0
  127. package/internal/requirements/modules.go +312 -0
  128. package/internal/requirements/modules_test.go +373 -0
  129. package/internal/requirements/relations.go +155 -0
  130. package/internal/requirements/relations_test.go +231 -0
  131. package/internal/requirements/requirements.go +10 -0
  132. package/internal/requirements/shots.go +418 -0
  133. package/internal/requirements/shots_test.go +387 -0
  134. package/internal/session/session.go +92 -0
  135. package/internal/session/sessionlog.go +23 -0
  136. package/internal/session/sessionlog_read_test.go +39 -0
  137. package/internal/suggest/suggest.go +23 -1
  138. package/internal/team/event.go +3 -1
  139. package/internal/team/execute.go +21 -6
  140. package/internal/team/fallback_route_test.go +80 -0
  141. package/internal/team/planner.go +15 -3
  142. package/internal/team/route.go +89 -17
  143. package/internal/team/team.go +18 -4
  144. package/npm/apk/magic-agent-mobile-release.apk +0 -0
  145. package/npm/apk/version.json +1 -5
  146. package/npm/dist/darwin-arm64/magic-agent +0 -0
  147. package/npm/dist/darwin-arm64/magic-client +0 -0
  148. package/npm/dist/darwin-x64/magic-agent +0 -0
  149. package/npm/dist/darwin-x64/magic-client +0 -0
  150. package/npm/dist/linux-arm64/magic-agent +0 -0
  151. package/npm/dist/linux-arm64/magic-client +0 -0
  152. package/npm/dist/linux-x64/magic-agent +0 -0
  153. package/npm/dist/linux-x64/magic-client +0 -0
  154. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  155. package/npm/dist/win32-x64/magic-client.exe +0 -0
  156. package/package.json +8 -2
@@ -0,0 +1,373 @@
1
+ package requirements
2
+
3
+ // modules_test.go - 模块去重(合并「表达同一件事」的已有模块)的代码侧验证。
4
+ //
5
+ // 模型那一侧不可控,能验的是代码这一侧:候选判对、方向归一(功能点多的幸存)、
6
+ // 链式归并、同名文档内容合并不丢、幻觉模块名不认、模块级整理绝不改模块结构、
7
+ // 手动 noop(没有新记忆)也认真清理一次。
8
+
9
+ import (
10
+ "context"
11
+ "os"
12
+ "path/filepath"
13
+ "strings"
14
+ "testing"
15
+ "time"
16
+
17
+ "github.com/darren/magic-agent/internal/agent"
18
+ "github.com/darren/magic-agent/internal/session"
19
+ )
20
+
21
+ // writeFeatureDoc 直接落一篇功能点文档(造模块结构用,不走模型)。
22
+ func writeFeatureDoc(t *testing.T, id, module, name string, points ...string) {
23
+ t.Helper()
24
+ dir := filepath.Join(featuresDir(id), module)
25
+ if err := os.MkdirAll(dir, 0o700); err != nil {
26
+ t.Fatal(err)
27
+ }
28
+ doc := Doc{Desc: name + "的描述", Points: points}
29
+ if err := os.WriteFile(filepath.Join(dir, name+".md"), []byte(RenderDoc(name, doc)), 0o600); err != nil {
30
+ t.Fatal(err)
31
+ }
32
+ }
33
+
34
+ func TestSimilarModulePairs(t *testing.T) {
35
+ got := similarModulePairs([]string{"引擎管理", "引擎调用", "任务执行"})
36
+ if len(got) != 1 || got[0] != [2]string{"引擎管理", "引擎调用"} {
37
+ t.Errorf("共享字应判候选,实际 %v", got)
38
+ }
39
+ if got := similarModulePairs([]string{"配置", "配置管理"}); len(got) != 1 {
40
+ t.Errorf("互为子串应判候选,实际 %v", got)
41
+ }
42
+ if got := similarModulePairs([]string{"任务执行", "浏览器控制"}); len(got) != 0 {
43
+ t.Errorf("不相交不该判候选,实际 %v", got)
44
+ }
45
+ }
46
+
47
+ func TestModuleDedupeWanted(t *testing.T) {
48
+ mods := []string{"引擎管理", "引擎调用"}
49
+ cases := []struct {
50
+ name string
51
+ opts Options
52
+ res Result
53
+ want bool
54
+ }{
55
+ {"手动空间级", Options{}, Result{}, true},
56
+ {"手动无新记忆(noop 也清理)", Options{}, Result{}, true},
57
+ {"模块级整理永不合并", Options{Module: "引擎管理"}, Result{}, false},
58
+ {"自动路径要本轮建了新文档", Options{Auto: true}, Result{Created: 1}, true},
59
+ {"自动路径什么都没建", Options{Auto: true}, Result{}, false},
60
+ }
61
+ for _, c := range cases {
62
+ if got := moduleDedupeWanted(c.opts, c.res, mods); got != c.want {
63
+ t.Errorf("%s: 得 %v 想 %v", c.name, got, c.want)
64
+ }
65
+ }
66
+ if moduleDedupeWanted(Options{}, Result{}, []string{"登录"}) {
67
+ t.Error("单模块不该触发")
68
+ }
69
+ if moduleDedupeWanted(Options{}, Result{}, []string{"任务执行", "浏览器控制"}) {
70
+ t.Error("无确定性候选不该触发(不为干净清单烧调用)")
71
+ }
72
+ }
73
+
74
+ // TestDedupeModulesMergesSynonym 相似模块 → 并入功能点更多的那个,文档整体搬走 + 记合并记录。
75
+ func TestDedupeModulesMergesSynonym(t *testing.T) {
76
+ root := testEnv(t)
77
+ id := DirID(filepath.Join(root, "proj-merge"))
78
+ writeFeatureDoc(t, id, "引擎调用", "调用编排", "一次提问跑一个引擎")
79
+ writeFeatureDoc(t, id, "引擎管理", "引擎切换", "切换默认引擎")
80
+ writeFeatureDoc(t, id, "引擎管理", "引擎探测", "探测本机装了哪些引擎")
81
+
82
+ call := func(prompt string, schema *agent.JSONSchema) (string, error) {
83
+ if !strings.Contains(prompt, "需要合并成一个") {
84
+ t.Errorf("模块合并 prompt 形态不对: %s", prompt)
85
+ }
86
+ return `{"pairs":[{"from":"引擎调用","to":"引擎管理"}]}`, nil
87
+ }
88
+ merged := dedupeModules(id, call)
89
+ if len(merged) != 1 || merged[0] != "引擎调用 → 引擎管理" {
90
+ t.Fatalf("应合并 引擎调用→引擎管理,实际 %v", merged)
91
+ }
92
+ files := listFeatureFiles(id)
93
+ if len(files) != 3 {
94
+ t.Fatalf("三篇文档都该在,实际 %+v", files)
95
+ }
96
+ for _, f := range files {
97
+ if f.Module != "引擎管理" {
98
+ t.Errorf("文档应都在引擎管理下,实际 %q: %s", f.Module, f.Name)
99
+ }
100
+ }
101
+ if _, err := os.Stat(filepath.Join(featuresDir(id), "引擎调用")); !os.IsNotExist(err) {
102
+ t.Error("搬空后引擎调用目录应撤掉")
103
+ }
104
+ d := ParseDoc(readFile(t, filepath.Join(featuresDir(id), "引擎管理", "调用编排.md")))
105
+ if len(d.Log) != 1 || !strings.Contains(d.Log[0], "模块合并") {
106
+ t.Errorf("搬走的文档应记一行合并记录,实际 %v", d.Log)
107
+ }
108
+ }
109
+
110
+ // TestDedupeModulesMergesSameNameDoc 两个模块下同一个功能点 → 合并成一篇,内容不丢。
111
+ func TestDedupeModulesMergesSameNameDoc(t *testing.T) {
112
+ root := testEnv(t)
113
+ id := DirID(filepath.Join(root, "proj-samename"))
114
+ writeFeatureDoc(t, id, "模型配置", "默认模型", "默认用 claude")
115
+ writeFeatureDoc(t, id, "配置管理", "默认模型", "默认模型可改")
116
+
117
+ call := func(prompt string, schema *agent.JSONSchema) (string, error) {
118
+ return `{"pairs":[{"from":"模型配置","to":"配置管理"}]}`, nil
119
+ }
120
+ merged := dedupeModules(id, call)
121
+ if len(merged) != 1 || merged[0] != "模型配置 → 配置管理" {
122
+ t.Fatalf("应合并 模型配置→配置管理,实际 %v", merged)
123
+ }
124
+ files := listFeatureFiles(id)
125
+ if len(files) != 1 || files[0].Module != "配置管理" || files[0].Name != "默认模型" {
126
+ t.Fatalf("同名文档应合并成一篇,实际 %+v", files)
127
+ }
128
+ d := ParseDoc(readFile(t, files[0].File))
129
+ if len(d.Points) != 2 || d.Points[0] != "默认模型可改" || d.Points[1] != "默认用 claude" {
130
+ t.Errorf("要点应 目标在前来源在后,实际 %v", d.Points)
131
+ }
132
+ if len(d.Log) != 1 || !strings.Contains(d.Log[0], "模块合并") {
133
+ t.Errorf("合并记录应记在幸存文档里,实际 %v", d.Log)
134
+ }
135
+ }
136
+
137
+ // TestDedupeModulesChain 链式 A→B→C 都归到功能点最多的 C。
138
+ func TestDedupeModulesChain(t *testing.T) {
139
+ root := testEnv(t)
140
+ id := DirID(filepath.Join(root, "proj-chain"))
141
+ writeFeatureDoc(t, id, "会话管理", "会话列表", "看到全部会话")
142
+ writeFeatureDoc(t, id, "对话交互", "消息渲染", "渲染消息")
143
+ writeFeatureDoc(t, id, "对话管理", "消息变体", "消息有几种样式")
144
+ writeFeatureDoc(t, id, "对话管理", "消息重发", "60 秒可重发")
145
+
146
+ call := func(prompt string, schema *agent.JSONSchema) (string, error) {
147
+ // 会话管理→对话交互(数量持平听模型的)、对话交互→对话管理(1<2 不翻向)
148
+ return `{"pairs":[{"from":"会话管理","to":"对话交互"},{"from":"对话交互","to":"对话管理"}]}`, nil
149
+ }
150
+ merged := dedupeModules(id, call)
151
+ if len(merged) != 2 {
152
+ t.Fatalf("链式应合并 2 个模块,实际 %v", merged)
153
+ }
154
+ files := listFeatureFiles(id)
155
+ if len(files) != 4 {
156
+ t.Fatalf("四篇文档都该在,实际 %+v", files)
157
+ }
158
+ for _, f := range files {
159
+ if f.Module != "对话管理" {
160
+ t.Errorf("链式终点应是对话管理,实际 %q: %s", f.Module, f.Name)
161
+ }
162
+ }
163
+ }
164
+
165
+ func TestDedupeModulesKeepsDistinct(t *testing.T) {
166
+ root := testEnv(t)
167
+ id := DirID(filepath.Join(root, "proj-distinct"))
168
+ writeFeatureDoc(t, id, "内容创作", "写文案", "生成文案")
169
+ writeFeatureDoc(t, id, "团队协作", "分工", "拆子任务")
170
+
171
+ call := func(prompt string, schema *agent.JSONSchema) (string, error) {
172
+ return `{"pairs":[]}`, nil
173
+ }
174
+ if merged := dedupeModules(id, call); len(merged) != 0 {
175
+ t.Errorf("模型说没有同义时不该合并,实际 %v", merged)
176
+ }
177
+ if mods := listModules(id); len(mods) != 2 {
178
+ t.Errorf("两个模块都该留着,实际 %v", mods)
179
+ }
180
+ }
181
+
182
+ // TestDedupeModulesIgnoresInvalidPairs 模型编一个不存在的模块名 → 一律不认。
183
+ func TestDedupeModulesIgnoresInvalidPairs(t *testing.T) {
184
+ root := testEnv(t)
185
+ id := DirID(filepath.Join(root, "proj-invalid"))
186
+ writeFeatureDoc(t, id, "内容创作", "写文案", "生成文案")
187
+ writeFeatureDoc(t, id, "团队协作", "分工", "拆子任务")
188
+
189
+ call := func(prompt string, schema *agent.JSONSchema) (string, error) {
190
+ return `{"pairs":[{"from":"不存在的模块","to":"内容创作"},{"from":"团队协作","to":"幻觉模块"}]}`, nil
191
+ }
192
+ if merged := dedupeModules(id, call); len(merged) != 0 {
193
+ t.Errorf("幻觉模块名不该被合并,实际 %v", merged)
194
+ }
195
+ if mods := listModules(id); len(mods) != 2 {
196
+ t.Errorf("两个模块都该留着,实际 %v", mods)
197
+ }
198
+ }
199
+
200
+ // TestConsolidateDedupesModulesAcrossRounds 跨轮堆积的相似模块,被收尾的模块去重合并掉。
201
+ //
202
+ // 关键在假调用器分流三种 prompt:归纳(返 features)、新模块二审(放水返空)、
203
+ // 模块合并(返同义 pair)—— 二审放水后近义模块照常建出来,交给去重收尾。
204
+ func TestConsolidateDedupesModulesAcrossRounds(t *testing.T) {
205
+ root := testEnv(t)
206
+ proj := filepath.Join(root, "proj-dedupe")
207
+ if err := os.MkdirAll(proj, 0o700); err != nil {
208
+ t.Fatal(err)
209
+ }
210
+ writeSessionLog(t, session.Dir(), "s-dedupe", proj, "引擎切换加上", "调用时带上超时")
211
+ id := DirID(proj)
212
+ // 已初始化 + 游标 0:跳过目录初始化,直接走会话增量
213
+ if err := writeState(id, state{BootstrapAt: time.Now().UnixMilli(), Sessions: map[string]memCursor{}}); err != nil {
214
+ t.Fatal(err)
215
+ }
216
+
217
+ old := newCaller
218
+ defer func() { newCaller = old }()
219
+ round := 0
220
+ newCaller = func(ctx context.Context, engineName, model string, timeout time.Duration) (llmCall, error) {
221
+ return func(prompt string, schema *agent.JSONSchema) (string, error) {
222
+ switch {
223
+ case strings.Contains(prompt, "== 归纳规则 =="):
224
+ round++
225
+ if round == 1 {
226
+ return `{"features":[{"module":"引擎管理","name":"引擎切换","desc":"用户切换默认引擎","points":["可切换默认引擎"],"sources":[1]}]}`, nil
227
+ }
228
+ return `{"features":[{"module":"引擎调用","name":"调用超时","desc":"用户设置调用超时","points":["可设置调用超时"],"sources":[3]}]}`, nil
229
+ case strings.Contains(prompt, "本轮新增模块"):
230
+ return `{"pairs":[]}`, nil // 二审放水:模拟模型没判出来
231
+ default:
232
+ return `{"pairs":[{"from":"引擎调用","to":"引擎管理"}]}`, nil
233
+ }
234
+ }, nil
235
+ }
236
+
237
+ // 第一轮:只建出「引擎管理」一个模块,不够去重门槛。
238
+ res, err := Consolidate(context.Background(), proj, Options{})
239
+ if err != nil {
240
+ t.Fatalf("第一轮归纳失败: %v", err)
241
+ }
242
+ if res.Created != 1 || res.Merged != 0 {
243
+ t.Fatalf("第一轮只该新建 1 篇、不合并,实际 %+v", res)
244
+ }
245
+ if mods := listModules(id); len(mods) != 1 || mods[0] != "引擎管理" {
246
+ t.Fatalf("第一轮后应只有引擎管理,实际 %v", mods)
247
+ }
248
+
249
+ // 追加一条新记忆 → 第二轮建出「引擎调用」(二审放水),收尾去重把它并进引擎管理。
250
+ ev, err := os.OpenFile(filepath.Join(session.Dir(), "s-dedupe.jsonl"), os.O_APPEND|os.O_WRONLY, 0o600)
251
+ if err != nil {
252
+ t.Fatal(err)
253
+ }
254
+ defer func() { _ = ev.Close() }()
255
+ line := `{"seq":100,"kind":"user","text":"调用默认引擎带上超时","at":"2026-10-25T10:00:00Z","session_id":"s-dedupe"}` + "\n"
256
+ if _, err := ev.WriteString(line); err != nil {
257
+ t.Fatal(err)
258
+ }
259
+
260
+ res2, err := Consolidate(context.Background(), proj, Options{})
261
+ if err != nil {
262
+ t.Fatalf("第二轮归纳失败: %v", err)
263
+ }
264
+ if res2.Created != 1 || res2.Merged != 1 {
265
+ t.Fatalf("第二轮应新建 1 篇 + 合并 1 个模块,实际 %+v", res2)
266
+ }
267
+ if len(res2.MergedModules) != 1 || res2.MergedModules[0] != "引擎调用 → 引擎管理" {
268
+ t.Errorf("合并清单不对: %v", res2.MergedModules)
269
+ }
270
+ files := listFeatureFiles(id)
271
+ if len(files) != 2 {
272
+ t.Fatalf("两篇文档都该在,实际 %+v", files)
273
+ }
274
+ for _, f := range files {
275
+ if f.Module != "引擎管理" {
276
+ t.Errorf("文档应都在引擎管理下,实际 %q: %s", f.Module, f.Name)
277
+ }
278
+ }
279
+ if _, err := os.Stat(filepath.Join(featuresDir(id), "引擎调用")); !os.IsNotExist(err) {
280
+ t.Error("搬空后引擎调用目录应撤掉")
281
+ }
282
+ }
283
+
284
+ // TestConsolidateDedupesOnIdle 没有新记忆的手动「整理」也认真清理一次(不只会说 noop)。
285
+ func TestConsolidateDedupesOnIdle(t *testing.T) {
286
+ root := testEnv(t)
287
+ proj := filepath.Join(root, "proj-idle")
288
+ if err := os.MkdirAll(proj, 0o700); err != nil {
289
+ t.Fatal(err)
290
+ }
291
+ writeSessionLog(t, session.Dir(), "s-idle", proj, "一句话")
292
+ id := DirID(proj)
293
+ writeFeatureDoc(t, id, "引擎调用", "调用编排", "一次提问跑一个引擎")
294
+ writeFeatureDoc(t, id, "引擎管理", "引擎切换", "切换默认引擎")
295
+ writeFeatureDoc(t, id, "引擎管理", "引擎探测", "探测本机装了哪些引擎")
296
+ // 已初始化 + 全部已消费:手动整理走 noop 分支
297
+ if err := writeState(id, state{BootstrapAt: time.Now().UnixMilli(), Consumed: 1, Sessions: map[string]memCursor{}}); err != nil {
298
+ t.Fatal(err)
299
+ }
300
+
301
+ old := newCaller
302
+ defer func() { newCaller = old }()
303
+ asked := 0
304
+ newCaller = func(ctx context.Context, engineName, model string, timeout time.Duration) (llmCall, error) {
305
+ return func(prompt string, schema *agent.JSONSchema) (string, error) {
306
+ asked++
307
+ if strings.Contains(prompt, "== 归纳规则 ==") {
308
+ t.Errorf("没有新记忆不该跑归纳 prompt")
309
+ }
310
+ return `{"pairs":[{"from":"引擎调用","to":"引擎管理"}]}`, nil
311
+ }, nil
312
+ }
313
+
314
+ res, err := Consolidate(context.Background(), proj, Options{})
315
+ if err != nil {
316
+ t.Fatalf("整理失败: %v", err)
317
+ }
318
+ if res.Noop {
319
+ t.Error("合并了模块就不算 noop(用户该看到反馈)")
320
+ }
321
+ if !res.OK || res.Merged != 1 || asked != 1 {
322
+ t.Fatalf("应只问一次模型并合并 1 个模块,实际 %+v asked=%d", res, asked)
323
+ }
324
+ files := listFeatureFiles(id)
325
+ if len(files) != 3 {
326
+ t.Fatalf("三篇文档都该在,实际 %+v", files)
327
+ }
328
+ for _, f := range files {
329
+ if f.Module != "引擎管理" {
330
+ t.Errorf("文档应都在引擎管理下,实际 %q: %s", f.Module, f.Name)
331
+ }
332
+ }
333
+ }
334
+
335
+ // TestConsolidateModuleScopeNeverMergesModules 模块级整理只动这个模块,绝不改模块结构。
336
+ func TestConsolidateModuleScopeNeverMergesModules(t *testing.T) {
337
+ root := testEnv(t)
338
+ proj := filepath.Join(root, "proj-scope-nomerge")
339
+ if err := os.MkdirAll(proj, 0o700); err != nil {
340
+ t.Fatal(err)
341
+ }
342
+ writeSessionLog(t, session.Dir(), "s-scope2", proj, "引擎切换要能按名字排序")
343
+ id := DirID(proj)
344
+ writeFeatureDoc(t, id, "引擎调用", "调用编排", "一次提问跑一个引擎")
345
+ writeFeatureDoc(t, id, "引擎管理", "引擎切换", "切换默认引擎")
346
+ writeFeatureDoc(t, id, "引擎管理", "引擎探测", "探测本机装了哪些引擎")
347
+ if err := writeState(id, state{BootstrapAt: time.Now().UnixMilli(), Sessions: map[string]memCursor{}}); err != nil {
348
+ t.Fatal(err)
349
+ }
350
+
351
+ old := newCaller
352
+ defer func() { newCaller = old }()
353
+ newCaller = func(ctx context.Context, engineName, model string, timeout time.Duration) (llmCall, error) {
354
+ return func(prompt string, schema *agent.JSONSchema) (string, error) {
355
+ if strings.Contains(prompt, "本轮新增模块") || strings.Contains(prompt, "需要合并成一个") {
356
+ t.Errorf("模块级整理不该问模块二审/合并: %s", prompt)
357
+ }
358
+ return `{"features":[{"module":"引擎管理","name":"引擎切换","points":["可按名称排序"],"sources":[1]}]}`, nil
359
+ }, nil
360
+ }
361
+
362
+ res, err := Consolidate(context.Background(), proj, Options{Module: "引擎管理"})
363
+ if err != nil {
364
+ t.Fatalf("模块级整理失败: %v", err)
365
+ }
366
+ if res.Merged != 0 {
367
+ t.Errorf("模块级整理不该合并模块,实际 %+v", res)
368
+ }
369
+ mods := listModules(id)
370
+ if !contains(mods, "引擎调用") || !contains(mods, "引擎管理") {
371
+ t.Errorf("两个模块都该原地留着,实际 %v", mods)
372
+ }
373
+ }
@@ -0,0 +1,155 @@
1
+ package requirements
2
+
3
+ import (
4
+ "bufio"
5
+ "encoding/json"
6
+ "os"
7
+ "path/filepath"
8
+ "strconv"
9
+ )
10
+
11
+ // Relation 一条「会话 ↔ 需求功能点」的关联记录。
12
+ //
13
+ // 文档的「来源对话」段只存展示文本(30 条截断、无 session id),关系会静默丢失;
14
+ // 这里按 (session, module, feature, n) 显式落一行,供图谱聚合。
15
+ // STitle 是写入当时的会话标题快照 —— 会话登记表会被清理,老会话查不到标题,
16
+ // 图谱不能依赖事后回查。
17
+ type Relation struct {
18
+ Ts int64 `json:"ts"`
19
+ Session string `json:"session"`
20
+ STitle string `json:"stitle,omitempty"`
21
+ Module string `json:"module"`
22
+ Feature string `json:"feature"`
23
+ N int `json:"n,omitempty"`
24
+ }
25
+
26
+ // relationsFile 会话↔需求关系流(追加型,与 memory.jsonl 同风格)。
27
+ func relationsFile(id string) string { return filepath.Join(wsDir(id), "relations.jsonl") }
28
+
29
+ // appendRelations 追加关系行。写入期只 append 不读不比 —— 去重统一由
30
+ // compactRelations 在归纳收尾做,别把去重逻辑散到各写入点。
31
+ func appendRelations(id string, rels []Relation) error {
32
+ if len(rels) == 0 {
33
+ return nil
34
+ }
35
+ if err := os.MkdirAll(wsDir(id), 0o700); err != nil {
36
+ return err
37
+ }
38
+ f, err := os.OpenFile(relationsFile(id), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
39
+ if err != nil {
40
+ return err
41
+ }
42
+ defer f.Close()
43
+ enc := json.NewEncoder(f)
44
+ for _, r := range rels {
45
+ if r.Session == "" || r.Feature == "" {
46
+ continue // 没有会话归属的(基线初始化)不记关系
47
+ }
48
+ if err := enc.Encode(r); err != nil {
49
+ return err
50
+ }
51
+ }
52
+ return nil
53
+ }
54
+
55
+ // readRelations 读全量关系行(单行坏了跳过,尽力而为)。
56
+ func readRelations(id string) []Relation {
57
+ f, err := os.Open(relationsFile(id))
58
+ if err != nil {
59
+ return nil
60
+ }
61
+ defer f.Close()
62
+ var out []Relation
63
+ sc := bufio.NewScanner(f)
64
+ sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
65
+ for sc.Scan() {
66
+ line := []byte(sc.Text())
67
+ if len(line) == 0 {
68
+ continue
69
+ }
70
+ var r Relation
71
+ if json.Unmarshal(line, &r) == nil && r.Session != "" && r.Feature != "" {
72
+ out = append(out, r)
73
+ }
74
+ }
75
+ return out
76
+ }
77
+
78
+ // relationKey 判重键:同一会话对同一功能点的同一条来源只记一次。
79
+ func relationKey(r Relation) string {
80
+ return r.Session + "\x00" + r.Module + "\x00" + r.Feature + "\x00" + strconv.Itoa(r.N)
81
+ }
82
+
83
+ // compactRelations 全量去重(保最后一次出现的字段值),原子重写。
84
+ func compactRelations(id string) error {
85
+ rels := readRelations(id)
86
+ if rels == nil {
87
+ return nil // 文件不存在:无事可做
88
+ }
89
+ seen := make(map[string]Relation, len(rels))
90
+ order := make([]string, 0, len(rels))
91
+ for _, r := range rels {
92
+ k := relationKey(r)
93
+ if _, ok := seen[k]; ok {
94
+ seen[k] = r // 后写覆盖前写(Ts 等取最新)
95
+ continue
96
+ }
97
+ seen[k] = r
98
+ order = append(order, k)
99
+ }
100
+ tmp := relationsFile(id) + ".tmp"
101
+ f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
102
+ if err != nil {
103
+ return err
104
+ }
105
+ enc := json.NewEncoder(f)
106
+ for _, k := range order {
107
+ if err := enc.Encode(seen[k]); err != nil {
108
+ f.Close()
109
+ os.Remove(tmp)
110
+ return err
111
+ }
112
+ }
113
+ if err := f.Close(); err != nil {
114
+ os.Remove(tmp)
115
+ return err
116
+ }
117
+ return os.Rename(tmp, relationsFile(id))
118
+ }
119
+
120
+ // rewriteRelationsModule 模块合并后把 from 的归属改写成 to ——
121
+ // 搬目录后 from 目录被删,不改写图谱里会出现悬空模块。
122
+ func rewriteRelationsModule(id, from, to string) error {
123
+ rels := readRelations(id)
124
+ if rels == nil {
125
+ return nil
126
+ }
127
+ changed := false
128
+ for i, r := range rels {
129
+ if r.Module == from {
130
+ rels[i].Module = to
131
+ changed = true
132
+ }
133
+ }
134
+ if !changed {
135
+ return nil
136
+ }
137
+ tmp := relationsFile(id) + ".tmp"
138
+ f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
139
+ if err != nil {
140
+ return err
141
+ }
142
+ enc := json.NewEncoder(f)
143
+ for _, r := range rels {
144
+ if err := enc.Encode(r); err != nil {
145
+ f.Close()
146
+ os.Remove(tmp)
147
+ return err
148
+ }
149
+ }
150
+ if err := f.Close(); err != nil {
151
+ os.Remove(tmp)
152
+ return err
153
+ }
154
+ return os.Rename(tmp, relationsFile(id))
155
+ }