magic-agent 2.1.5 → 2.3.2

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 (78) hide show
  1. package/CLIENT-CONTRACT.md +147 -11
  2. package/README.md +10 -4
  3. package/client-ui/pc/app.css +17 -0
  4. package/client-ui/pc/index.html +5 -0
  5. package/client-ui/pc/js/05-task-list-render.js +28 -4
  6. package/client-ui/pc/js/07-chat-stream-render.js +19 -8
  7. package/client-ui/pc/js/93-rail.js +24 -2
  8. package/client-ui/shared/conv/03-chat-message.js +63 -3
  9. package/client-ui/shared/conv/06-ask-answer.js +30 -7
  10. package/client-ui/shared/conv/09-stream-apply.js +16 -0
  11. package/client-ui/shared/conv/19-attachments.js +75 -11
  12. package/client-ui/shared/conv/27-conv-ledger.js +1 -1
  13. package/client-ui/shared/conv/29-msg-variants.js +56 -3
  14. package/client-ui/shared/conv/README.md +6 -0
  15. package/client-ui/shared/conv/conv.css +33 -1
  16. package/client-ui/shared/conversation.css +42 -4
  17. package/client-ui/shared/conversation.js +801 -102
  18. package/client-ui/shared/transport-http.js +199 -38
  19. package/client-ui-mobile/index.html +731 -581
  20. package/client-ui-mobile/reqs.css +39 -0
  21. package/client-ui-mobile/reqs.js +520 -0
  22. package/cmd/magic-client/main.go +1 -1
  23. package/internal/agent/capability.go +8 -1
  24. package/internal/agent/claude.go +5 -3
  25. package/internal/agent/codebuddy.go +6 -2
  26. package/internal/agent/codex.go +7 -2
  27. package/internal/agent/dsh_sdk.go +8 -0
  28. package/internal/agent/dsh_sdk_test.go +17 -0
  29. package/internal/agent/engine.go +20 -0
  30. package/internal/agent/engine_test.go +52 -0
  31. package/internal/agent/llmengine.go +14 -0
  32. package/internal/agent/llmengine_test.go +24 -0
  33. package/internal/agent/llmhttp.go +6 -0
  34. package/internal/agent/llmhttp_test.go +27 -0
  35. package/internal/agent/llmmodels.go +48 -0
  36. package/internal/agent/permission.go +5 -0
  37. package/internal/cli/ask.go +38 -17
  38. package/internal/cli/requirements.go +2 -0
  39. package/internal/cli/root.go +5 -0
  40. package/internal/cli/session.go +16 -14
  41. package/internal/cli/sessionlog.go +5 -0
  42. package/internal/cli/sessionlog_user_test.go +49 -0
  43. package/internal/cli/suggest.go +98 -0
  44. package/internal/cli/suggest_test.go +54 -0
  45. package/internal/cli/team.go +111 -24
  46. package/internal/client/requirements.go +27 -7
  47. package/internal/client/requirements_test.go +51 -0
  48. package/internal/client/service.go +35 -3
  49. package/internal/client/service_test.go +35 -0
  50. package/internal/client/suggest.go +131 -0
  51. package/internal/client/suggest_test.go +116 -0
  52. package/internal/requirements/consolidate.go +86 -24
  53. package/internal/requirements/requirements.go +9 -0
  54. package/internal/requirements/requirements_test.go +129 -0
  55. package/internal/requirements/store.go +11 -3
  56. package/internal/session/delete.go +58 -11
  57. package/internal/session/delete_test.go +140 -0
  58. package/internal/session/session.go +25 -0
  59. package/internal/session/session_mark_test.go +62 -0
  60. package/internal/suggest/suggest.go +532 -0
  61. package/internal/suggest/suggest_test.go +268 -0
  62. package/internal/team/board.go +107 -15
  63. package/internal/team/execute.go +1 -1
  64. package/internal/team/team.go +28 -19
  65. package/internal/team/team_test.go +68 -15
  66. package/npm/apk/magic-agent-mobile-release.apk +0 -0
  67. package/npm/apk/version.json +3 -3
  68. package/npm/dist/darwin-arm64/magic-agent +0 -0
  69. package/npm/dist/darwin-arm64/magic-client +0 -0
  70. package/npm/dist/darwin-x64/magic-agent +0 -0
  71. package/npm/dist/darwin-x64/magic-client +0 -0
  72. package/npm/dist/linux-arm64/magic-agent +0 -0
  73. package/npm/dist/linux-arm64/magic-client +0 -0
  74. package/npm/dist/linux-x64/magic-agent +0 -0
  75. package/npm/dist/linux-x64/magic-client +0 -0
  76. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  77. package/npm/dist/win32-x64/magic-client.exe +0 -0
  78. package/package.json +6 -2
