magic-agent 1.0.49 → 1.0.109
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.
- package/CLIENT-CONTRACT.md +514 -7
- package/README.md +31 -2
- package/client-ui/pc/app.css +3 -41
- package/client-ui/pc/js/04-task-list.js +6 -2
- package/client-ui/pc/js/90-glue.js +23 -4
- package/client-ui/pc/js/92-composer.js +11 -4
- package/client-ui/pc/js/93-rail.js +178 -19
- package/client-ui/shared/conv/03-chat-message.js +145 -25
- package/client-ui/shared/conv/09-stream-apply.js +55 -6
- package/client-ui/shared/conv/19-attachments.js +70 -6
- package/client-ui/shared/conv/27-conv-ledger.js +18 -0
- package/client-ui/shared/conv/29-msg-variants.js +10 -0
- package/client-ui/shared/conv/README.md +6 -0
- package/client-ui/shared/conv/conv.css +77 -0
- package/client-ui/shared/conversation.css +229 -43
- package/client-ui/shared/conversation.js +1316 -131
- package/client-ui/shared/transport-http.js +164 -12
- package/client-ui-mobile/index.html +2443 -178
- package/internal/agent/capability.go +16 -2
- package/internal/agent/claude.go +3 -2
- package/internal/agent/codebuddy.go +7 -1
- package/internal/agent/codex_test.go +3 -0
- package/internal/agent/custom_models_test.go +5 -0
- package/internal/agent/dsh.go +18 -2
- package/internal/agent/dsh_test.go +53 -2
- package/internal/agent/engine_test.go +34 -19
- package/internal/agent/env_test.go +14 -9
- package/internal/agent/models_test.go +9 -0
- package/internal/agent/runcmd_test.go +24 -3
- package/internal/agent/stream.go +33 -0
- package/internal/cli/ask.go +94 -4
- package/internal/cli/contract_test.go +26 -0
- package/internal/cli/events.go +18 -7
- package/internal/cli/keepalive_test.go +69 -0
- package/internal/cli/requirements.go +119 -0
- package/internal/cli/requirements_test.go +36 -0
- package/internal/cli/root.go +56 -4
- package/internal/cli/session.go +64 -25
- package/internal/cli/sessionlog.go +76 -7
- package/internal/cli/sessionlog_test.go +58 -0
- package/internal/cli/team.go +374 -0
- package/internal/cli/team_session.go +156 -0
- package/internal/cli/team_test.go +360 -0
- package/internal/client/plugins.go +7 -4
- package/internal/client/requirements.go +309 -0
- package/internal/client/requirements_test.go +239 -0
- package/internal/client/service.go +545 -19
- package/internal/client/service_test.go +47 -2
- package/internal/client/session_events_test.go +320 -0
- package/internal/client/team_flags.go +204 -0
- package/internal/client/team_flags_test.go +254 -0
- package/internal/requirements/bootstrap.go +423 -0
- package/internal/requirements/consolidate.go +908 -0
- package/internal/requirements/doc.go +473 -0
- package/internal/requirements/memory.go +324 -0
- package/internal/requirements/requirements.go +169 -0
- package/internal/requirements/requirements_test.go +530 -0
- package/internal/requirements/store.go +289 -0
- package/internal/session/delete.go +8 -4
- package/internal/session/session.go +194 -17
- package/internal/session/session_append.go +4 -1
- package/internal/session/session_append_test.go +19 -0
- package/internal/session/session_mark_test.go +42 -3
- package/internal/session/session_test.go +43 -0
- package/internal/session/sessionlog.go +50 -34
- package/internal/session/sessionlog_read_test.go +69 -11
- package/internal/session/status.go +205 -0
- package/internal/session/status_test.go +144 -0
- package/internal/team/adaptive.go +293 -0
- package/internal/team/board.go +216 -0
- package/internal/team/event.go +28 -0
- package/internal/team/execute.go +648 -0
- package/internal/team/plan.go +903 -0
- package/internal/team/planner.go +294 -0
- package/internal/team/reduce.go +192 -0
- package/internal/team/role.go +334 -0
- package/internal/team/route.go +206 -0
- package/internal/team/schedule.go +241 -0
- package/internal/team/schedule_test.go +286 -0
- package/internal/team/subsession.go +27 -0
- package/internal/team/team.go +401 -0
- package/internal/team/team_test.go +1997 -0
- package/npm/bin/magic-client.js +0 -0
- package/npm/dist/darwin-arm64/magic-agent +0 -0
- package/npm/dist/darwin-arm64/magic-client +0 -0
- package/npm/dist/darwin-x64/magic-agent +0 -0
- package/npm/dist/darwin-x64/magic-client +0 -0
- package/npm/dist/linux-arm64/magic-agent +0 -0
- package/npm/dist/linux-arm64/magic-client +0 -0
- package/npm/dist/linux-x64/magic-agent +0 -0
- package/npm/dist/linux-x64/magic-client +0 -0
- package/npm/dist/win32-x64/magic-agent.exe +0 -0
- package/npm/dist/win32-x64/magic-client.exe +0 -0
- package/package.json +1 -1
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
package requirements
|
|
2
|
+
|
|
3
|
+
// requirements_test.go - 骨架解析 / 合并去重 / 记忆摄入幂等 / 工作区分组。
|
|
4
|
+
//
|
|
5
|
+
// 这些是「归纳」里真正容易错的部分(模型输出那一侧不可控,能验的是代码这一侧):
|
|
6
|
+
// · 占位符回吐(模型把「(暂无)」当内容)必须在解析与渲染两处都滤掉;
|
|
7
|
+
// · 需求点 / 结论保最早、来源 / 流水保最新(清单要稳定,证据要新鲜);
|
|
8
|
+
// · 记忆摄入按字节偏移续读,跑两遍不能重复入流(否则每轮都多出一批重复来源);
|
|
9
|
+
// · 同一功能点在同一轮里被输出两次,必须原地合并而不是生成 xxx-2.md。
|
|
10
|
+
|
|
11
|
+
import (
|
|
12
|
+
"context"
|
|
13
|
+
"encoding/json"
|
|
14
|
+
"os"
|
|
15
|
+
"path/filepath"
|
|
16
|
+
"strings"
|
|
17
|
+
"testing"
|
|
18
|
+
"time"
|
|
19
|
+
|
|
20
|
+
"github.com/darren/magic-agent/internal/agent"
|
|
21
|
+
"github.com/darren/magic-agent/internal/session"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
// testEnv 把需求根目录与会话目录都指向临时目录。
|
|
25
|
+
func testEnv(t *testing.T) string {
|
|
26
|
+
t.Helper()
|
|
27
|
+
root := t.TempDir()
|
|
28
|
+
t.Setenv(EnvDir, filepath.Join(root, "requirements"))
|
|
29
|
+
t.Setenv(session.EnvDir, filepath.Join(root, "sessions"))
|
|
30
|
+
return root
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
func TestParseDocPlaceholders(t *testing.T) {
|
|
34
|
+
text := `# 登录
|
|
35
|
+
## 最终版
|
|
36
|
+
(待生成)
|
|
37
|
+
|
|
38
|
+
## 需求描述
|
|
39
|
+
支持手机号登录
|
|
40
|
+
|
|
41
|
+
## 需求点
|
|
42
|
+
1. 手机号 + 验证码即可登录
|
|
43
|
+
2. (暂无)
|
|
44
|
+
|
|
45
|
+
## 已定结论
|
|
46
|
+
- (暂无)
|
|
47
|
+
|
|
48
|
+
## 来源对话
|
|
49
|
+
- [10-22 10:00] #3 想要手机号登录
|
|
50
|
+
|
|
51
|
+
## 更新记录
|
|
52
|
+
- (暂无)
|
|
53
|
+
`
|
|
54
|
+
d := ParseDoc(text)
|
|
55
|
+
if d.Final != "" {
|
|
56
|
+
t.Errorf("(待生成)应视为空,实际 %q", d.Final)
|
|
57
|
+
}
|
|
58
|
+
if len(d.Points) != 1 || d.Points[0] != "手机号 + 验证码即可登录" {
|
|
59
|
+
t.Errorf("需求点应只留 1 条,实际 %v", d.Points)
|
|
60
|
+
}
|
|
61
|
+
if len(d.Conclusions) != 0 {
|
|
62
|
+
t.Errorf("占位符结论应被丢弃,实际 %v", d.Conclusions)
|
|
63
|
+
}
|
|
64
|
+
if len(d.Sources) != 1 {
|
|
65
|
+
t.Errorf("来源应保留 1 条,实际 %v", d.Sources)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
func TestRenderDocNeverWritesPlaceholder(t *testing.T) {
|
|
70
|
+
out := RenderDoc("登录", Doc{Points: []string{"(暂无)", "要点"}, Conclusions: []string{"待定"}})
|
|
71
|
+
if strings.Contains(out, "- (暂无)") || strings.Contains(out, "1. (暂无)") {
|
|
72
|
+
t.Errorf("渲染出口仍在写占位符:\n%s", out)
|
|
73
|
+
}
|
|
74
|
+
if !strings.Contains(out, "(待生成)") {
|
|
75
|
+
t.Errorf("最终版为空时应写占位,避免半截文件:\n%s", out)
|
|
76
|
+
}
|
|
77
|
+
// 反向:再解析回来要还是那一条要点(骨架自洽)。
|
|
78
|
+
if d := ParseDoc(out); len(d.Points) != 1 || d.Points[0] != "要点" {
|
|
79
|
+
t.Errorf("渲染后再解析不一致: %+v", d)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
func TestMergeUniqueKeepsHeadAndTail(t *testing.T) {
|
|
84
|
+
head := mergeUnique([]string{"a", "b"}, []string{"c", "a"}, 2, true, nil)
|
|
85
|
+
if strings.Join(head, ",") != "a,b" {
|
|
86
|
+
t.Errorf("保留最早 + 上限 2,应得 a,b,实际 %v", head)
|
|
87
|
+
}
|
|
88
|
+
tail := mergeUnique([]string{"x1", "x2"}, []string{"x3"}, 2, false, nil)
|
|
89
|
+
if strings.Join(tail, ",") != "x2,x3" {
|
|
90
|
+
t.Errorf("保留最新 + 上限 2,应得 x2,x3,实际 %v", tail)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
func TestSourceKeyIgnoresTimestampAndNo(t *testing.T) {
|
|
95
|
+
a := sourceKey("[10-22 10:00] #3 想要手机号登录")
|
|
96
|
+
b := sourceKey("[10-23 09:00] #11 想要手机号登录")
|
|
97
|
+
if a != b || a == "" {
|
|
98
|
+
t.Errorf("同一句原话应判重(实际 %q vs %q)", a, b)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
func TestSlugify(t *testing.T) {
|
|
103
|
+
if got := SlugifyFeature("登录 / 注册"); got != "登录-注册" {
|
|
104
|
+
t.Errorf("功能点名 slug 不对: %q", got)
|
|
105
|
+
}
|
|
106
|
+
if got := SlugifyModule("需求管理模块"); got != "需求管理模块" {
|
|
107
|
+
t.Errorf("模块名应限 6 字: %q", got)
|
|
108
|
+
}
|
|
109
|
+
if got := SlugifyModule("a/b:c"); got != "abc" {
|
|
110
|
+
t.Errorf("模块名应去非法字符: %q", got)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// writeSessionLog 造一条会话日志 + 登记记录(模拟 core 跑过一轮)。
|
|
115
|
+
func writeSessionLog(t *testing.T, dir, sid, cwd string, texts ...string) {
|
|
116
|
+
t.Helper()
|
|
117
|
+
if err := os.MkdirAll(dir, 0o700); err != nil {
|
|
118
|
+
t.Fatal(err)
|
|
119
|
+
}
|
|
120
|
+
var b strings.Builder
|
|
121
|
+
for i, s := range texts {
|
|
122
|
+
ev, _ := json.Marshal(map[string]any{
|
|
123
|
+
"seq": i + 1, "kind": "user", "text": s,
|
|
124
|
+
"at": "2026-10-22T10:00:00Z", "session_id": sid,
|
|
125
|
+
})
|
|
126
|
+
b.Write(append(ev, '\n'))
|
|
127
|
+
}
|
|
128
|
+
if err := os.WriteFile(filepath.Join(dir, sid+".jsonl"), []byte(b.String()), 0o600); err != nil {
|
|
129
|
+
t.Fatal(err)
|
|
130
|
+
}
|
|
131
|
+
// 引擎侧事件(非 user)不该进记忆。
|
|
132
|
+
extra, _ := json.Marshal(map[string]any{"seq": 99, "kind": "text", "text": "助手的话"})
|
|
133
|
+
f, err := os.OpenFile(filepath.Join(dir, sid+".jsonl"), os.O_APPEND|os.O_WRONLY, 0o600)
|
|
134
|
+
if err != nil {
|
|
135
|
+
t.Fatal(err)
|
|
136
|
+
}
|
|
137
|
+
_, _ = f.Write(append(extra, '\n'))
|
|
138
|
+
_ = f.Close()
|
|
139
|
+
|
|
140
|
+
h, err := session.Begin(session.BeginOptions{SessionID: sid, Workspace: cwd, PromptHead: texts[0]})
|
|
141
|
+
if err != nil {
|
|
142
|
+
t.Fatal(err)
|
|
143
|
+
}
|
|
144
|
+
h.Finish(sid, session.StateDone)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
func TestIngestIsIdempotent(t *testing.T) {
|
|
148
|
+
root := testEnv(t)
|
|
149
|
+
cwd := filepath.Join(root, "proj")
|
|
150
|
+
if err := os.MkdirAll(cwd, 0o700); err != nil {
|
|
151
|
+
t.Fatal(err)
|
|
152
|
+
}
|
|
153
|
+
writeSessionLog(t, session.Dir(), "s-1", cwd, "第一句", "第二句")
|
|
154
|
+
|
|
155
|
+
id := DirID(cwd)
|
|
156
|
+
st := readState(id)
|
|
157
|
+
n, err := ingest(id, cwd, &st)
|
|
158
|
+
if err != nil {
|
|
159
|
+
t.Fatal(err)
|
|
160
|
+
}
|
|
161
|
+
if n != 2 {
|
|
162
|
+
t.Fatalf("首次应摄入 2 条,实际 %d", n)
|
|
163
|
+
}
|
|
164
|
+
if got := readMemory(id); len(got) != 2 {
|
|
165
|
+
t.Fatalf("记忆流应有 2 条,实际 %d", len(got))
|
|
166
|
+
}
|
|
167
|
+
// 第二遍:偏移已到文件尾 → 不该重复入流。
|
|
168
|
+
n2, err := ingest(id, cwd, &st)
|
|
169
|
+
if err != nil {
|
|
170
|
+
t.Fatal(err)
|
|
171
|
+
}
|
|
172
|
+
if n2 != 0 {
|
|
173
|
+
t.Errorf("重复摄入应得 0,实际 %d", n2)
|
|
174
|
+
}
|
|
175
|
+
if got := readMemory(id); len(got) != 2 {
|
|
176
|
+
t.Errorf("记忆流不该变长,实际 %d", len(got))
|
|
177
|
+
}
|
|
178
|
+
// 追加一句 → 只补那一条,编号接着走。
|
|
179
|
+
ev, _ := json.Marshal(map[string]any{"seq": 100, "kind": "user", "text": "第三句", "at": "2026-10-22T11:00:00Z"})
|
|
180
|
+
f, _ := os.OpenFile(filepath.Join(session.Dir(), "s-1.jsonl"), os.O_APPEND|os.O_WRONLY, 0o600)
|
|
181
|
+
_, _ = f.Write(append(ev, '\n'))
|
|
182
|
+
_ = f.Close()
|
|
183
|
+
if n3, _ := ingest(id, cwd, &st); n3 != 1 {
|
|
184
|
+
t.Errorf("追加一句应摄入 1,实际 %d", n3)
|
|
185
|
+
}
|
|
186
|
+
mem := readMemory(id)
|
|
187
|
+
if len(mem) != 3 || mem[2].Text != "第三句" || mem[2].N != 3 {
|
|
188
|
+
t.Errorf("记忆流编号应单调追加,实际 %+v", mem)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
func TestSessionDirForPrefersWorkspace(t *testing.T) {
|
|
193
|
+
root := testEnv(t)
|
|
194
|
+
proj := filepath.Join(root, "proj2")
|
|
195
|
+
if err := os.MkdirAll(proj, 0o700); err != nil {
|
|
196
|
+
t.Fatal(err)
|
|
197
|
+
}
|
|
198
|
+
// 显式 -w 优先于 core 自己的 cwd。
|
|
199
|
+
if got := sessionDirFor(session.Record{Workspace: proj, Cwd: "/tmp/somewhere"}); got != proj {
|
|
200
|
+
t.Errorf("Workspace 应优先,实际 %q", got)
|
|
201
|
+
}
|
|
202
|
+
// 没有 -w → 用 Cwd(这条会话实际跑在哪个目录)。
|
|
203
|
+
if got := sessionDirFor(session.Record{Cwd: proj}); got != proj {
|
|
204
|
+
t.Errorf("无 Workspace 时应回落到 Cwd,实际 %q", got)
|
|
205
|
+
}
|
|
206
|
+
// 两者都没有 → 空(宁可漏掉,也不塞进错的目录)。
|
|
207
|
+
if got := sessionDirFor(session.Record{}); got != "" {
|
|
208
|
+
t.Errorf("都没有时应为空,实际 %q", got)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// TestWriteFeatureItemMergesInPlace 同一功能点被输出两次 → 一篇文档、内容合并。
|
|
213
|
+
func TestWriteFeatureItemMergesInPlace(t *testing.T) {
|
|
214
|
+
root := testEnv(t)
|
|
215
|
+
id := DirID(filepath.Join(root, "proj3"))
|
|
216
|
+
merger := newDocMerger(id)
|
|
217
|
+
// sourcesOf 由代码按记忆编号回查原文(真实链路里由 Consolidate 提供)。
|
|
218
|
+
quotes := map[int]string{
|
|
219
|
+
1: "[10-22 10:00] #1 想要手机号登录",
|
|
220
|
+
2: "[10-22 11:00] #2 不要密码",
|
|
221
|
+
}
|
|
222
|
+
ctx := &writeCtx{merger: merger, kind: "归纳", sourcesOf: func(f featureOut) []string {
|
|
223
|
+
out := []string{}
|
|
224
|
+
for _, n := range f.Sources {
|
|
225
|
+
if q, ok := quotes[n]; ok {
|
|
226
|
+
out = append(out, q)
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return out
|
|
230
|
+
}}
|
|
231
|
+
|
|
232
|
+
a := writeFeatureItem(id, featureOut{
|
|
233
|
+
Module: "登录", Name: "手机号登录", Desc: "用户用手机号登录",
|
|
234
|
+
Points: []string{"手机号 + 验证码即可登录", "(暂无)"},
|
|
235
|
+
Sources: []int{1},
|
|
236
|
+
}, ctx)
|
|
237
|
+
if !a.ok || !a.created || a.points != 1 {
|
|
238
|
+
t.Fatalf("首次写入应新建 1 篇 + 1 条要点,实际 %+v", a)
|
|
239
|
+
}
|
|
240
|
+
// 第二次:同名(模型把名字写长了,会被截断到同一前缀)→ 必须原地合并。
|
|
241
|
+
b := writeFeatureItem(id, featureOut{
|
|
242
|
+
Module: "账户", Name: "手机号登录",
|
|
243
|
+
Points: []string{"手机号 + 验证码即可登录", "支持微信一键登录"},
|
|
244
|
+
Conclusions: []string{"不做密码登录"},
|
|
245
|
+
Sources: []int{2},
|
|
246
|
+
}, ctx)
|
|
247
|
+
if !b.ok || b.created {
|
|
248
|
+
t.Fatalf("同名应原地合并而不是新建,实际 %+v", b)
|
|
249
|
+
}
|
|
250
|
+
if b.points != 1 || b.conclusions != 1 {
|
|
251
|
+
t.Errorf("应新增 1 条要点 / 1 条结论,实际 %+v", b)
|
|
252
|
+
}
|
|
253
|
+
files := listFeatureFiles(id)
|
|
254
|
+
if len(files) != 1 {
|
|
255
|
+
t.Fatalf("应只有 1 篇文档,实际 %d: %+v", len(files), files)
|
|
256
|
+
}
|
|
257
|
+
if files[0].Module != "登录" {
|
|
258
|
+
t.Errorf("已有文档不搬家(模块应保持「登录」),实际 %q", files[0].Module)
|
|
259
|
+
}
|
|
260
|
+
d := ParseDoc(readFile(t, files[0].File))
|
|
261
|
+
if len(d.Points) != 2 || len(d.Conclusions) != 1 || len(d.Sources) != 2 {
|
|
262
|
+
t.Errorf("合并结果不对: %+v", d)
|
|
263
|
+
}
|
|
264
|
+
if !strings.Contains(d.Final, "用户用手机号登录") {
|
|
265
|
+
t.Errorf("最终版应由描述派生: %q", d.Final)
|
|
266
|
+
}
|
|
267
|
+
if !strings.Contains(strings.Join(d.Log, "\n"), "归纳:新增需求点 1 条") {
|
|
268
|
+
t.Errorf("更新记录应记本轮增量: %v", d.Log)
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
func readFile(t *testing.T, p string) string {
|
|
273
|
+
t.Helper()
|
|
274
|
+
b, err := os.ReadFile(p)
|
|
275
|
+
if err != nil {
|
|
276
|
+
t.Fatal(err)
|
|
277
|
+
}
|
|
278
|
+
return string(b)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
func TestParseFeaturesTolerant(t *testing.T) {
|
|
282
|
+
cases := []string{
|
|
283
|
+
`{"features":[{"module":"登录","name":"手机号","desc":"","points":["a"],"conclusions":[],"sources":[1]}]}`,
|
|
284
|
+
"```json\n{\"features\":[{\"module\":\"登录\",\"name\":\"手机号\",\"sources\":[\"#3\"]}]}\n```",
|
|
285
|
+
`{"features":[{"module":"登录","name":"手机号","sources":[1]}]}`,
|
|
286
|
+
}
|
|
287
|
+
for i, c := range cases {
|
|
288
|
+
f, err := parseFeatures(c)
|
|
289
|
+
if err != nil || len(f) != 1 {
|
|
290
|
+
t.Fatalf("case %d 解析失败: %v", i, err)
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if _, err := parseFeatures("完全不是 JSON"); err == nil {
|
|
294
|
+
t.Error("不可解析时应报错(让上轮把原因记进 last_error)")
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
func TestWorkspacesGroupsAndSorts(t *testing.T) {
|
|
299
|
+
root := testEnv(t)
|
|
300
|
+
proj := filepath.Join(root, "proj4")
|
|
301
|
+
if err := os.MkdirAll(proj, 0o700); err != nil {
|
|
302
|
+
t.Fatal(err)
|
|
303
|
+
}
|
|
304
|
+
writeSessionLog(t, session.Dir(), "s-4", proj, "给登录加个验证码")
|
|
305
|
+
|
|
306
|
+
list, err := Workspaces()
|
|
307
|
+
if err != nil {
|
|
308
|
+
t.Fatal(err)
|
|
309
|
+
}
|
|
310
|
+
id := DirID(proj)
|
|
311
|
+
var hit *Workspace
|
|
312
|
+
for i := range list {
|
|
313
|
+
if list[i].ID == id {
|
|
314
|
+
hit = &list[i]
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
if hit == nil {
|
|
318
|
+
t.Fatalf("会话侧反推的目录应出现在清单里: %+v", list)
|
|
319
|
+
}
|
|
320
|
+
if hit.Dir != proj {
|
|
321
|
+
t.Errorf("目录应归一为绝对路径,实际 %q", hit.Dir)
|
|
322
|
+
}
|
|
323
|
+
// 还没归纳过:pending 是 0(摄入发生在归纳那一轮),文档数为 0。
|
|
324
|
+
if hit.FeatureCount != 0 {
|
|
325
|
+
t.Errorf("还没归纳过不该有文档,实际 %d", hit.FeatureCount)
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// TestConsolidatePipeline 走完整链路:摄入 → prompt → 模型输出 → 合并 → 游标前进。
|
|
330
|
+
// 模型用假调用器(真实归纳的网络/CLI 由真机验收覆盖),验的是代码这一侧。
|
|
331
|
+
func TestConsolidatePipeline(t *testing.T) {
|
|
332
|
+
root := testEnv(t)
|
|
333
|
+
proj := filepath.Join(root, "proj5")
|
|
334
|
+
if err := os.MkdirAll(proj, 0o700); err != nil {
|
|
335
|
+
t.Fatal(err)
|
|
336
|
+
}
|
|
337
|
+
writeSessionLog(t, session.Dir(), "s-5", proj, "想要手机号登录", "不要密码登录")
|
|
338
|
+
// 空目录首次整理也要落初始化标记:不然后面每次点「整理」都会重扫一遍目录。
|
|
339
|
+
if list, _ := Workspaces(); len(list) > 0 {
|
|
340
|
+
for _, w := range list {
|
|
341
|
+
if w.ID == DirID(proj) && w.Initialized {
|
|
342
|
+
t.Error("还没整理过时 initialized 应为 false")
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
old := newCaller
|
|
348
|
+
defer func() { newCaller = old }()
|
|
349
|
+
calls := 0
|
|
350
|
+
newCaller = func(ctx context.Context, engineName, model string, timeout time.Duration) (llmCall, error) {
|
|
351
|
+
return func(prompt string, schema *agent.JSONSchema) (string, error) {
|
|
352
|
+
calls++
|
|
353
|
+
// 首轮:断言关键输入都进了 prompt(记忆编号 / 原文 / 工作区名)。
|
|
354
|
+
// 之后(--force 且没有新记忆)memory 段本来就是空的,不该再要求有编号。
|
|
355
|
+
if calls == 1 {
|
|
356
|
+
for _, want := range []string{"#1", "想要手机号登录", "工作区:proj5"} {
|
|
357
|
+
if !strings.Contains(prompt, want) {
|
|
358
|
+
t.Errorf("prompt 缺 %q", want)
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return `{"features":[{"module":"登录","name":"手机号登录","desc":"用户用手机号登录",
|
|
363
|
+
"points":["手机号 + 验证码即可登录"],"conclusions":["不做密码登录"],"sources":[1,2]}]}`, nil
|
|
364
|
+
}, nil
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
res, err := Consolidate(context.Background(), proj, Options{})
|
|
368
|
+
if err != nil {
|
|
369
|
+
t.Fatalf("归纳失败: %v", err)
|
|
370
|
+
}
|
|
371
|
+
if !res.OK || res.Created != 1 || res.Consumed != 2 || res.Calls != 1 {
|
|
372
|
+
t.Fatalf("结果不对: %+v", res)
|
|
373
|
+
}
|
|
374
|
+
if !res.Bootstrap {
|
|
375
|
+
t.Errorf("空目录首次整理应标记 bootstrap(目录基线为空,回落会话增量),实际 %+v", res)
|
|
376
|
+
}
|
|
377
|
+
if st := readState(res.Workspace); st.BootstrapAt == 0 {
|
|
378
|
+
t.Error("空目录首次整理也应落 bootstrap_at")
|
|
379
|
+
}
|
|
380
|
+
if res.Points != 1 || res.Conclusions != 1 {
|
|
381
|
+
t.Errorf("应记 1 要点 / 1 结论: %+v", res)
|
|
382
|
+
}
|
|
383
|
+
files := listFeatureFiles(res.Workspace)
|
|
384
|
+
if len(files) != 1 || files[0].Module != "登录" {
|
|
385
|
+
t.Fatalf("应落一篇 features/登录/手机号登录.md,实际 %+v", files)
|
|
386
|
+
}
|
|
387
|
+
d := ParseDoc(readFile(t, files[0].File))
|
|
388
|
+
if len(d.Sources) != 2 {
|
|
389
|
+
t.Errorf("来源应按 #编号回查原文得到 2 条,实际 %v", d.Sources)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// 再跑一轮:没有新记忆 → noop,不再调模型。
|
|
393
|
+
res2, err := Consolidate(context.Background(), proj, Options{})
|
|
394
|
+
if err != nil {
|
|
395
|
+
t.Fatal(err)
|
|
396
|
+
}
|
|
397
|
+
if !res2.Noop || calls != 1 {
|
|
398
|
+
t.Errorf("没有新记忆时应 noop 且不调模型,实际 %+v calls=%d", res2, calls)
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// 强制跑:允许无新记忆也调一次(手动「立即整理」),游标不动。
|
|
402
|
+
res3, err := Consolidate(context.Background(), proj, Options{Force: true})
|
|
403
|
+
if err != nil {
|
|
404
|
+
t.Fatal(err)
|
|
405
|
+
}
|
|
406
|
+
if res3.Noop || calls != 2 {
|
|
407
|
+
t.Errorf("--force 应真跑一轮,实际 %+v calls=%d", res3, calls)
|
|
408
|
+
}
|
|
409
|
+
if st := readState(res3.Workspace); st.Consumed != 2 {
|
|
410
|
+
t.Errorf("没有消费新记忆时游标不该动,实际 %d", st.Consumed)
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// TestConsolidateBootstrapsOnceThenIncremental 首次「整理」按目录反推,之后只处理新会话。
|
|
415
|
+
//
|
|
416
|
+
// 这是 2026-10-22 用户口径的代码化:
|
|
417
|
+
//
|
|
418
|
+
// · state.BootstrapAt == 0 → 扫目录、让带工具的 agent 产出基线,旧会话记为已消费;
|
|
419
|
+
// · 之后 → 只把上次游标之后的新 user 事件喂给归纳,不重放历史。
|
|
420
|
+
//
|
|
421
|
+
// 显式默认引擎不能读目录时必须明说,而不是偷偷换一个引擎。
|
|
422
|
+
func TestBootstrapRejectsNonWorkspaceDefaultEngine(t *testing.T) {
|
|
423
|
+
_, err := pickBootstrapEngine("llm")
|
|
424
|
+
if err == nil || !strings.Contains(err.Error(), "不能读目录") {
|
|
425
|
+
t.Fatalf("默认引擎 llm 应被拒绝并说明原因,实际 %v", err)
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
func TestConsolidateBootstrapsOnceThenIncremental(t *testing.T) {
|
|
430
|
+
root := testEnv(t)
|
|
431
|
+
proj := filepath.Join(root, "proj-bootstrap")
|
|
432
|
+
if err := os.MkdirAll(proj, 0o700); err != nil {
|
|
433
|
+
t.Fatal(err)
|
|
434
|
+
}
|
|
435
|
+
if err := os.WriteFile(filepath.Join(proj, "README.md"), []byte("# 图书管理\n用户可以借书、还书。\n"), 0o600); err != nil {
|
|
436
|
+
t.Fatal(err)
|
|
437
|
+
}
|
|
438
|
+
writeSessionLog(t, session.Dir(), "s-boot", proj, "旧会话:想借书", "旧会话:想还书")
|
|
439
|
+
|
|
440
|
+
oldPick, oldRun := bootstrapEnginePicker, runBootstrapAgent
|
|
441
|
+
defer func() { bootstrapEnginePicker, runBootstrapAgent = oldPick, oldRun }()
|
|
442
|
+
bootstrapEnginePicker = func(string) (string, error) { return "claude", nil }
|
|
443
|
+
var bootPrompt, bootDir string
|
|
444
|
+
runBootstrapAgent = func(ctx context.Context, engineName, model, dir, prompt string, timeout time.Duration) (string, error) {
|
|
445
|
+
bootPrompt, bootDir = prompt, dir
|
|
446
|
+
return `{"features":[{"module":"借阅","name":"借书还书","desc":"用户借书和还书",
|
|
447
|
+
"points":["扫码借书","到期提醒"],"conclusions":[]}]}`, nil
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// 初始化这一轮绝不能落到纯文本归纳调用上(那会凭空编需求)。
|
|
451
|
+
oldCaller := newCaller
|
|
452
|
+
defer func() { newCaller = oldCaller }()
|
|
453
|
+
newCaller = func(ctx context.Context, engineName, model string, timeout time.Duration) (llmCall, error) {
|
|
454
|
+
t.Fatal("首次初始化不应调用文本归纳器")
|
|
455
|
+
return nil, nil
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
res, err := Consolidate(context.Background(), proj, Options{})
|
|
459
|
+
if err != nil {
|
|
460
|
+
t.Fatalf("首次初始化失败: %v", err)
|
|
461
|
+
}
|
|
462
|
+
if !res.OK || !res.Bootstrap || res.BootstrapEngine != "claude" {
|
|
463
|
+
t.Fatalf("首次应走目录初始化,实际 %+v", res)
|
|
464
|
+
}
|
|
465
|
+
if res.Created != 1 || res.Points != 2 {
|
|
466
|
+
t.Errorf("应新建 1 篇 / 2 条需求点,实际 %+v", res)
|
|
467
|
+
}
|
|
468
|
+
if bootDir != proj {
|
|
469
|
+
t.Errorf("初始化必须在工作区目录里读代码,实际 %q", bootDir)
|
|
470
|
+
}
|
|
471
|
+
for _, want := range []string{"图书管理", "用户可以借书、还书", "反推"} {
|
|
472
|
+
if !strings.Contains(bootPrompt, want) {
|
|
473
|
+
t.Errorf("初始化 prompt 缺 %q", want)
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
files := listFeatureFiles(res.Workspace)
|
|
477
|
+
if len(files) != 1 || files[0].Name != "借书还书" {
|
|
478
|
+
t.Fatalf("应落一篇目录基线文档,实际 %+v", files)
|
|
479
|
+
}
|
|
480
|
+
st := readState(res.Workspace)
|
|
481
|
+
if st.BootstrapAt == 0 {
|
|
482
|
+
t.Error("初始化完成后应记 BootstrapAt")
|
|
483
|
+
}
|
|
484
|
+
if st.Consumed != 2 {
|
|
485
|
+
t.Errorf("旧会话应记为已消费(目录基线覆盖),实际 consumed=%d", st.Consumed)
|
|
486
|
+
}
|
|
487
|
+
wl, _ := Workspaces()
|
|
488
|
+
for _, w := range wl {
|
|
489
|
+
if w.ID == res.Workspace && !w.Initialized {
|
|
490
|
+
t.Error("清单里的 initialized 应为 true")
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// 初始化之后追加一条新会话:只处理这一条,prompt 里不应再出现旧会话原话。
|
|
495
|
+
ev, _ := json.Marshal(map[string]any{
|
|
496
|
+
"seq": 100, "kind": "user", "text": "新会话:想加预约功能",
|
|
497
|
+
"at": "2026-10-22T12:00:00Z", "session_id": "s-boot",
|
|
498
|
+
})
|
|
499
|
+
f, err := os.OpenFile(filepath.Join(session.Dir(), "s-boot.jsonl"), os.O_APPEND|os.O_WRONLY, 0o600)
|
|
500
|
+
if err != nil {
|
|
501
|
+
t.Fatal(err)
|
|
502
|
+
}
|
|
503
|
+
_, _ = f.Write(append(ev, '\n'))
|
|
504
|
+
_ = f.Close()
|
|
505
|
+
|
|
506
|
+
var gotPrompt string
|
|
507
|
+
newCaller = func(ctx context.Context, engineName, model string, timeout time.Duration) (llmCall, error) {
|
|
508
|
+
return func(prompt string, schema *agent.JSONSchema) (string, error) {
|
|
509
|
+
gotPrompt = prompt
|
|
510
|
+
return `{"features":[{"module":"借阅","name":"借书还书","desc":"用户借书和还书",
|
|
511
|
+
"points":["支持预约"],"conclusions":[],"sources":[3]}]}`, nil
|
|
512
|
+
}, nil
|
|
513
|
+
}
|
|
514
|
+
res2, err := Consolidate(context.Background(), proj, Options{})
|
|
515
|
+
if err != nil {
|
|
516
|
+
t.Fatalf("增量整理失败: %v", err)
|
|
517
|
+
}
|
|
518
|
+
if res2.Bootstrap {
|
|
519
|
+
t.Error("已初始化后不应再次反推目录")
|
|
520
|
+
}
|
|
521
|
+
if res2.Consumed != 1 {
|
|
522
|
+
t.Errorf("只应消费新增的 1 条记忆,实际 %d", res2.Consumed)
|
|
523
|
+
}
|
|
524
|
+
if !strings.Contains(gotPrompt, "新会话:想加预约功能") {
|
|
525
|
+
t.Errorf("增量 prompt 应包含新会话,实际:%s", gotPrompt)
|
|
526
|
+
}
|
|
527
|
+
if strings.Contains(gotPrompt, "旧会话:想还书") {
|
|
528
|
+
t.Errorf("增量 prompt 不应重放已初始化前的旧会话,实际:%s", gotPrompt)
|
|
529
|
+
}
|
|
530
|
+
}
|