magic-agent 2.1.5 → 2.3.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 (52) hide show
  1. package/CLIENT-CONTRACT.md +103 -6
  2. package/client-ui/pc/app.css +3 -0
  3. package/client-ui/pc/index.html +5 -0
  4. package/client-ui/pc/js/05-task-list-render.js +28 -4
  5. package/client-ui/pc/js/07-chat-stream-render.js +19 -8
  6. package/client-ui/pc/js/93-rail.js +21 -1
  7. package/client-ui/shared/conv/03-chat-message.js +43 -0
  8. package/client-ui/shared/conv/06-ask-answer.js +30 -7
  9. package/client-ui/shared/conv/09-stream-apply.js +16 -0
  10. package/client-ui/shared/conv/19-attachments.js +75 -11
  11. package/client-ui/shared/conv/29-msg-variants.js +56 -3
  12. package/client-ui/shared/conv/README.md +6 -0
  13. package/client-ui/shared/conv/conv.css +16 -1
  14. package/client-ui/shared/conversation.css +42 -4
  15. package/client-ui/shared/conversation.js +655 -87
  16. package/client-ui/shared/transport-http.js +16 -0
  17. package/client-ui-mobile/index.html +401 -553
  18. package/client-ui-mobile/reqs.css +39 -0
  19. package/client-ui-mobile/reqs.js +507 -0
  20. package/cmd/magic-client/main.go +1 -1
  21. package/internal/cli/ask.go +6 -0
  22. package/internal/cli/root.go +5 -0
  23. package/internal/cli/session.go +16 -14
  24. package/internal/cli/suggest.go +98 -0
  25. package/internal/cli/suggest_test.go +54 -0
  26. package/internal/cli/team.go +111 -24
  27. package/internal/client/service.go +11 -1
  28. package/internal/client/suggest.go +131 -0
  29. package/internal/client/suggest_test.go +116 -0
  30. package/internal/session/delete.go +58 -11
  31. package/internal/session/delete_test.go +140 -0
  32. package/internal/session/session.go +25 -0
  33. package/internal/session/session_mark_test.go +62 -0
  34. package/internal/suggest/suggest.go +421 -0
  35. package/internal/suggest/suggest_test.go +218 -0
  36. package/internal/team/board.go +107 -15
  37. package/internal/team/execute.go +1 -1
  38. package/internal/team/team.go +28 -19
  39. package/internal/team/team_test.go +68 -15
  40. package/npm/apk/magic-agent-mobile-release.apk +0 -0
  41. package/npm/apk/version.json +3 -3
  42. package/npm/dist/darwin-arm64/magic-agent +0 -0
  43. package/npm/dist/darwin-arm64/magic-client +0 -0
  44. package/npm/dist/darwin-x64/magic-agent +0 -0
  45. package/npm/dist/darwin-x64/magic-client +0 -0
  46. package/npm/dist/linux-arm64/magic-agent +0 -0
  47. package/npm/dist/linux-arm64/magic-client +0 -0
  48. package/npm/dist/linux-x64/magic-agent +0 -0
  49. package/npm/dist/linux-x64/magic-client +0 -0
  50. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  51. package/npm/dist/win32-x64/magic-client.exe +0 -0
  52. package/package.json +5 -2