@@ -0,0 +1,268 @@
1
+ package suggest
2
+
3
+ import (
4
+ "context"
5
+ "os"
6
+ "path/filepath"
7
+ "strings"
8
+ "testing"
9
+
10
+ "github.com/darren/magic-agent/internal/agent"
11
+ )
12
+
13
+ // fakeEngine 记录请求,原样回一段文本(不发网络、不起 CLI)。
14
+ type fakeEngine struct {
15
+ name string
16
+ text string
17
+ got agent.Request
18
+ }
19
+
20
+ func (f *fakeEngine) Name() string { return f.name }
21
+ func (f *fakeEngine) Detect() (bool, string) { return true, "fake://" + f.name }
22
+ func (f *fakeEngine) Complete(_ context.Context, req agent.Request) (agent.Response, error) {
23
+ f.got = req
24
+ return agent.Response{Text: f.text, Model: "fake-model"}, nil
25
+ }
26
+
27
+ func TestParseJSONObjectWithFence(t *testing.T) {
28
+ raw := "好的,这是建议:\n```json\n{\"suggestions\":[\"好的,执行吧\",\"先看一下 diff\"]}\n```\n"
29
+ got := Parse(raw, 3)
30
+ want := []string{"好的,执行吧", "先看一下 diff"}
31
+ if len(got) != len(want) {
32
+ t.Fatalf("Parse = %#v,期望 %#v", got, want)
33
+ }
34
+ for i := range want {
35
+ if got[i] != want[i] {
36
+ t.Fatalf("Parse[%d] = %q,期望 %q", i, got[i], want[i])
37
+ }
38
+ }
39
+ }
40
+
41
+ func TestParseBareArray(t *testing.T) {
42
+ got := Parse(`["继续","跑一遍测试"]`, 3)
43
+ if len(got) != 2 || got[0] != "继续" || got[1] != "跑一遍测试" {
44
+ t.Fatalf("Parse = %#v", got)
45
+ }
46
+ }
47
+
48
+ func TestParseLineFallback(t *testing.T) {
49
+ // 模型没按 JSON 回:只认「像一句指令」的短行,带解释/代码的丢掉。
50
+ raw := "1. 好的,执行吧。\n2、先看一下 diff\n- 这是解释:它改了三个文件\n```\ncode\n```"
51
+ got := Parse(raw, 3)
52
+ want := []string{"好的,执行吧", "先看一下 diff"}
53
+ if len(got) != len(want) {
54
+ t.Fatalf("Parse = %#v,期望 %#v", got, want)
55
+ }
56
+ for i := range want {
57
+ if got[i] != want[i] {
58
+ t.Fatalf("Parse[%d] = %q,期望 %q", i, got[i], want[i])
59
+ }
60
+ }
61
+ }
62
+
63
+ func TestParseRejectsNoise(t *testing.T) {
64
+ long := strings.Repeat("很", MaxLabelRunes+1)
65
+ raw := `{"suggestions":[
66
+ "继续",
67
+ "继续",
68
+ "AI 已经完成了这一步",
69
+ "助手建议你先跑测试",
70
+ "…半句话被截断了",
71
+ "` + long + `",
72
+ "好的,执行吧"
73
+ ]}`
74
+ got := Parse(raw, 3)
75
+ if len(got) != 2 || got[0] != "继续" || got[1] != "好的,执行吧" {
76
+ t.Fatalf("Parse = %#v,去重/噪声过滤不对", got)
77
+ }
78
+ if n := len(Parse(raw, 1)); n != 1 {
79
+ t.Fatalf("count=1 时给了 %d 条", n)
80
+ }
81
+ }
82
+
83
+ func TestParseEmpty(t *testing.T) {
84
+ for _, raw := range []string{"", " ", "{}", `{"suggestions":[]}`} {
85
+ if got := Parse(raw, 3); len(got) != 0 {
86
+ t.Fatalf("Parse(%q) = %#v,期望空", raw, got)
87
+ }
88
+ }
89
+ }
90
+
91
+ func TestNormalizeTrimsPunctuationAndQuotes(t *testing.T) {
92
+ got := Parse(`{"suggestions":["「好的,执行吧」。"]}`, 3)
93
+ if len(got) != 1 || got[0] != "好的,执行吧" {
94
+ t.Fatalf("Parse = %#v", got)
95
+ }
96
+ }
97
+
98
+ func TestPromptCarriesBothSides(t *testing.T) {
99
+ p := Prompt("帮我把登录页改完", "改动只在移动端静态资源。要我继续吗?", 3)
100
+ for _, want := range []string{"帮我把登录页改完", "要我继续吗?", "最多 3 条", `{"suggestions"`} {
101
+ if !strings.Contains(p, want) {
102
+ t.Fatalf("提示词缺少 %q:\n%s", want, p)
103
+ }
104
+ }
105
+ // 回复取结尾:超长时保留最后一段(下一步在那里)。
106
+ tail := strings.Repeat("前", 7000) + "最后一句:要我继续吗?"
107
+ p2 := Prompt("", tail, 3)
108
+ if !strings.Contains(p2, "最后一句:要我继续吗?") {
109
+ t.Fatalf("超长回复的结尾被截掉了")
110
+ }
111
+ }
112
+
113
+ func TestGenerateUsesToolsOffAndNoSession(t *testing.T) {
114
+ f := &fakeEngine{name: "fake-suggest", text: `{"suggestions":["好的,执行吧"]}`}
115
+ agent.Register(f) // 注册在 Engines() 首次调用之前:本包只找这个假引擎
116
+ res, err := Generate(context.Background(), Request{User: "改完了吗", Reply: "要我继续吗?", Engine: f.name})
117
+ if err != nil {
118
+ t.Fatalf("Generate: %v", err)
119
+ }
120
+ if len(res.Suggestions) != 1 || res.Suggestions[0] != "好的,执行吧" {
121
+ t.Fatalf("Suggestions = %#v", res.Suggestions)
122
+ }
123
+ if res.Engine != f.name {
124
+ t.Fatalf("Engine = %q", res.Engine)
125
+ }
126
+ if f.got.Tools == nil || !f.got.Tools.IsOff() {
127
+ t.Fatalf("建议调用必须 ToolsOff(不带工具),实际 %#v", f.got.Tools)
128
+ }
129
+ if f.got.SessionID != "" || f.got.Continue {
130
+ t.Fatalf("建议调用不能续接会话(会污染上下文):session=%q continue=%v", f.got.SessionID, f.got.Continue)
131
+ }
132
+ if f.got.JSONSchema == nil || len(f.got.JSONSchema.Required) != 1 {
133
+ t.Fatalf("缺少结构化输出契约")
134
+ }
135
+ if f.got.Reasoning != "minimal" {
136
+ t.Fatalf("建议调用必须用 minimal 兜底档:%q", f.got.Reasoning)
137
+ }
138
+ if !f.got.NoThinking {
139
+ t.Fatalf("建议调用必须显式 NoThinking(claude/codebuddy/codex/dsh 各自关闭思考)")
140
+ }
141
+ }
142
+
143
+ func TestDropCreativeRestarts(t *testing.T) {
144
+ user := "写一个短篇恋爱小说"
145
+ complete := "# 她算得比天气预报准\n\n……林知夏把笔帽放回兜里,从此再也没咬扁过。"
146
+ cases := []struct {
147
+ name string
148
+ items []string
149
+ reply string
150
+ want []string
151
+ }{
152
+ {"另起一篇", []string{"再来一篇职场题材的", "跑一下测试"}, complete, []string{"跑一下测试"}},
153
+ {"再写一个故事", []string{"再写一个校园故事"}, complete, []string{}},
154
+ {"再来个职场篇", []string{"再来个职场篇"}, complete, []string{}},
155
+ {"换个校园题材", []string{"换个校园题材的"}, complete, []string{}},
156
+ {"下一篇", []string{"下一篇写职场"}, complete, []string{}},
157
+ {"写个番外", []string{"写个番外"}, complete, []string{}},
158
+ {"换题材", []string{"换个题材"}, complete, []string{}},
159
+ {"成品后继续写", []string{"继续写下一章"}, complete, []string{}},
160
+ {"未完待续保留", []string{"继续写下一章"}, "# 第三章\n\n……未完待续", []string{"继续写下一章"}},
161
+ {"AI 主动邀请保留", []string{"再来一篇"}, complete + "\n\n要不要我再写一篇?", []string{"再来一篇"}},
162
+ {"成品后裸继续", []string{"继续"}, complete, []string{}},
163
+ {"AI 问要继续保留", []string{"继续"}, complete + "\n\n要继续吗?", []string{"继续"}},
164
+ {"写代码不误杀", []string{"再写一个单元测试"}, "func Add() {}", []string{"再写一个单元测试"}},
165
+ }
166
+ for _, tc := range cases {
167
+ got := dropCreativeRestarts(tc.items, user, tc.reply)
168
+ if len(got) != len(tc.want) {
169
+ t.Fatalf("%s: dropCreativeRestarts = %#v,期望 %#v", tc.name, got, tc.want)
170
+ }
171
+ for i := range tc.want {
172
+ if got[i] != tc.want[i] {
173
+ t.Fatalf("%s: [%d] = %q,期望 %q", tc.name, i, got[i], tc.want[i])
174
+ }
175
+ }
176
+ }
177
+ // 非创作轮次不启用这条过滤:写代码时「再来一篇」虽怪,但不能由这里替用户判死。
178
+ if got := dropCreativeRestarts([]string{"再来一篇职场题材的"}, "修一下登录 bug", complete); len(got) != 1 {
179
+ t.Fatalf("非创作轮次被误过滤:%#v", got)
180
+ }
181
+ // 用户只说「继续」,但 AI 交的是一篇完整创作:也按创作轮次处理。
182
+ longStory := "# 长夜\n\n" + strings.Repeat("这是一段完整的故事。", 60)
183
+ if got := dropCreativeRestarts([]string{"再来一篇职场题材的"}, "继续", longStory); len(got) != 0 {
184
+ t.Fatalf("创作成品后的续篇没被过滤:%#v", got)
185
+ }
186
+ // 带代码块的工程回答不能被当成创作成品。
187
+ codeDoc := "# 修复说明\n\n```go\nfunc Add(){}\n```\n" + strings.Repeat("测试通过。", 100)
188
+ if got := dropCreativeRestarts([]string{"再写一个测试"}, "继续", codeDoc); len(got) != 1 {
189
+ t.Fatalf("工程回答被误判成创作:%#v", got)
190
+ }
191
+ }
192
+
193
+ func TestGenerateFiltersCreativeRestart(t *testing.T) {
194
+ f := &fakeEngine{name: "fake-suggest-creative", text: `{"suggestions":["再来一篇职场题材的","跑一下测试"]}`}
195
+ agent.Register(f)
196
+ res, err := Generate(context.Background(), Request{
197
+ User: "写一个短篇恋爱小说", Reply: "# 标题\n\n……故事收尾,没有提问。", Engine: f.name,
198
+ })
199
+ if err != nil {
200
+ t.Fatalf("Generate: %v", err)
201
+ }
202
+ if len(res.Suggestions) != 1 || res.Suggestions[0] != "跑一下测试" {
203
+ t.Fatalf("Suggestions = %#v,另起一篇应被过滤", res.Suggestions)
204
+ }
205
+ }
206
+
207
+ func TestPromptStopsCreativeRestart(t *testing.T) {
208
+ p := Prompt("再来一篇职场题材的", "# 甲方的那句话\n\n……沈清把合同收进抽屉。", 3)
209
+ for _, want := range []string{"交付型任务", "必须给空数组", "再来一篇", "用户上一句就是"} {
210
+ if !strings.Contains(p, want) {
211
+ t.Fatalf("提示词缺少 %q:\n%s", want, p)
212
+ }
213
+ }
214
+ }
215
+
216
+ func TestGenerateEmptyReplyShortCircuits(t *testing.T) {
217
+ f := &fakeEngine{name: "fake-suggest-empty", text: "不该被调用"}
218
+ agent.Register(f)
219
+ res, err := Generate(context.Background(), Request{Reply: " ", Engine: f.name})
220
+ if err != nil {
221
+ t.Fatalf("Generate: %v", err)
222
+ }
223
+ if len(res.Suggestions) != 0 || f.got.Messages != nil {
224
+ t.Fatalf("空正文不该调引擎:%#v %#v", res.Suggestions, f.got.Messages)
225
+ }
226
+ }
227
+
228
+ func TestEverySuggestionEngineUsesNoThinking(t *testing.T) {
229
+ // 官方文档确认 codebuddy 的 alwaysThinkingEnabled / dsh 的 reasoningEffort=off
230
+ // 都能关思考;建议链路不再把它们预先改道到 llm,而是让引擎自己按 NoThinking 执行。
231
+ for _, name := range []string{"llm", "claude", "codex", "codebuddy", "dsh"} {
232
+ f := &fakeEngine{name: name, text: `{"suggestions":["继续"]}`}
233
+ agent.Register(f)
234
+ res, err := Generate(context.Background(), Request{Reply: "要我继续吗?", Engine: name})
235
+ if err != nil {
236
+ t.Fatalf("%s: Generate: %v", name, err)
237
+ }
238
+ if !f.got.NoThinking || len(f.got.Messages) == 0 {
239
+ t.Fatalf("%s 必须真的执行且带 NoThinking:%#v", name, f.got)
240
+ }
241
+ if res.Engine != name {
242
+ t.Fatalf("%s: 不应预先改道,got engine %q", name, res.Engine)
243
+ }
244
+ }
245
+ }
246
+
247
+ func TestFastLLMModelPicksNoThinkVariant(t *testing.T) {
248
+ dir := t.TempDir()
249
+ p := filepath.Join(dir, "models.json")
250
+ raw := `[
251
+ {"id":"slow","model":"MiniMax-M3.1-Flash","url":"http://x/v1","apiKey":"k"},
252
+ {"id":"fast-nothink","model":"MiniMax-M3","url":"http://x/v1","apiKey":"k",
253
+ "extraBody":{"thinking":{"type":"disabled"}}}
254
+ ]`
255
+ if err := os.WriteFile(p, []byte(raw), 0o600); err != nil {
256
+ t.Fatal(err)
257
+ }
258
+ t.Setenv("MAGIC_AGENT_MODELS", p)
259
+ if got := fastLLMModel(); got != "fast-nothink" {
260
+ t.Fatalf("fastLLMModel = %q, want fast-nothink", got)
261
+ }
262
+ if got := noThinkLLMModel("slow"); got != "fast-nothink" {
263
+ t.Fatalf("noThinkLLMModel(slow) = %q, want fast-nothink(用户选了会思考的模型也要换成 no-think)", got)
264
+ }
265
+ if got := noThinkLLMModel("fast-nothink"); got != "fast-nothink" {
266
+ t.Fatalf("noThinkLLMModel(fast-nothink) = %q, want 原样保留", got)
267
+ }
268
+ }
@@ -10,6 +10,7 @@ package team
10
10
  import (
11
11
  "encoding/json"
12
12
  "fmt"
13
+ "strings"
13
14
  "sync"
14
15
  "time"
15
16
 
@@ -38,11 +39,33 @@ type TaskRow struct {
38
39
  Deps []string `json:"deps,omitempty"`
39
40
  // Note 失败 / 跳过的一句话原因(成功时为空)。
40
41
  Note string `json:"note,omitempty"`
42
+ // Active 正在跑的那一行**此刻在干什么**(「等待授权」「已 40 秒无新输出」)。
43
+ //
44
+ // 2026-10-22 用户:「子任务进度也不需要显示多行 在任务列表中显示即可」——
45
+ // 以前心跳是一条条 notice(主会话里长出十几行「仍在运行…」),现在只挂在这一行上:
46
+ // 快照每次整份重发,进度就只剩一处可看,也不占画布。只对 running 行写。
47
+ //
48
+ // ⚠️ 它是**状态说明**,不是标题:客户端画在「标题 + 状态」的次要位置
49
+ // (2026-10-22 用户:「应该显示子任务标题和状态耗时」)。标题永远取 Title。
50
+ Active string `json:"active,omitempty"`
51
+ // RuntimeMs 这一行**已经跑了多久 / 最终跑了多久**(毫秒,0 = 还没开跑)。
52
+ //
53
+ // pending 行是 0;running 行随快照整份重发不断增大;落定后冻结成总耗时
54
+ // (失败的也冻结 —— 「跑了 3 分钟然后失败」比光一个红叉有用)。由 core 给绝对值,
55
+ // 客户端不做本地计时:多端重连 / 历史回放看到的就是当时的数,不会各自跳字。
56
+ RuntimeMs int64 `json:"runtime_ms,omitempty"`
41
57
  // SessionID / RunID 这一行的子会话(点进去看过程)。RunID 在任务一开跑就有,
42
58
  // SessionID 要等引擎给出;两个都可能为空(调用方没开登记 / 引擎不给 id)。
43
59
  SessionID string `json:"session_id,omitempty"`
44
60
  RunID string `json:"run_id,omitempty"`
45
61
  Output string `json:"output,omitempty"`
62
+ // Arts 这一行**实际交付的产物**(绝对路径)。
63
+ //
64
+ // 2026-10-22 用户:「主会话产物要显示子任务的产物」—— 子任务的产物以前只出现在
65
+ // result.tasks[].arts(要等整轮结束才下发一份终态信封),主会话的画布上到那时
66
+ // 才看得到;而任务列表卡是随状态一路整份重发的,把产物挂在这一行上,某个子任务
67
+ // 一落定,主会话里立刻就能看到它交了什么东西(图 → 缩略图,文档 → 卡片)。
68
+ Arts []string `json:"arts,omitempty"`
46
69
  }
47
70
 
48
71
  // Board 任务列表的唯一写者(并发安全)。
@@ -54,13 +77,33 @@ type Board struct {
54
77
  rows map[string]*TaskRow
55
78
  // last 每一行最后一次「有动静」的时间(子事件 / 状态变化),心跳据此播报。
56
79
  last map[string]time.Time
80
+ // started 每一行开跑的时刻(RuntimeMs 的起点)。
81
+ started map[string]time.Time
57
82
  }
58
83
 
59
84
  func newBoard(emit func(agent.StreamEvent)) *Board {
60
85
  return &Board{
61
- emit: emit,
62
- rows: make(map[string]*TaskRow),
63
- last: make(map[string]time.Time),
86
+ emit: emit,
87
+ rows: make(map[string]*TaskRow),
88
+ last: make(map[string]time.Time),
89
+ started: make(map[string]time.Time),
90
+ }
91
+ }
92
+
93
+ // stampLocked 按开跑时刻刷新一行的运行时长(调用方持锁)。
94
+ //
95
+ // 每次出快照前统一刷一遍:状态没变的行也不会停在上一份快照的秒数上
96
+ // (心跳 quiet 与状态变化都会整份重发,用户看到的就是「一路在涨」)。
97
+ func (b *Board) stampLocked() {
98
+ now := time.Now()
99
+ for _, id := range b.order {
100
+ row := b.rows[id]
101
+ if row == nil || row.Status != RowRunning {
102
+ continue
103
+ }
104
+ if st, ok := b.started[id]; ok && !st.IsZero() {
105
+ row.RuntimeMs = now.Sub(st).Milliseconds()
106
+ }
64
107
  }
65
108
  }
66
109
 
@@ -81,6 +124,7 @@ func (b *Board) snapshotLocked() string {
81
124
  if len(b.order) == 0 {
82
125
  return ""
83
126
  }
127
+ b.stampLocked()
84
128
  rows := make([]TaskRow, 0, len(b.order))
85
129
  for _, id := range b.order {
86
130
  rows = append(rows, *b.rows[id])
@@ -127,7 +171,10 @@ func (b *Board) start(t Task, engine, model string) {
127
171
  row.Status = RowRunning
128
172
  row.Engine, row.Model = engine, model
129
173
  row.Note = ""
174
+ row.Active = ""
175
+ row.RuntimeMs = 0
130
176
  b.last[t.ID] = time.Now()
177
+ b.started[t.ID] = b.last[t.ID]
131
178
  })
132
179
  }
133
180
 
@@ -171,6 +218,15 @@ func (b *Board) finish(t Task, r TaskResult) {
171
218
  row.SessionID = r.SessionID
172
219
  }
173
220
  row.Output = r.OutputPath
221
+ /* 产物跟着结局一起落在这一行上:主会话画布据此立即(不必等整轮 result)
222
+ 显示子任务交了什么东西。空结果也显式清空,免得重试时留着上一次的旧产物。 */
223
+ row.Arts = r.Arts
224
+ row.Active = "" // 落定了就没有「此刻在干什么」了
225
+ /* 运行时长冻结在落定这一刻:失败 / 跳过的行同样有数 ——
226
+ 「跑了 3 分钟才失败」和「一开跑就失败」是两件事。 */
227
+ if st, ok := b.started[t.ID]; ok && !st.IsZero() {
228
+ row.RuntimeMs = time.Since(st).Milliseconds()
229
+ }
174
230
  if r.State != TaskDone {
175
231
  row.Note = oneLine(r.Reason)
176
232
  }
@@ -179,21 +235,47 @@ func (b *Board) finish(t Task, r TaskResult) {
179
235
  }
180
236
 
181
237
  // touch 子任务有事件了(刷新心跳时间;事件本身写子会话的日志,不进主会话)。
182
- func (b *Board) touch(id string) {
238
+ func (b *Board) touch(id string, ev agent.StreamEvent) {
183
239
  b.mu.Lock()
184
- defer b.mu.Unlock()
185
- if _, ok := b.rows[id]; ok {
186
- b.last[id] = time.Now()
240
+ row := b.rows[id]
241
+ if row == nil {
242
+ b.mu.Unlock()
243
+ return
244
+ }
245
+ b.last[id] = time.Now()
246
+ /* 又有输出了 → 那一行的「已 N 秒无新输出」不再成立。只改内存不发快照:
247
+ 下一条状态变化会带上;否则每条子事件都要整份广播一次,白白刷屏。 */
248
+ row.Active = ""
249
+ /* 但**卡在授权上**是另一种情况:子引擎停在那里等答复,事件停了也不会再动 —— 只写
250
+ 「已 900 秒无新输出」等于什么都没说。把「需要授权:Read(等待用户选择)」直接挂到
251
+ 这一行(它属于任务列表,不占画布)。团队模式没有作答通道(子任务不共享上下文),
252
+ 用户能做的只有去那条子会话里看 / 停掉它,所以这里只报事实。 */
253
+ var snapshot string
254
+ if ev.Kind == agent.KindAsk {
255
+ label := strings.TrimSpace(ev.Text)
256
+ if label == "" {
257
+ label = "等待用户处理"
258
+ }
259
+ row.Active = label
260
+ snapshot = b.snapshotLocked()
261
+ }
262
+ b.mu.Unlock()
263
+ if snapshot != "" {
264
+ b.emit(agent.StreamEvent{Kind: agent.KindTasks, Text: snapshot})
187
265
  }
188
266
  }
189
267
 
190
- // quietLines 返回「已经 taskQuiet 没动静」的运行中任务的播报文案,并把它们的活跃时间
191
- // 推到此刻 —— 同一条任务因此每 taskHeartbeat 才播一次,秒数持续增长。
192
- func (b *Board) quietLines() []string {
268
+ // quiet 把「已经 taskQuiet 没动静」的运行中任务的**行内进度**写上去,并把它们的
269
+ // 活跃时间推到此刻 —— 同一条任务因此每 taskHeartbeat 才回写一次,秒数持续增长。
270
+ //
271
+ // 2026-10-22 用户:「子任务进度也不需要显示多行 在任务列表中显示即可」:以前这里
272
+ // 返回一串文案,由 executeWithHeartbeat 逐条发 notice —— 主会话的画布上于是长出
273
+ // 十几行「仍在运行…(已 40 秒无新输出)」。现在只更新任务列表卡对应那一行,进度
274
+ // 只有一处可看;「有动静了」也要把 Active 清掉,否则那一行会停在最后一次心跳的秒数上。
275
+ func (b *Board) quiet() {
193
276
  b.mu.Lock()
194
- defer b.mu.Unlock()
277
+ changed := false
195
278
  now := time.Now()
196
- var out []string
197
279
  for _, id := range b.order {
198
280
  row := b.rows[id]
199
281
  if row == nil || row.Status != RowRunning {
@@ -204,10 +286,20 @@ func (b *Board) quietLines() []string {
204
286
  continue
205
287
  }
206
288
  b.last[id] = now
207
- out = append(out, fmt.Sprintf("团队模式:%s「%s」仍在运行…(已 %d 秒无新输出)",
208
- id, row.Title, int(now.Sub(last).Seconds())))
289
+ if s := fmt.Sprintf("已 %d 秒无新输出", int(now.Sub(last).Seconds())); s != row.Active {
290
+ row.Active = s
291
+ changed = true
292
+ }
293
+ }
294
+ if !changed {
295
+ b.mu.Unlock()
296
+ return
297
+ }
298
+ text := b.snapshotLocked()
299
+ b.mu.Unlock()
300
+ if text != "" {
301
+ b.emit(agent.StreamEvent{Kind: agent.KindTasks, Text: text})
209
302
  }
210
- return out
211
303
  }
212
304
 
213
305
  // notice 往主会话发一条过程提示(不属于任何子任务)。
@@ -268,7 +268,7 @@ func runAttempt(ctx context.Context, opts Options, t Task, runDir string, route
268
268
  if sub != nil {
269
269
  sub.Push(ev)
270
270
  }
271
- board.touch(t.ID)
271
+ board.touch(t.ID, ev)
272
272
  })
273
273
 
274
274
  if err != nil {
@@ -146,6 +146,12 @@ type Options struct {
146
146
 
147
147
  // OnEvent 事件出口(由 CLI 层接到 eventSink)。并发安全由调用方保证。
148
148
  OnEvent func(agent.StreamEvent)
149
+ // OnTick 计划阶段的心跳(每隔 planHeartbeat 一次),**不发事件**。
150
+ //
151
+ // 为什么要它:计划阶段是非流式调用,几十秒里一条事件都没有;这一段既不能往
152
+ // 主会话里刷「已 N 秒」(用户明确不要多行进度),又不能让别端把这一轮判成死。
153
+ // CLI 层把它接到会话登记表的续期上(见 cli/team.go)。空 = 什么都不做。
154
+ OnTick func()
149
155
 
150
156
  // OpenSubSession 为一个子任务开一条**普通会话**(登记 + 独立日志)。
151
157
  //
@@ -244,7 +250,7 @@ func Run(ctx context.Context, opts Options) (Result, error) {
244
250
  /* 计划阶段:主引擎**关工具**跑一次。
245
251
  关工具不是为了省事 —— 计划必须是可解析的结构化输出,开着工具时模型会把
246
252
  中间过程(读文件 / 跑命令)混进正文,解析成功率会掉下来。 */
247
- plan, planSession, planErr := planWithHeartbeat(ctx, opts, emit)
253
+ plan, planSession, planErr := planWithHeartbeat(ctx, opts, opts.OnTick)
248
254
  degraded := ""
249
255
  if planErr != nil {
250
256
  degraded = "计划生成失败,已降级为单任务:" + planErr.Error()
@@ -322,24 +328,30 @@ func Run(ctx context.Context, opts Options) (Result, error) {
322
328
  return res, nil
323
329
  }
324
330
 
325
- // planHeartbeat 计划阶段的进度播报间隔。
326
- const planHeartbeat = 5 * time.Second
327
-
328
- // planWithHeartbeat 跑计划阶段,等待期间定期播报「已等 N 秒」。
331
+ // planHeartbeat 计划阶段的心跳间隔。
329
332
  //
330
333
  // 计划是一次**非流式**调用(ToolsOff,要模型整段吐 JSON),实测 40 秒起步;
331
- // 这期间一条事件都没有,客户端看起来就是「发出去了没反应」。心跳不伪造进度,
332
- // 只证明 core 还活着 —— 这是「无响应」里最长、也最容易被当成挂死的一段。
334
+ // 这期间一条事件都没有,客户端看起来就是「发出去了没反应」。
335
+ // 心跳**只证明 core 还活着**,不再往主会话里发「已 N 秒」的 notice
336
+ // (2026-10-22 用户:「子任务进度也不需要显示多行 在任务列表中显示即可」):
337
+ // 计划阶段任务列表还没建出来,所以这一段的「还活着」由 core 侧续期登记表
338
+ // (cli/team.go 的 emit → Touch)证明,客户端跟随时不会被判成终态而提前收尾。
339
+ const planHeartbeat = 5 * time.Second
340
+
341
+ // planWithHeartbeat 跑计划阶段,期间按 planHeartbeat 打点续期。
333
342
  //
334
- // 退出顺序:close(stop) 之后 wg.Wait(),保证心跳 goroutine 彻底停了才返回,
335
- // 否则它的 notice 可能插到「计划已生成」后面。
336
- func planWithHeartbeat(ctx context.Context, opts Options, emit func(agent.StreamEvent)) (Plan, string, error) {
343
+ // 续期而不是发事件:画面上一行字都不多,但别端的尾随连接能从 updated_at 看出
344
+ // 这一轮没死。退出顺序 close(stop) → wg.Wait():心跳彻底停了才返回,避免它
345
+ // 把 lastTouch 推到「计划已生成」之后。
346
+ func planWithHeartbeat(ctx context.Context, opts Options, touch func()) (Plan, string, error) {
337
347
  stop := make(chan struct{})
338
348
  var wg sync.WaitGroup
349
+ if touch == nil {
350
+ touch = func() {}
351
+ }
339
352
  wg.Add(1)
340
353
  go func() {
341
354
  defer wg.Done()
342
- start := time.Now()
343
355
  tick := time.NewTicker(planHeartbeat)
344
356
  defer tick.Stop()
345
357
  for {
@@ -349,10 +361,7 @@ func planWithHeartbeat(ctx context.Context, opts Options, emit func(agent.Stream
349
361
  case <-ctx.Done():
350
362
  return
351
363
  case <-tick.C:
352
- emit(agent.StreamEvent{
353
- Kind: agent.KindNotice,
354
- Text: fmt.Sprintf("团队模式:正在生成计划…(已 %d 秒)", int(time.Since(start).Seconds())),
355
- })
364
+ touch()
356
365
  }
357
366
  }
358
367
  }()
@@ -366,7 +375,9 @@ func planWithHeartbeat(ctx context.Context, opts Options, emit func(agent.Stream
366
375
  //
367
376
  // 子引擎可能几十秒到十几分钟不吐一条事件:模型侧 request 超时后 claude CLI 会自己
368
377
  // 重试,重试期间不产生任何 stream-json。此时任务列表里只有一行「运行中」,跟卡死
369
- // 无法区分。心跳不伪造进度,只证明「这个子任务还活着」。
378
+ // 无法区分。心跳不伪造进度,只证明「这个子任务还活着」——
379
+ // 落点只在**任务列表那一行**(TaskRow.Active),不往主会话里发 notice(2026-10-22
380
+ // 用户:「子任务进度也不需要显示多行 在任务列表中显示即可」)。
370
381
  const (
371
382
  taskHeartbeat = 20 * time.Second
372
383
  taskQuiet = 30 * time.Second
@@ -388,9 +399,7 @@ func executeWithHeartbeat(ctx context.Context, opts Options, plan Plan, runDir s
388
399
  case <-ctx.Done():
389
400
  return
390
401
  case <-tick.C:
391
- for _, text := range board.quietLines() {
392
- board.notice(text)
393
- }
402
+ board.quiet()
394
403
  }
395
404
  }
396
405
  }()