@@ -0,0 +1,421 @@
1
+ // Package suggest - 生成「下一轮快捷回复」的建议(一轮对话结束后给用户可点的下一句)。
2
+ //
3
+ // 为什么要有它(用户 2026-10-23:「在回复结束后 ai 自动生成下一轮的建议,点击即可发送」):
4
+ // 每轮回答结束后,用户往往还要再打一句「继续 / 看下 diff / 先别动」。这些句子**就在回答里**
5
+ // (AI 问「要我继续吗?」、AI 列了「下一步」),把它提炼成按钮能省一次打字 —— 但提炼必须
6
+ // 来自模型对**这一轮原文**的理解,不能套模板硬凑(模板会给出与内容无关的噪音)。
7
+ //
8
+ // 三条纪律:
9
+ // 1. **一次性旁路调用**:`agent.Runner` + `ToolsOff` + 不带 SessionID —— 独立进程、独立会话,
10
+ // 不续接也不污染用户正在聊的那条上下文;不经过 CLI 的 ask 通道,不留会话登记。
11
+ // 2. **宁缺毋滥**:模型没给出可用句子、或这轮本来就没有下一步时返回空数组,调用方据此不画按钮。
12
+ // 3. **不编造**:提示词明确禁止引入对话里没出现过的文件 / 数字 / 结论;解析侧再做一层长度、
13
+ // 去重、形态校验,挡掉「AI 的回答」冒充「用户的话」。
14
+ //
15
+ // 与 `--summarize-title`(internal/session/title.go::Summarize)的分工:那条把会话首问压成
16
+ // 标题、结果落进标题表;这条只产出一组**临时**建议,不落盘、不缓存 —— 下一轮结束就重算。
17
+ package suggest
18
+
19
+ import (
20
+ "context"
21
+ "encoding/json"
22
+ "fmt"
23
+ "regexp"
24
+ "strings"
25
+ "time"
26
+ "unicode/utf8"
27
+
28
+ "github.com/darren/magic-agent/internal/agent"
29
+ )
30
+
31
+ // 默认参数。取「短、快」:建议是锦上添花,不能让它拖住下一轮。
32
+ const (
33
+ // DefaultEngine 默认用 llm:纯文本、不带工具、没有 CLI 会话副作用(与 --title-engine 同口径)。
34
+ DefaultEngine = "llm"
35
+ // DefaultCount 最多给几条建议(UI 一行放得下 3 枚)。
36
+ DefaultCount = 3
37
+ // MaxLabelRunes 单条建议的字数上限。提示词要求 ≤16;解析时放宽到 28 仍然接受,
38
+ // 再长就不是「随手发的一句话」了,直接丢掉(不截断 —— 截断后的半句话发出去更糟)。
39
+ MaxLabelRunes = 28
40
+ // DefaultTimeout 单次旁路调用的超时。到点没有结果就当没有建议(调用方不等待)。
41
+ DefaultTimeout = 22 * time.Second
42
+ )
43
+
44
+ // Request 生成建议的输入。
45
+ type Request struct {
46
+ // User 用户最新那一句(可为空:比如本轮是系统派发的任务)。
47
+ User string
48
+ // Reply 引擎这一轮的**权威正文**(必填;空 = 没有可提炼的内容)。
49
+ Reply string
50
+ // Engine 用哪个引擎生成(空 = DefaultEngine)。
51
+ Engine string
52
+ // Model 模型标识(空 = 引擎默认)。
53
+ Model string
54
+ // Count 最多几条(<=0 = DefaultCount)。
55
+ Count int
56
+ // Timeout 单次调用超时(<=0 = DefaultTimeout)。
57
+ Timeout time.Duration
58
+ }
59
+
60
+ // Result 生成结果。
61
+ type Result struct {
62
+ Suggestions []string `json:"suggestions"`
63
+ Engine string `json:"engine,omitempty"`
64
+ Model string `json:"model,omitempty"`
65
+ ElapsedMS int64 `json:"elapsedMs,omitempty"`
66
+ }
67
+
68
+ // RequestBody 是 CLI / 插件之间传的 JSON 形状(只含对话内容,引擎走 -e/-m)。
69
+ type RequestBody struct {
70
+ User string `json:"user"`
71
+ Reply string `json:"reply"`
72
+ }
73
+
74
+ // Prompt 拼提示词。截断策略:用户那句取**开头**(它通常很短),回复取**结尾** ——
75
+ // 下一步 / 待确认的问题几乎都在最后一段,截掉尾部等于把要提炼的东西扔了。
76
+ func Prompt(user, reply string, count int) string {
77
+ if count <= 0 || count > DefaultCount {
78
+ count = DefaultCount
79
+ }
80
+ u := strings.TrimSpace(user)
81
+ if n := len([]rune(u)); n > 1500 {
82
+ u = string([]rune(u)[:1500]) + "…"
83
+ }
84
+ r := strings.TrimSpace(reply)
85
+ if n := len([]rune(r)); n > 6000 {
86
+ r = "…" + string([]rune(r)[n-6000:])
87
+ }
88
+ var b strings.Builder
89
+ b.WriteString("你是「下一句」建议器,服务于一个正在用 AI agent 干活的用户。\n")
90
+ b.WriteString("读完下面这一轮对话,给出用户此刻最可能**直接发出去**的简短指令。\n\n")
91
+ b.WriteString("硬约束:\n")
92
+ fmt.Fprintf(&b, "1. 最多 %d 条;每条 ≤ 16 个汉字,口语,像用户随手打的;不要编号、引号、句末标点。\n", count)
93
+ b.WriteString("2. 必须是**用户对 AI 说的话**,不是 AI 的回答,也不是给用户的说明。\n")
94
+ b.WriteString("3. 只依据这一轮里**已经出现**的内容(AI 问的问题、列出的下一步、给出的选项)。\n")
95
+ b.WriteString(" 不许编造对话里没有的文件、数字、结论;拿不准就不要写。\n")
96
+ b.WriteString("4. AI 在等拍板 → 给直接的肯定/否定答复;AI 列了下一步 → 挑最关键的 1-2 条;\n")
97
+ b.WriteString(" 给多条时按「最该先做」排序,**第一条就是最佳建议**(托管模式会直接发它);\n")
98
+ b.WriteString(" 这一轮已经彻底结束、没有下一步 → 给空数组。宁缺毋滥。\n")
99
+ b.WriteString("5. 只输出 JSON:{\"suggestions\":[\"…\",\"…\"]},不要解释、不要 markdown 围栏。\n")
100
+ b.WriteString("6. 这一轮如果是**交付型任务**(小说 / 故事 / 文章 / 诗 / 剧本 / 文案 / 报告 / 代码 / 图纸),\n")
101
+ b.WriteString(" 而 AI 已经把成品完整交出来、没有提问、也没有列出下一步 → **必须给空数组**。\n")
102
+ b.WriteString(" 不要建议「再来一篇」「换个题材」「再写一个」「继续写」这类**另起一篇**的话;\n")
103
+ b.WriteString(" 除非 AI 在这一轮明确问「要不要再来一篇 / 下一篇写什么」。即使用户上一句就是\n")
104
+ b.WriteString(" 「再来一篇」,只要这一篇已经交付完、没有新问题,也算结束。\n\n")
105
+ b.WriteString("例 1(交付完成 → 空):\n")
106
+ b.WriteString("用户:写一个短篇恋爱小说\n")
107
+ b.WriteString("AI:# 标题 …(完整故事,结尾收束,没有提问)\n")
108
+ b.WriteString("输出:{\"suggestions\":[]}\n\n")
109
+ b.WriteString("例 2(AI 在等拍板 → 1-2 条):\n")
110
+ b.WriteString("用户:修一下登录 bug\n")
111
+ b.WriteString("AI:修好了,测试通过。要不要我顺手把过期 token 也处理掉?\n")
112
+ b.WriteString("输出:{\"suggestions\":[\"处理过期 token\",\"先这样\"]}\n\n")
113
+ b.WriteString("用户刚才说:\n")
114
+ if u == "" {
115
+ b.WriteString("(本轮由系统派发,没有用户原话)\n")
116
+ } else {
117
+ b.WriteString(u + "\n")
118
+ }
119
+ b.WriteString("\nAI 的回复:\n")
120
+ b.WriteString(r)
121
+ b.WriteString("\n")
122
+ return b.String()
123
+ }
124
+
125
+ // Schema 结构化输出契约(llm 引擎会按它做一次 JSON 后处理;其余引擎靠提示词约束)。
126
+ func Schema() *agent.JSONSchema {
127
+ return &agent.JSONSchema{
128
+ Type: "object",
129
+ Properties: map[string]map[string]any{
130
+ "suggestions": {"type": "array", "items": map[string]any{"type": "string"}},
131
+ },
132
+ Required: []string{"suggestions"},
133
+ }
134
+ }
135
+
136
+ // Parse 从模型输出里抽建议。容忍三种形态:JSON 对象 {"suggestions":[…]}
137
+ // (带不带 ``` 围栏都行)、裸 JSON 数组、以及一眼能认出的逐行短句。
138
+ // 解析失败 / 全是废句 → 返回空切片(不是错误:没有建议是合法结果)。
139
+ func Parse(raw string, count int) []string {
140
+ if count <= 0 || count > DefaultCount {
141
+ count = DefaultCount
142
+ }
143
+ text := strings.TrimSpace(raw)
144
+ if text == "" {
145
+ return []string{}
146
+ }
147
+ if obj := firstJSONValue(text); obj != nil {
148
+ var arr []any
149
+ switch v := obj.(type) {
150
+ case map[string]any:
151
+ if a, ok := v["suggestions"].([]any); ok {
152
+ arr = a
153
+ }
154
+ case []any:
155
+ arr = v
156
+ }
157
+ if arr != nil {
158
+ return normalize(arrToStrings(arr), count)
159
+ }
160
+ }
161
+ return normalize(splitLines(text), count)
162
+ }
163
+
164
+ // Generate 跑一次旁路生成。失败时返回空建议 + error(调用方如实降级:不画按钮)。
165
+ func Generate(ctx context.Context, in Request) (Result, error) {
166
+ reply := strings.TrimSpace(in.Reply)
167
+ if reply == "" {
168
+ return Result{Suggestions: []string{}}, nil
169
+ }
170
+ name := strings.TrimSpace(in.Engine)
171
+ if name == "" {
172
+ name = DefaultEngine
173
+ }
174
+ engine := agent.Lookup(name)
175
+ if engine == nil {
176
+ return Result{}, fmt.Errorf("建议生成:未知引擎 %q", name)
177
+ }
178
+ timeout := in.Timeout
179
+ if timeout <= 0 {
180
+ timeout = DefaultTimeout
181
+ }
182
+ cctx, cancel := context.WithTimeout(ctx, timeout)
183
+ defer cancel()
184
+ start := time.Now()
185
+ runner := &agent.Runner{Engine: engine, Timeout: timeout}
186
+ resp, err := runner.Run(cctx, agent.Request{
187
+ Model: strings.TrimSpace(in.Model),
188
+ Tools: agent.ToolsOff,
189
+ Reasoning: "off", // 建议要快;思考过程只会拖长等待
190
+ MaxTokens: 300,
191
+ Messages: []agent.Message{{Role: "user", Content: Prompt(in.User, reply, in.Count)}},
192
+ JSONSchema: Schema(),
193
+ })
194
+ if err != nil {
195
+ return Result{}, err
196
+ }
197
+ items := Parse(resp.Text, in.Count)
198
+ items = dropCreativeRestarts(items, in.User, reply)
199
+ return Result{
200
+ Suggestions: items,
201
+ Engine: engine.Name(),
202
+ Model: resp.Model,
203
+ ElapsedMS: time.Since(start).Milliseconds(),
204
+ }, nil
205
+ }
206
+
207
+ // ── 交付完成后的「另起一篇」过滤(2026-10-23 用户实测)─────────────────
208
+ //
209
+ // 提示词已经要求「交付完就给空数组」,但模型仍会把「再写一篇 / 换个题材」当成下一步
210
+ // (用户截图:短篇写完,托管自动发出「再来一篇职场题材的」,又生成一篇)。
211
+ // 这一层是**确定性兜底**:只在创作类轮次里,丢掉「另起一篇」形态的建议;
212
+ // AI 明确问「要不要再来一篇 / 下一篇写什么」时放行。
213
+
214
+ var (
215
+ creativeTurnRe = regexp.MustCompile(`小说|故事|文章|短篇|长篇|诗歌?|剧本|文案|作文|题材|写一篇|再写一篇|再来一篇|创作`)
216
+ // 「再来一篇 / 再写一章 / 再画一首」:量词是篇/章/首/部,基本只用于创作。
217
+ anotherWritingRe = regexp.MustCompile(`^(再|又)?(来|写|生成|创作|画|做)(一|两)?(篇|章|首|部)`)
218
+ // 「再来个职场篇 / 再写一个短篇」:没有创作名词,但以篇/章/首/部收尾。
219
+ anotherPieceRe = regexp.MustCompile(`^(再|又)?(来|写|生成|创作|画|做)(一|个)?[^,。!?]{0,10}(篇|章|首|部)$`)
220
+ // 「再写一个校园故事 / 来个职场段子」:量词是「个/段」时必须带创作名词。
221
+ anotherCreativeRe = regexp.MustCompile(`^(再|又)?(来|写|生成|创作|画|做)(一|个|段)?[^,。!?]{0,8}(小说|故事|文章|散文|诗歌?|剧本|文案|作文|童话|段子|番外|续集|续篇)`)
222
+ switchTopicRe = regexp.MustCompile(`^(换|改)(一|个)?[^,。!?]{0,8}(题材|主题|方向|背景)`)
223
+ nextPieceRe = regexp.MustCompile(`^下一(篇|章|首|部)`)
224
+ continueWriteRe = regexp.MustCompile(`^(继续|接着)(写|创作|生成|画)`)
225
+ bareContinueRe = regexp.MustCompile(`^(继续|接着)(吧|来)?$`)
226
+ inviteAnotherRe = regexp.MustCompile(`(要不要|需要我|想不想|是否).{0,10}(再|下一|续)|要继续吗|接着(写|来)吗|下一(篇|章|个)|续写|告诉我.{0,10}(题材|主题|方向)`)
227
+ )
228
+
229
+ // looksLikeCreativeReply 识别「用户只说继续,但 AI 交的是一篇完整创作」:
230
+ // Markdown 一级标题 + 超过 400 字 + 没有代码块。技术长文也按此处理(成品交付完不该再
231
+ // 建议「另起一篇」),带代码块的工程回答则排除,避免误杀「再写一个测试」。
232
+ func looksLikeCreativeReply(reply string) bool {
233
+ r := strings.TrimSpace(reply)
234
+ if !strings.HasPrefix(r, "# ") || strings.Contains(r, "```") {
235
+ return false
236
+ }
237
+ return utf8.RuneCountInString(r) >= 400
238
+ }
239
+
240
+ // dropCreativeRestarts 把「成品交付完之后的另起一篇」过滤掉。
241
+ // 只在创作类轮次(用户这句本身是创作请求,或 AI 交了一篇完整创作)生效 ——
242
+ // 写代码时「再写一个测试」是正常下一步,不能误杀。
243
+ func dropCreativeRestarts(items []string, user, reply string) []string {
244
+ if !creativeTurnRe.MatchString(strings.TrimSpace(user)) && !looksLikeCreativeReply(reply) {
245
+ return items
246
+ }
247
+ tail := []rune(strings.TrimSpace(reply))
248
+ if len(tail) > 600 {
249
+ tail = tail[len(tail)-600:]
250
+ }
251
+ invited := inviteAnotherRe.MatchString(string(tail))
252
+ unfinished := strings.Contains(string(tail), "未完") || strings.Contains(string(tail), "待续") || strings.Contains(string(tail), "下一章")
253
+ explicitCreative := creativeTurnRe.MatchString(strings.TrimSpace(user))
254
+ out := make([]string, 0, len(items))
255
+ for _, s := range items {
256
+ t := strings.TrimSpace(s)
257
+ if t == "" {
258
+ continue
259
+ }
260
+ restart := anotherWritingRe.MatchString(t) || anotherPieceRe.MatchString(t) ||
261
+ anotherCreativeRe.MatchString(t) || switchTopicRe.MatchString(t) || nextPieceRe.MatchString(t)
262
+ if restart && !invited {
263
+ continue
264
+ }
265
+ if continueWriteRe.MatchString(t) && !invited && !unfinished {
266
+ continue
267
+ }
268
+ /* 光秃秃一句「继续」是最容易把成品续写成第二篇的:只在用户这句本身是创作请求时挡。 */
269
+ if bareContinueRe.MatchString(t) && explicitCreative && !invited && !unfinished {
270
+ continue
271
+ }
272
+ out = append(out, s)
273
+ }
274
+ return out
275
+ }
276
+
277
+ // ── 解析辅助 ──────────────────────────────────────────────────────────────
278
+
279
+ // firstJSONValue 从可能裹着围栏 / 解释文字的输出里抠出**第一个** JSON 值。
280
+ // 用 json.Decoder 逐个 '{' / '[' 试(比正则稳:能正确处理字符串里的括号)。
281
+ func firstJSONValue(s string) any {
282
+ s = stripFence(s)
283
+ for i := 0; i < len(s); i++ {
284
+ if s[i] != '{' && s[i] != '[' {
285
+ continue
286
+ }
287
+ dec := json.NewDecoder(strings.NewReader(s[i:]))
288
+ dec.UseNumber()
289
+ var v any
290
+ if err := dec.Decode(&v); err == nil {
291
+ return v
292
+ }
293
+ }
294
+ return nil
295
+ }
296
+
297
+ // dropFenced 去掉整段 ``` 围栏(含围栏里的代码):逐行兜底时不能把代码示例当建议。
298
+ func dropFenced(s string) string {
299
+ var b strings.Builder
300
+ inFence := false
301
+ for _, ln := range strings.Split(s, "\n") {
302
+ if strings.HasPrefix(strings.TrimSpace(ln), "```") {
303
+ inFence = !inFence
304
+ continue
305
+ }
306
+ if !inFence {
307
+ b.WriteString(ln)
308
+ b.WriteString("\n")
309
+ }
310
+ }
311
+ return b.String()
312
+ }
313
+
314
+ func stripFence(s string) string {
315
+ t := strings.TrimSpace(s)
316
+ if !strings.HasPrefix(t, "```") {
317
+ return t
318
+ }
319
+ if i := strings.Index(t, "\n"); i >= 0 {
320
+ t = t[i+1:]
321
+ } else {
322
+ t = strings.TrimPrefix(t, "```")
323
+ }
324
+ if i := strings.LastIndex(t, "```"); i >= 0 {
325
+ t = t[:i]
326
+ }
327
+ return strings.TrimSpace(t)
328
+ }
329
+
330
+ func arrToStrings(a []any) []string {
331
+ out := make([]string, 0, len(a))
332
+ for _, v := range a {
333
+ if s, ok := v.(string); ok {
334
+ out = append(out, s)
335
+ }
336
+ }
337
+ return out
338
+ }
339
+
340
+ // splitLines 兜底:模型没按 JSON 回(或只回了一串短句)时,按行取。
341
+ // 只保留「像一句指令」的行 —— 带冒号解释、带代码、太长的一律不要。
342
+ func splitLines(text string) []string {
343
+ var out []string
344
+ for _, ln := range strings.Split(dropFenced(text), "\n") {
345
+ ln = strings.TrimSpace(ln)
346
+ if ln == "" {
347
+ continue
348
+ }
349
+ ln = trimListPrefix(ln)
350
+ if strings.ContainsAny(ln, "`{}<>") || strings.Contains(ln, ":") || strings.Contains(ln, "://") {
351
+ continue
352
+ }
353
+ out = append(out, ln)
354
+ }
355
+ return out
356
+ }
357
+
358
+ // normalize 归一 + 校验:剥引号/列表号/句末标点,丢弃过长、多行、像 AI 回答的句子,去重截断。
359
+ func normalize(in []string, count int) []string {
360
+ out := make([]string, 0, count)
361
+ seen := make(map[string]bool, len(in))
362
+ for _, raw := range in {
363
+ s := strings.TrimSpace(raw)
364
+ s = trimListPrefix(s)
365
+ /* 引号与句末标点会互相挡着(「好的」。 里右引号在句号前面):多剥两轮。 */
366
+ for i := 0; i < 2; i++ {
367
+ s = strings.TrimRight(s, "。..!!??;;,,")
368
+ s = strings.Trim(s, " \t\r\n\"'“”‘’「」『』")
369
+ }
370
+ s = strings.TrimRight(s, "。..!!??;;,,")
371
+ s = strings.Join(strings.Fields(s), " ")
372
+ if s == "" || utf8.RuneCountInString(s) > MaxLabelRunes {
373
+ continue
374
+ }
375
+ /* 以省略号开头 = 上游被截断的半句话(见 Prompt 的截断),发出去会缺主语。 */
376
+ if strings.HasPrefix(s, "…") || strings.ContainsAny(s, "\n\r") {
377
+ continue
378
+ }
379
+ /* 「AI 的答复」冒充「用户的话」:这类开头一定不是用户要发的。 */
380
+ if strings.HasPrefix(s, "AI") || strings.HasPrefix(s, "助手") || strings.HasPrefix(s, "Agent") {
381
+ continue
382
+ }
383
+ if seen[s] {
384
+ continue
385
+ }
386
+ seen[s] = true
387
+ out = append(out, s)
388
+ if len(out) >= count {
389
+ break
390
+ }
391
+ }
392
+ return out
393
+ }
394
+
395
+ // trimListPrefix 去掉「1. / 1、/ - / • / (1)」这类列表前缀(模型偶尔会自作主张编号)。
396
+ func trimListPrefix(s string) string {
397
+ t := strings.TrimSpace(s)
398
+ for i := 0; i < 3; i++ { // 最多剥三层(如 "- 1. xxx")
399
+ prev := t
400
+ t = strings.TrimLeft(t, "-*•· ")
401
+ for _, sep := range []string{".", "、", ")", ")", ":"} {
402
+ if j := strings.Index(t, sep); j > 0 && j <= 3 {
403
+ allDigit := true
404
+ for _, r := range t[:j] {
405
+ if r < '0' || r > '9' {
406
+ allDigit = false
407
+ break
408
+ }
409
+ }
410
+ if allDigit {
411
+ t = strings.TrimSpace(t[j+len(sep):])
412
+ break
413
+ }
414
+ }
415
+ }
416
+ if t == prev {
417
+ break
418
+ }
419
+ }
420
+ return strings.TrimSpace(t)
421
+ }
@@ -0,0 +1,218 @@
1
+ package suggest
2
+
3
+ import (
4
+ "context"
5
+ "strings"
6
+ "testing"
7
+
8
+ "github.com/darren/magic-agent/internal/agent"
9
+ )
10
+
11
+ // fakeEngine 记录请求,原样回一段文本(不发网络、不起 CLI)。
12
+ type fakeEngine struct {
13
+ name string
14
+ text string
15
+ got agent.Request
16
+ }
17
+
18
+ func (f *fakeEngine) Name() string { return f.name }
19
+ func (f *fakeEngine) Detect() (bool, string) { return true, "fake://" + f.name }
20
+ func (f *fakeEngine) Complete(_ context.Context, req agent.Request) (agent.Response, error) {
21
+ f.got = req
22
+ return agent.Response{Text: f.text, Model: "fake-model"}, nil
23
+ }
24
+
25
+ func TestParseJSONObjectWithFence(t *testing.T) {
26
+ raw := "好的,这是建议:\n```json\n{\"suggestions\":[\"好的,执行吧\",\"先看一下 diff\"]}\n```\n"
27
+ got := Parse(raw, 3)
28
+ want := []string{"好的,执行吧", "先看一下 diff"}
29
+ if len(got) != len(want) {
30
+ t.Fatalf("Parse = %#v,期望 %#v", got, want)
31
+ }
32
+ for i := range want {
33
+ if got[i] != want[i] {
34
+ t.Fatalf("Parse[%d] = %q,期望 %q", i, got[i], want[i])
35
+ }
36
+ }
37
+ }
38
+
39
+ func TestParseBareArray(t *testing.T) {
40
+ got := Parse(`["继续","跑一遍测试"]`, 3)
41
+ if len(got) != 2 || got[0] != "继续" || got[1] != "跑一遍测试" {
42
+ t.Fatalf("Parse = %#v", got)
43
+ }
44
+ }
45
+
46
+ func TestParseLineFallback(t *testing.T) {
47
+ // 模型没按 JSON 回:只认「像一句指令」的短行,带解释/代码的丢掉。
48
+ raw := "1. 好的,执行吧。\n2、先看一下 diff\n- 这是解释:它改了三个文件\n```\ncode\n```"
49
+ got := Parse(raw, 3)
50
+ want := []string{"好的,执行吧", "先看一下 diff"}
51
+ if len(got) != len(want) {
52
+ t.Fatalf("Parse = %#v,期望 %#v", got, want)
53
+ }
54
+ for i := range want {
55
+ if got[i] != want[i] {
56
+ t.Fatalf("Parse[%d] = %q,期望 %q", i, got[i], want[i])
57
+ }
58
+ }
59
+ }
60
+
61
+ func TestParseRejectsNoise(t *testing.T) {
62
+ long := strings.Repeat("很", MaxLabelRunes+1)
63
+ raw := `{"suggestions":[
64
+ "继续",
65
+ "继续",
66
+ "AI 已经完成了这一步",
67
+ "助手建议你先跑测试",
68
+ "…半句话被截断了",
69
+ "` + long + `",
70
+ "好的,执行吧"
71
+ ]}`
72
+ got := Parse(raw, 3)
73
+ if len(got) != 2 || got[0] != "继续" || got[1] != "好的,执行吧" {
74
+ t.Fatalf("Parse = %#v,去重/噪声过滤不对", got)
75
+ }
76
+ if n := len(Parse(raw, 1)); n != 1 {
77
+ t.Fatalf("count=1 时给了 %d 条", n)
78
+ }
79
+ }
80
+
81
+ func TestParseEmpty(t *testing.T) {
82
+ for _, raw := range []string{"", " ", "{}", `{"suggestions":[]}`} {
83
+ if got := Parse(raw, 3); len(got) != 0 {
84
+ t.Fatalf("Parse(%q) = %#v,期望空", raw, got)
85
+ }
86
+ }
87
+ }
88
+
89
+ func TestNormalizeTrimsPunctuationAndQuotes(t *testing.T) {
90
+ got := Parse(`{"suggestions":["「好的,执行吧」。"]}`, 3)
91
+ if len(got) != 1 || got[0] != "好的,执行吧" {
92
+ t.Fatalf("Parse = %#v", got)
93
+ }
94
+ }
95
+
96
+ func TestPromptCarriesBothSides(t *testing.T) {
97
+ p := Prompt("帮我把登录页改完", "改动只在移动端静态资源。要我继续吗?", 3)
98
+ for _, want := range []string{"帮我把登录页改完", "要我继续吗?", "最多 3 条", `{"suggestions"`} {
99
+ if !strings.Contains(p, want) {
100
+ t.Fatalf("提示词缺少 %q:\n%s", want, p)
101
+ }
102
+ }
103
+ // 回复取结尾:超长时保留最后一段(下一步在那里)。
104
+ tail := strings.Repeat("前", 7000) + "最后一句:要我继续吗?"
105
+ p2 := Prompt("", tail, 3)
106
+ if !strings.Contains(p2, "最后一句:要我继续吗?") {
107
+ t.Fatalf("超长回复的结尾被截掉了")
108
+ }
109
+ }
110
+
111
+ func TestGenerateUsesToolsOffAndNoSession(t *testing.T) {
112
+ f := &fakeEngine{name: "fake-suggest", text: `{"suggestions":["好的,执行吧"]}`}
113
+ agent.Register(f) // 注册在 Engines() 首次调用之前:本包只找这个假引擎
114
+ res, err := Generate(context.Background(), Request{User: "改完了吗", Reply: "要我继续吗?", Engine: f.name})
115
+ if err != nil {
116
+ t.Fatalf("Generate: %v", err)
117
+ }
118
+ if len(res.Suggestions) != 1 || res.Suggestions[0] != "好的,执行吧" {
119
+ t.Fatalf("Suggestions = %#v", res.Suggestions)
120
+ }
121
+ if res.Engine != f.name {
122
+ t.Fatalf("Engine = %q", res.Engine)
123
+ }
124
+ if f.got.Tools == nil || !f.got.Tools.IsOff() {
125
+ t.Fatalf("建议调用必须 ToolsOff(不带工具),实际 %#v", f.got.Tools)
126
+ }
127
+ if f.got.SessionID != "" || f.got.Continue {
128
+ t.Fatalf("建议调用不能续接会话(会污染上下文):session=%q continue=%v", f.got.SessionID, f.got.Continue)
129
+ }
130
+ if f.got.JSONSchema == nil || len(f.got.JSONSchema.Required) != 1 {
131
+ t.Fatalf("缺少结构化输出契约")
132
+ }
133
+ }
134
+
135
+ func TestDropCreativeRestarts(t *testing.T) {
136
+ user := "写一个短篇恋爱小说"
137
+ complete := "# 她算得比天气预报准\n\n……林知夏把笔帽放回兜里,从此再也没咬扁过。"
138
+ cases := []struct {
139
+ name string
140
+ items []string
141
+ reply string
142
+ want []string
143
+ }{
144
+ {"另起一篇", []string{"再来一篇职场题材的", "跑一下测试"}, complete, []string{"跑一下测试"}},
145
+ {"再写一个故事", []string{"再写一个校园故事"}, complete, []string{}},
146
+ {"再来个职场篇", []string{"再来个职场篇"}, complete, []string{}},
147
+ {"换个校园题材", []string{"换个校园题材的"}, complete, []string{}},
148
+ {"下一篇", []string{"下一篇写职场"}, complete, []string{}},
149
+ {"写个番外", []string{"写个番外"}, complete, []string{}},
150
+ {"换题材", []string{"换个题材"}, complete, []string{}},
151
+ {"成品后继续写", []string{"继续写下一章"}, complete, []string{}},
152
+ {"未完待续保留", []string{"继续写下一章"}, "# 第三章\n\n……未完待续", []string{"继续写下一章"}},
153
+ {"AI 主动邀请保留", []string{"再来一篇"}, complete + "\n\n要不要我再写一篇?", []string{"再来一篇"}},
154
+ {"成品后裸继续", []string{"继续"}, complete, []string{}},
155
+ {"AI 问要继续保留", []string{"继续"}, complete + "\n\n要继续吗?", []string{"继续"}},
156
+ {"写代码不误杀", []string{"再写一个单元测试"}, "func Add() {}", []string{"再写一个单元测试"}},
157
+ }
158
+ for _, tc := range cases {
159
+ got := dropCreativeRestarts(tc.items, user, tc.reply)
160
+ if len(got) != len(tc.want) {
161
+ t.Fatalf("%s: dropCreativeRestarts = %#v,期望 %#v", tc.name, got, tc.want)
162
+ }
163
+ for i := range tc.want {
164
+ if got[i] != tc.want[i] {
165
+ t.Fatalf("%s: [%d] = %q,期望 %q", tc.name, i, got[i], tc.want[i])
166
+ }
167
+ }
168
+ }
169
+ // 非创作轮次不启用这条过滤:写代码时「再来一篇」虽怪,但不能由这里替用户判死。
170
+ if got := dropCreativeRestarts([]string{"再来一篇职场题材的"}, "修一下登录 bug", complete); len(got) != 1 {
171
+ t.Fatalf("非创作轮次被误过滤:%#v", got)
172
+ }
173
+ // 用户只说「继续」,但 AI 交的是一篇完整创作:也按创作轮次处理。
174
+ longStory := "# 长夜\n\n" + strings.Repeat("这是一段完整的故事。", 60)
175
+ if got := dropCreativeRestarts([]string{"再来一篇职场题材的"}, "继续", longStory); len(got) != 0 {
176
+ t.Fatalf("创作成品后的续篇没被过滤:%#v", got)
177
+ }
178
+ // 带代码块的工程回答不能被当成创作成品。
179
+ codeDoc := "# 修复说明\n\n```go\nfunc Add(){}\n```\n" + strings.Repeat("测试通过。", 100)
180
+ if got := dropCreativeRestarts([]string{"再写一个测试"}, "继续", codeDoc); len(got) != 1 {
181
+ t.Fatalf("工程回答被误判成创作:%#v", got)
182
+ }
183
+ }
184
+
185
+ func TestGenerateFiltersCreativeRestart(t *testing.T) {
186
+ f := &fakeEngine{name: "fake-suggest-creative", text: `{"suggestions":["再来一篇职场题材的","跑一下测试"]}`}
187
+ agent.Register(f)
188
+ res, err := Generate(context.Background(), Request{
189
+ User: "写一个短篇恋爱小说", Reply: "# 标题\n\n……故事收尾,没有提问。", Engine: f.name,
190
+ })
191
+ if err != nil {
192
+ t.Fatalf("Generate: %v", err)
193
+ }
194
+ if len(res.Suggestions) != 1 || res.Suggestions[0] != "跑一下测试" {
195
+ t.Fatalf("Suggestions = %#v,另起一篇应被过滤", res.Suggestions)
196
+ }
197
+ }
198
+
199
+ func TestPromptStopsCreativeRestart(t *testing.T) {
200
+ p := Prompt("再来一篇职场题材的", "# 甲方的那句话\n\n……沈清把合同收进抽屉。", 3)
201
+ for _, want := range []string{"交付型任务", "必须给空数组", "再来一篇", "用户上一句就是"} {
202
+ if !strings.Contains(p, want) {
203
+ t.Fatalf("提示词缺少 %q:\n%s", want, p)
204
+ }
205
+ }
206
+ }
207
+
208
+ func TestGenerateEmptyReplyShortCircuits(t *testing.T) {
209
+ f := &fakeEngine{name: "fake-suggest-empty", text: "不该被调用"}
210
+ agent.Register(f)
211
+ res, err := Generate(context.Background(), Request{Reply: " ", Engine: f.name})
212
+ if err != nil {
213
+ t.Fatalf("Generate: %v", err)
214
+ }
215
+ if len(res.Suggestions) != 0 || f.got.Messages != nil {
216
+ t.Fatalf("空正文不该调引擎:%#v %#v", res.Suggestions, f.got.Messages)
217
+ }
218
+ }