magic-agent 1.0.49 → 1.0.108

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 (94) hide show
  1. package/CLIENT-CONTRACT.md +514 -7
  2. package/README.md +31 -2
  3. package/client-ui/pc/app.css +3 -41
  4. package/client-ui/pc/js/04-task-list.js +6 -2
  5. package/client-ui/pc/js/90-glue.js +23 -4
  6. package/client-ui/pc/js/92-composer.js +11 -4
  7. package/client-ui/pc/js/93-rail.js +178 -19
  8. package/client-ui/shared/conv/03-chat-message.js +145 -25
  9. package/client-ui/shared/conv/09-stream-apply.js +55 -6
  10. package/client-ui/shared/conv/19-attachments.js +70 -6
  11. package/client-ui/shared/conv/27-conv-ledger.js +18 -0
  12. package/client-ui/shared/conv/29-msg-variants.js +10 -0
  13. package/client-ui/shared/conv/README.md +6 -0
  14. package/client-ui/shared/conv/conv.css +77 -0
  15. package/client-ui/shared/conversation.css +229 -43
  16. package/client-ui/shared/conversation.js +1316 -131
  17. package/client-ui/shared/transport-http.js +164 -12
  18. package/client-ui-mobile/index.html +2168 -143
  19. package/internal/agent/capability.go +16 -2
  20. package/internal/agent/claude.go +3 -2
  21. package/internal/agent/codebuddy.go +7 -1
  22. package/internal/agent/codex_test.go +3 -0
  23. package/internal/agent/custom_models_test.go +5 -0
  24. package/internal/agent/dsh.go +18 -2
  25. package/internal/agent/dsh_test.go +53 -2
  26. package/internal/agent/engine_test.go +34 -19
  27. package/internal/agent/env_test.go +14 -9
  28. package/internal/agent/models_test.go +9 -0
  29. package/internal/agent/runcmd_test.go +24 -3
  30. package/internal/agent/stream.go +33 -0
  31. package/internal/cli/ask.go +94 -4
  32. package/internal/cli/contract_test.go +26 -0
  33. package/internal/cli/events.go +18 -7
  34. package/internal/cli/keepalive_test.go +69 -0
  35. package/internal/cli/requirements.go +119 -0
  36. package/internal/cli/requirements_test.go +36 -0
  37. package/internal/cli/root.go +56 -4
  38. package/internal/cli/session.go +64 -25
  39. package/internal/cli/sessionlog.go +76 -7
  40. package/internal/cli/sessionlog_test.go +58 -0
  41. package/internal/cli/team.go +374 -0
  42. package/internal/cli/team_session.go +156 -0
  43. package/internal/cli/team_test.go +360 -0
  44. package/internal/client/plugins.go +7 -4
  45. package/internal/client/requirements.go +309 -0
  46. package/internal/client/requirements_test.go +239 -0
  47. package/internal/client/service.go +545 -19
  48. package/internal/client/service_test.go +47 -2
  49. package/internal/client/session_events_test.go +320 -0
  50. package/internal/client/team_flags.go +204 -0
  51. package/internal/client/team_flags_test.go +254 -0
  52. package/internal/requirements/bootstrap.go +423 -0
  53. package/internal/requirements/consolidate.go +908 -0
  54. package/internal/requirements/doc.go +473 -0
  55. package/internal/requirements/memory.go +324 -0
  56. package/internal/requirements/requirements.go +169 -0
  57. package/internal/requirements/requirements_test.go +530 -0
  58. package/internal/requirements/store.go +289 -0
  59. package/internal/session/delete.go +8 -4
  60. package/internal/session/session.go +194 -17
  61. package/internal/session/session_append.go +4 -1
  62. package/internal/session/session_append_test.go +19 -0
  63. package/internal/session/session_mark_test.go +42 -3
  64. package/internal/session/session_test.go +43 -0
  65. package/internal/session/sessionlog.go +50 -34
  66. package/internal/session/sessionlog_read_test.go +69 -11
  67. package/internal/session/status.go +205 -0
  68. package/internal/session/status_test.go +144 -0
  69. package/internal/team/adaptive.go +293 -0
  70. package/internal/team/board.go +216 -0
  71. package/internal/team/event.go +28 -0
  72. package/internal/team/execute.go +648 -0
  73. package/internal/team/plan.go +903 -0
  74. package/internal/team/planner.go +294 -0
  75. package/internal/team/reduce.go +192 -0
  76. package/internal/team/role.go +334 -0
  77. package/internal/team/route.go +206 -0
  78. package/internal/team/schedule.go +241 -0
  79. package/internal/team/schedule_test.go +286 -0
  80. package/internal/team/subsession.go +27 -0
  81. package/internal/team/team.go +401 -0
  82. package/internal/team/team_test.go +1997 -0
  83. package/npm/bin/magic-client.js +0 -0
  84. package/npm/dist/darwin-arm64/magic-agent +0 -0
  85. package/npm/dist/darwin-arm64/magic-client +0 -0
  86. package/npm/dist/darwin-x64/magic-agent +0 -0
  87. package/npm/dist/darwin-x64/magic-client +0 -0
  88. package/npm/dist/linux-arm64/magic-agent +0 -0
  89. package/npm/dist/linux-arm64/magic-client +0 -0
  90. package/npm/dist/linux-x64/magic-agent +0 -0
  91. package/npm/dist/linux-x64/magic-client +0 -0
  92. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  93. package/npm/dist/win32-x64/magic-client.exe +0 -0
  94. package/package.json +1 -1
@@ -0,0 +1,144 @@
1
+ package session
2
+
3
+ import (
4
+ "os"
5
+ "path/filepath"
6
+ "testing"
7
+ "time"
8
+ )
9
+
10
+ // withStatusHome 把状态表指到临时目录(不碰用户真实的 ~/.magic-agent)。
11
+ // 缓存按路径失效(见 statusCachePath),所以这里只需换目录。
12
+ func withStatusHome(t *testing.T) string {
13
+ t.Helper()
14
+ dir := t.TempDir()
15
+ t.Setenv(EnvDir, filepath.Join(dir, "sessions"))
16
+ statusMu.Lock()
17
+ statusCache, statusLoaded, statusCachePath = nil, false, ""
18
+ statusMu.Unlock()
19
+ return dir
20
+ }
21
+
22
+ // TestStatusFollowsHandleLifecycle 写登记表的每一步都要顺手更新会话级状态:
23
+ // Begin → running、Mark → idle、Finish → done。客户端只读这一个字段。
24
+ func TestStatusFollowsHandleLifecycle(t *testing.T) {
25
+ withStatusHome(t)
26
+ h, err := Begin(BeginOptions{Engine: "fake", SessionID: "sess-1", PID: os.Getpid()})
27
+ if err != nil {
28
+ t.Fatal(err)
29
+ }
30
+ if got := StatusForAny("sess-1", h.RunID()); got != StateRunning {
31
+ t.Fatalf("Begin 后 status = %q,want %q", got, StateRunning)
32
+ }
33
+ h.Mark(StateIdle) // 常驻会话轮次之间
34
+ if got := StatusFor("sess-1"); got != StateIdle {
35
+ t.Fatalf("Mark(idle) 后 status = %q,want %q", got, StateIdle)
36
+ }
37
+ h.Finish("sess-1", StateDone)
38
+ if got := StatusFor("sess-1"); got != StateDone {
39
+ t.Fatalf("Finish(done) 后 status = %q,want %q", got, StateDone)
40
+ }
41
+ // 落盘形状:与标题表同目录、同风格,换实现不换位置。
42
+ p, err := StatusStorePath()
43
+ if err != nil {
44
+ t.Fatal(err)
45
+ }
46
+ if filepath.Base(p) != "session-status.json" {
47
+ t.Fatalf("状态表文件名 = %s", p)
48
+ }
49
+ if b, err := os.ReadFile(p); err != nil || len(b) == 0 {
50
+ t.Fatalf("状态表没落盘:%v", err)
51
+ }
52
+ }
53
+
54
+ // TestStatusNoPersistLeavesNoTrace NoPersist 的会话连状态也不该留(与登记表一致)。
55
+ func TestStatusNoPersistLeavesNoTrace(t *testing.T) {
56
+ withStatusHome(t)
57
+ h, err := Begin(BeginOptions{Engine: "fake", SessionID: "sess-np", NoPersist: true})
58
+ if err != nil {
59
+ t.Fatal(err)
60
+ }
61
+ if got := StatusFor("sess-np"); got != "" {
62
+ t.Fatalf("NoPersist 会话不该有状态,实得 %q", got)
63
+ }
64
+ h.Finish("sess-np", StateDone)
65
+ if got := StatusFor("sess-np"); got != "" {
66
+ t.Fatalf("NoPersist 收尾后也不该有状态,实得 %q", got)
67
+ }
68
+ }
69
+
70
+ // TestStatusStaleRunCannotOverwriteNewer 旧一轮迟到的 done/gone 不能盖掉新一轮的 running。
71
+ // 没有这条守卫时,界面会出现「明明在跑却显示已完成」。
72
+ func TestStatusStaleRunCannotOverwriteNewer(t *testing.T) {
73
+ withStatusHome(t)
74
+ now := time.Now()
75
+ setStatus(Record{RunID: "run-new", SessionID: "sess-1", State: StateRunning, StartedAt: now})
76
+ setStatus(Record{RunID: "run-old", SessionID: "sess-1", State: StateDone,
77
+ StartedAt: now.Add(-time.Hour)})
78
+ if got := StatusFor("sess-1"); got != StateRunning {
79
+ t.Fatalf("旧一轮把新一轮盖掉了:status = %q,want %q", got, StateRunning)
80
+ }
81
+ // 反过来(新一轮写终态)必须生效。
82
+ setStatus(Record{RunID: "run-new", SessionID: "sess-1", State: StateDone, StartedAt: now})
83
+ if got := StatusFor("sess-1"); got != StateDone {
84
+ t.Fatalf("同一轮的状态更新没生效:%q", got)
85
+ }
86
+ }
87
+
88
+ // TestStatusMigratesRunKeyToSession 先落在 run_id 上的键,拿到 session_id 后迁走并删除旧键
89
+ // —— 留着双键,按不同 id 读就会拿到两个状态(与标题同一个坑)。
90
+ func TestStatusMigratesRunKeyToSession(t *testing.T) {
91
+ withStatusHome(t)
92
+ started := time.Now().Add(-time.Minute)
93
+ setStatus(Record{RunID: "run-1", State: StateRunning, StartedAt: started})
94
+ if got := StatusFor("run-1"); got != StateRunning {
95
+ t.Fatalf("前置条件:run 键上应有状态,实得 %q", got)
96
+ }
97
+ setStatus(Record{RunID: "run-1", SessionID: "sess-1", State: StateRunning, StartedAt: started})
98
+ if got := StatusFor("sess-1"); got != StateRunning {
99
+ t.Fatalf("session 键上应有状态,实得 %q", got)
100
+ }
101
+ if got := StatusFor("run-1"); got != "" {
102
+ t.Fatalf("迁移后旧 run 键该被清掉,实得 %q", got)
103
+ }
104
+ }
105
+
106
+ // TestStatusForAnyPrefersSession session_id 优先、run_id 兜底。
107
+ func TestStatusForAnyPrefersSession(t *testing.T) {
108
+ withStatusHome(t)
109
+ now := time.Now()
110
+ setStatus(Record{RunID: "run-1", SessionID: "sess-1", State: StateIdle, StartedAt: now})
111
+ if got := StatusForAny("sess-1", "run-x"); got != StateIdle {
112
+ t.Fatalf("StatusForAny(按 session) = %q", got)
113
+ }
114
+ setStatus(Record{RunID: "run-2", State: StateRunning, StartedAt: now})
115
+ if got := StatusForAny("", "run-2"); got != StateRunning {
116
+ t.Fatalf("StatusForAny(按 run) = %q", got)
117
+ }
118
+ if got := StatusForAny("", ""); got != "" {
119
+ t.Fatalf("空 id 应为空,实得 %q", got)
120
+ }
121
+ }
122
+
123
+ // TestDeleteDropsStatuses 会话删了,状态覆盖也要清 —— 否则同 id 再建会话会继承过期状态。
124
+ func TestDeleteDropsStatuses(t *testing.T) {
125
+ withStatusHome(t)
126
+ h, err := Begin(BeginOptions{Engine: "fake", SessionID: "sess-del", PID: os.Getpid()})
127
+ if err != nil {
128
+ t.Fatal(err)
129
+ }
130
+ h.Finish("sess-del", StateDone)
131
+ if got := StatusFor("sess-del"); got != StateDone {
132
+ t.Fatalf("前置条件:status = %q", got)
133
+ }
134
+ res, err := Delete("sess-del")
135
+ if err != nil {
136
+ t.Fatal(err)
137
+ }
138
+ if res.StatusesRemoved != 1 {
139
+ t.Fatalf("StatusesRemoved = %d,want 1", res.StatusesRemoved)
140
+ }
141
+ if got := StatusFor("sess-del"); got != "" {
142
+ t.Fatalf("删除后状态该被清掉,实得 %q", got)
143
+ }
144
+ }
@@ -0,0 +1,293 @@
1
+ package team
2
+
3
+ // adaptive.go - 执行中再规划:按失败原因与交接单的 open 项追加补充任务。
4
+ //
5
+ // 静态计划只能基于「用户说了什么」。真正跑起来才知道哪里会失败、上游交接单里
6
+ // 还留了哪些没解决的事。adaptive 在**一轮执行结束之后**读这两个信号,让主引擎
7
+ // 判断要不要补任务:
8
+ //
9
+ // 信号一 必需任务失败(skipped 不算 —— 它是级联结果,根因在失败的那个任务上)
10
+ // 信号二 已完成任务的交接单里有 open 项
11
+ //
12
+ // 纪律(写死,别放开):
13
+ // - **只补一轮**、每次最多 adaptiveMaxTasks 个;避免「再规划 → 又失败 → 再规划」
14
+ // 把一轮团队调用拖成无限循环。
15
+ // - **只增不改**:已跑完的任务与已写的 TASK-*.md 都不动,补充任务用独立 id
16
+ // (a1..aN)追加到任务列表末尾(board.add)。
17
+ // - 再规划失败 / 没有补充任务 / 与现有计划冲突 → 发一条 notice 后按当前结果收尾,
18
+ // **绝不**打断整轮(汇总仍会给出答案)。
19
+
20
+ import (
21
+ "context"
22
+ "fmt"
23
+ "strings"
24
+
25
+ "github.com/darren/magic-agent/internal/agent"
26
+ )
27
+
28
+ const (
29
+ // adaptiveMaxTasks 一轮再规划最多补几个任务。
30
+ adaptiveMaxTasks = 3
31
+ // adaptiveOpenPerTask 每个任务最多取几条 open 项进提示词。
32
+ adaptiveOpenPerTask = 3
33
+ // adaptiveMarker 再规划请求的系统提示词标记(测试据此区分计划轮与再规划轮)。
34
+ adaptiveMarker = "再规划"
35
+ )
36
+
37
+ // adaptiveSystemPrompt 再规划阶段的补充要求(接在 plannerSystemPrompt 之后)。
38
+ const adaptiveSystemPrompt = `你是同一个任务的**再规划**阶段:第一轮计划已经执行完,下面是失败的任务与交接单里留下的未解决项。
39
+
40
+ 只输出一个 JSON 对象,结构与上一轮完全相同(goal + tasks):
41
+ - 每个补充任务的 **role 必填**(同上一轮的角色表,类别由角色决定);
42
+ - 只列**补充任务**,不要复述已经完成的任务;
43
+ - 最多 3 个;不需要补充就直接输出 {"goal":"…","tasks":[]};
44
+ - id 随便写,core 会重编号为 a1、a2…;
45
+ - deps 只能引用本批补充任务的 id(不要引用已经跑完的原任务);
46
+ - 每个补充任务的 prompt 必须**自包含**:执行者看不到这段对话,也看不到原任务正文;
47
+ 需要上游文件时,把绝对路径写进 prompt 或 inputs;
48
+ - 只补「不做就交不了活」的缺口,不要为了凑数补任务。`
49
+
50
+ // adaptiveSignals 收集触发再规划的信号。
51
+ //
52
+ // 返回空 = 本轮没有需要补的事,调用方应当**静默**返回(成功轮次不该被一条
53
+ // 「正在再规划」打扰)。
54
+ func adaptiveSignals(plan Plan, results []TaskResult) []string {
55
+ var out []string
56
+ for _, r := range results {
57
+ t, ok := findTask(plan, r.ID)
58
+ if !ok {
59
+ continue
60
+ }
61
+ // skipped 是级联结果:根因任务已经单独报过,再为它补一次只会重复。
62
+ if r.State == TaskSkipped {
63
+ continue
64
+ }
65
+ if r.State != TaskDone {
66
+ if !t.IsRequired() {
67
+ continue
68
+ }
69
+ out = append(out, fmt.Sprintf("任务 %s「%s」失败:%s",
70
+ r.ID, orDash(r.Title), orDash(r.Reason)))
71
+ continue
72
+ }
73
+ if r.Handoff == nil {
74
+ continue
75
+ }
76
+ var open []string
77
+ for _, o := range r.Handoff.Open {
78
+ if s := strings.TrimSpace(o); s != "" {
79
+ open = append(open, oneLine(s))
80
+ }
81
+ if len(open) >= adaptiveOpenPerTask {
82
+ break
83
+ }
84
+ }
85
+ if len(open) == 0 {
86
+ continue
87
+ }
88
+ out = append(out, fmt.Sprintf("任务 %s「%s」留下未解决项:%s",
89
+ r.ID, orDash(r.Title), strings.Join(open, ";")))
90
+ }
91
+ return out
92
+ }
93
+
94
+ // adaptiveReplan 执行一轮再规划,返回(合并后的计划,追加后的结果)。
95
+ //
96
+ // 失败路径全部收敛为「原样返回 + 一条 notice」:再规划是**增强**,不是必需环节,
97
+ // 它不该让一轮本来能汇总的团队调用变成 error。
98
+ func adaptiveReplan(ctx context.Context, opts Options, plan Plan, results []TaskResult,
99
+ runDir, planSession string, board *Board) (Plan, []TaskResult) {
100
+ signals := adaptiveSignals(plan, results)
101
+ if len(signals) == 0 {
102
+ return plan, results
103
+ }
104
+ board.notice(fmt.Sprintf("团队模式:发现 %d 个未完成 / 未解决项,正在判断是否补充任务…", len(signals)))
105
+
106
+ extra, err := makeAdaptivePlan(ctx, opts, plan, signals, planSession)
107
+ if err != nil {
108
+ board.notice("团队模式:补充任务规划失败,按当前结果收尾:" + err.Error())
109
+ return plan, results
110
+ }
111
+ var dup int
112
+ extra, dup = normalizeAdaptiveTasks(extra, plan)
113
+ if dup > 0 {
114
+ board.notice(fmt.Sprintf("团队模式:%d 个补充任务与原任务同名(等同重试,重试由执行器负责),已跳过", dup))
115
+ }
116
+ if len(extra) == 0 {
117
+ board.notice("团队模式:没有需要补充的任务,按当前结果收尾")
118
+ return plan, results
119
+ }
120
+
121
+ merged := Plan{
122
+ Goal: plan.Goal,
123
+ Tasks: append(append([]Task{}, plan.Tasks...), extra...),
124
+ }
125
+ if err := merged.Validate(); err != nil {
126
+ board.notice("团队模式:补充任务与现有计划冲突,跳过:" + err.Error())
127
+ return plan, results
128
+ }
129
+ // PLAN.md 重写成「原计划 + 补充任务」:它是全队共享的事实源,后来者(补跑、
130
+ // 人工查看)必须看到完整的任务集合,而不是只有第一轮。
131
+ if err := WritePlanDoc(runDir, merged, opts); err != nil {
132
+ board.notice("团队模式:更新计划文档失败,跳过补充任务:" + err.Error())
133
+ return plan, results
134
+ }
135
+
136
+ var ids []string
137
+ for _, t := range extra {
138
+ if err := WriteTaskDoc(runDir, merged, t); err != nil {
139
+ board.notice(fmt.Sprintf("团队模式:写补充任务书 %s 失败,跳过:%v", t.ID, err))
140
+ return plan, results
141
+ }
142
+ if err := EnsureTaskDir(runDir, t.ID); err != nil {
143
+ board.notice(fmt.Sprintf("团队模式:建补充任务目录 %s 失败,跳过:%v", t.ID, err))
144
+ return plan, results
145
+ }
146
+ board.add(t, RouteFor(t, opts))
147
+ ids = append(ids, t.ID)
148
+ }
149
+ board.announce()
150
+ board.notice(fmt.Sprintf("团队模式:追加 %d 个补充任务(%s),继续执行",
151
+ len(extra), strings.Join(ids, "、")))
152
+
153
+ // 只跑补充任务:原任务已经跑过,重跑会把成本翻倍、也会覆盖 OUT/ARTS。
154
+ more := executeWithHeartbeat(ctx, opts, Plan{Goal: merged.Goal, Tasks: extra}, runDir, board)
155
+ return merged, append(results, more...)
156
+ }
157
+
158
+ // makeAdaptivePlan 让主引擎产出补充任务(与 makePlan 同一条非流式 + ToolsOff 路径)。
159
+ func makeAdaptivePlan(ctx context.Context, opts Options, plan Plan, signals []string, planSession string) ([]Task, error) {
160
+ if opts.Engine == nil {
161
+ return nil, fmt.Errorf("主引擎为空")
162
+ }
163
+ if err := ctx.Err(); err != nil {
164
+ return nil, err
165
+ }
166
+ route := PlanRoute(opts)
167
+ eng := agent.Lookup(route.Engine)
168
+ if eng == nil {
169
+ return nil, fmt.Errorf("计划引擎 %s 不可用", route.Engine)
170
+ }
171
+ // 会话沿用计划那一轮的 id **仅当**再规划还在主引擎上;换引擎时跨引擎续接是错的
172
+ // (同 makePlan 的 onMain 判定)。
173
+ onMain := strings.EqualFold(eng.Name(), opts.Engine.Name())
174
+ sid := ""
175
+ if onMain {
176
+ sid = planSession
177
+ }
178
+ rctx, cancel := context.WithTimeout(ctx, taskTimeout(opts))
179
+ defer cancel()
180
+
181
+ runner := &agent.Runner{Engine: eng, Timeout: taskTimeout(opts)}
182
+ resp, err := runner.Run(rctx, agent.Request{
183
+ Engine: eng.Name(),
184
+ Model: route.Model,
185
+ SystemPrompt: strings.TrimSpace(opts.SystemPrompt + "\n\n" + plannerPrompt(opts) + "\n\n" + adaptiveSystemPrompt),
186
+ Tools: agent.ToolsOff,
187
+ JSONSchema: planSchema(),
188
+ Messages: []agent.Message{{Role: "user", Content: adaptivePrompt(plan, signals)}},
189
+ Reasoning: opts.Reasoning,
190
+ SessionID: sid,
191
+ })
192
+ if err != nil {
193
+ return nil, err
194
+ }
195
+ replan, perr := parsePlan(resp.Text)
196
+ if perr != nil {
197
+ return nil, perr
198
+ }
199
+ return replan.Tasks, nil
200
+ }
201
+
202
+ // adaptivePrompt 组装再规划输入:目标 + 信号 + 补充任务的写法要求。
203
+ func adaptivePrompt(plan Plan, signals []string) string {
204
+ var b strings.Builder
205
+ b.WriteString("原计划目标:" + orDash(plan.Goal) + "\n\n")
206
+ b.WriteString("第一轮执行后发现的问题:\n")
207
+ for _, s := range signals {
208
+ b.WriteString("- " + oneLine(s) + "\n")
209
+ }
210
+ b.WriteString("\n请判断是否需要补充任务。" +
211
+ "需要就只输出补充任务(最多 " + fmt.Sprint(adaptiveMaxTasks) + " 个);" +
212
+ "不需要就输出 {\"goal\":\"…\",\"tasks\":[]}。\n")
213
+ return b.String()
214
+ }
215
+
216
+ // normalizeAdaptiveTasks 收拢补充任务的 id / deps,返回可直接合并进原计划的任务,
217
+ // 以及因**与原任务同名**被丢弃的个数。
218
+ //
219
+ // - 没有 prompt 的丢弃(不可执行,也会让 Validate 失败)
220
+ // - 与原任务(或本批已收的任务)同名的丢弃:模型常把「没做成」直接原样再提一遍,
221
+ // 那等价于重试,执行器自己会重试;收进来只会在任务卡上多出一行同名任务
222
+ // (2026-10-22 实测 t1/a1、t2/a2、t3/a3 各并列两行)
223
+ // - 最多 adaptiveMaxTasks 个
224
+ // - id 重编号为 a1..aN(跳过原计划已占用的 id)
225
+ // - deps 只保留指向本批补充任务的(重映射);指向原任务的依赖丢弃 —— 原任务已经
226
+ // 跑完,执行器拿不到它们,留着只会让整批校验失败
227
+ func normalizeAdaptiveTasks(src []Task, plan Plan) ([]Task, int) {
228
+ taken := make(map[string]bool, len(plan.Tasks))
229
+ seen := make(map[string]bool, len(plan.Tasks)+len(src))
230
+ for _, t := range plan.Tasks {
231
+ taken[strings.TrimSpace(t.ID)] = true
232
+ seen[titleKey(t.Title)] = true
233
+ }
234
+
235
+ dropped := 0
236
+ kept := make([]Task, 0, len(src))
237
+ for _, t := range src {
238
+ if len(kept) >= adaptiveMaxTasks {
239
+ break
240
+ }
241
+ if strings.TrimSpace(t.Prompt) == "" {
242
+ continue
243
+ }
244
+ k := titleKey(t.Title)
245
+ if k != "" && seen[k] {
246
+ dropped++
247
+ continue
248
+ }
249
+ if k != "" {
250
+ seen[k] = true
251
+ }
252
+ kept = append(kept, normalizeTask(t))
253
+ }
254
+
255
+ // 原 id → 新 id(先定好全批映射,deps 才能指到后面的任务)。
256
+ newID := make(map[string]string, len(kept))
257
+ ids := make([]string, len(kept))
258
+ n := 1
259
+ for i, t := range kept {
260
+ for taken[fmt.Sprintf("a%d", n)] {
261
+ n++
262
+ }
263
+ ids[i] = fmt.Sprintf("a%d", n)
264
+ taken[ids[i]] = true
265
+ n++
266
+ if old := strings.TrimSpace(t.ID); old != "" {
267
+ newID[old] = ids[i]
268
+ }
269
+ }
270
+
271
+ out := make([]Task, 0, len(kept))
272
+ for i, t := range kept {
273
+ t.ID = ids[i]
274
+ var deps []string
275
+ seen := make(map[string]bool, len(t.Deps))
276
+ for _, d := range t.Deps {
277
+ nid, ok := newID[strings.TrimSpace(d)]
278
+ if !ok || nid == t.ID || seen[nid] {
279
+ continue
280
+ }
281
+ seen[nid] = true
282
+ deps = append(deps, nid)
283
+ }
284
+ t.Deps = deps
285
+ out = append(out, t)
286
+ }
287
+ return out, dropped
288
+ }
289
+
290
+ // titleKey 把任务标题归一成比较用的键(忽略大小写与首尾空白)。
291
+ func titleKey(s string) string {
292
+ return strings.ToLower(strings.TrimSpace(s))
293
+ }
@@ -0,0 +1,216 @@
1
+ package team
2
+
3
+ // board.go - 主会话里的**任务列表卡**(agent.KindTasks 快照)。
4
+ //
5
+ // 为什么不再为每个子任务开一张委派卡(2026-10-22 用户定稿):子任务已经是独立会话,
6
+ // 过程都写在各自的日志里;主会话只需要一眼看清「拆了几件事、各自到哪了、点进去是哪条
7
+ // 会话」。所以这里维护一份全量快照,任何一行状态变化就整份重发 —— 快照语义天然幂等,
8
+ // 多端重连 / 乱序到达都不用做合并。
9
+
10
+ import (
11
+ "encoding/json"
12
+ "fmt"
13
+ "sync"
14
+ "time"
15
+
16
+ "github.com/darren/magic-agent/internal/agent"
17
+ )
18
+
19
+ // 任务行的状态词表。
20
+ //
21
+ // done / failed / skipped 与 TaskResult.State 同词(落盘那套),只多两个过程态:
22
+ // pending(计划里已确定、还没轮到)与 running。渲染层把它映射成
23
+ // pending→pending / running→in_progress / done→completed / failed→failed / skipped→skipped。
24
+ const (
25
+ RowPending = "pending"
26
+ RowRunning = "running"
27
+ )
28
+
29
+ // TaskRow 任务列表卡上的一行(wire 形状见 agent.KindTasks 的注释,两边必须一致)。
30
+ type TaskRow struct {
31
+ ID string `json:"id"`
32
+ Title string `json:"title"`
33
+ Role string `json:"role,omitempty"`
34
+ Kind string `json:"kind,omitempty"`
35
+ Engine string `json:"engine,omitempty"`
36
+ Model string `json:"model,omitempty"`
37
+ Status string `json:"status"`
38
+ Deps []string `json:"deps,omitempty"`
39
+ // Note 失败 / 跳过的一句话原因(成功时为空)。
40
+ Note string `json:"note,omitempty"`
41
+ // SessionID / RunID 这一行的子会话(点进去看过程)。RunID 在任务一开跑就有,
42
+ // SessionID 要等引擎给出;两个都可能为空(调用方没开登记 / 引擎不给 id)。
43
+ SessionID string `json:"session_id,omitempty"`
44
+ RunID string `json:"run_id,omitempty"`
45
+ Output string `json:"output,omitempty"`
46
+ }
47
+
48
+ // Board 任务列表的唯一写者(并发安全)。
49
+ type Board struct {
50
+ emit func(agent.StreamEvent)
51
+
52
+ mu sync.Mutex
53
+ order []string
54
+ rows map[string]*TaskRow
55
+ // last 每一行最后一次「有动静」的时间(子事件 / 状态变化),心跳据此播报。
56
+ last map[string]time.Time
57
+ }
58
+
59
+ func newBoard(emit func(agent.StreamEvent)) *Board {
60
+ return &Board{
61
+ emit: emit,
62
+ rows: make(map[string]*TaskRow),
63
+ last: make(map[string]time.Time),
64
+ }
65
+ }
66
+
67
+ // update 改一次状态并整份广播:所有变更都走这里,避免漏发快照。
68
+ func (b *Board) update(fn func()) {
69
+ b.mu.Lock()
70
+ fn()
71
+ text := b.snapshotLocked()
72
+ b.mu.Unlock()
73
+ if text == "" {
74
+ return
75
+ }
76
+ b.emit(agent.StreamEvent{Kind: agent.KindTasks, Text: text})
77
+ }
78
+
79
+ // snapshotLocked 生成 wire 形状(调用方持锁)。
80
+ func (b *Board) snapshotLocked() string {
81
+ if len(b.order) == 0 {
82
+ return ""
83
+ }
84
+ rows := make([]TaskRow, 0, len(b.order))
85
+ for _, id := range b.order {
86
+ rows = append(rows, *b.rows[id])
87
+ }
88
+ data, err := json.Marshal(map[string]any{"tasks": rows})
89
+ if err != nil {
90
+ return ""
91
+ }
92
+ return string(data)
93
+ }
94
+
95
+ // add 计划确定时先把全部任务登记进列表(含还在等上游的)——
96
+ // 否则有依赖的任务在轮到自己之前,界面上连「有这件事」都看不到。
97
+ //
98
+ // 登记**不发快照**:一批任务一次性 add 完再 announce,任务列表卡一出现就是完整的一张,
99
+ // 而不是先闪一行、再长成两行。
100
+ func (b *Board) add(t Task, route Route) {
101
+ b.mu.Lock()
102
+ defer b.mu.Unlock()
103
+ if _, ok := b.rows[t.ID]; ok {
104
+ return
105
+ }
106
+ b.order = append(b.order, t.ID)
107
+ b.rows[t.ID] = &TaskRow{
108
+ ID: t.ID, Title: t.Title, Role: t.Role, Kind: t.EffectiveKind(),
109
+ Engine: route.Engine, Model: route.Model,
110
+ Status: RowPending, Deps: t.Deps,
111
+ }
112
+ b.last[t.ID] = time.Now()
113
+ }
114
+
115
+ // announce 整份广播当前任务列表(一批 add 之后调一次)。
116
+ func (b *Board) announce() {
117
+ b.update(func() {})
118
+ }
119
+
120
+ // start 标记开跑(引擎 / 模型以实际解析结果为准)。
121
+ func (b *Board) start(t Task, engine, model string) {
122
+ b.update(func() {
123
+ row := b.rows[t.ID]
124
+ if row == nil {
125
+ return
126
+ }
127
+ row.Status = RowRunning
128
+ row.Engine, row.Model = engine, model
129
+ row.Note = ""
130
+ b.last[t.ID] = time.Now()
131
+ })
132
+ }
133
+
134
+ // setSession 绑定子会话(run_id 开跑即有,session_id 引擎给了才有)。
135
+ func (b *Board) setSession(id, sessionID, runID string) {
136
+ if sessionID == "" && runID == "" {
137
+ return
138
+ }
139
+ b.update(func() {
140
+ row := b.rows[id]
141
+ if row == nil {
142
+ return
143
+ }
144
+ if sessionID != "" {
145
+ row.SessionID = sessionID
146
+ }
147
+ if runID != "" {
148
+ row.RunID = runID
149
+ }
150
+ })
151
+ }
152
+
153
+ // finish 落定一行的结局。
154
+ func (b *Board) finish(t Task, r TaskResult) {
155
+ b.update(func() {
156
+ row := b.rows[t.ID]
157
+ if row == nil {
158
+ // 没经过 add(理论上不会发生):补一行,别让结果无处可落。
159
+ b.order = append(b.order, t.ID)
160
+ row = &TaskRow{ID: t.ID, Title: t.Title, Role: t.Role, Kind: t.EffectiveKind()}
161
+ b.rows[t.ID] = row
162
+ }
163
+ row.Status = r.State
164
+ if r.Engine != "" {
165
+ row.Engine = r.Engine
166
+ }
167
+ if r.Model != "" {
168
+ row.Model = r.Model
169
+ }
170
+ if r.SessionID != "" {
171
+ row.SessionID = r.SessionID
172
+ }
173
+ row.Output = r.OutputPath
174
+ if r.State != TaskDone {
175
+ row.Note = oneLine(r.Reason)
176
+ }
177
+ b.last[t.ID] = time.Now()
178
+ })
179
+ }
180
+
181
+ // touch 子任务有事件了(刷新心跳时间;事件本身写子会话的日志,不进主会话)。
182
+ func (b *Board) touch(id string) {
183
+ b.mu.Lock()
184
+ defer b.mu.Unlock()
185
+ if _, ok := b.rows[id]; ok {
186
+ b.last[id] = time.Now()
187
+ }
188
+ }
189
+
190
+ // quietLines 返回「已经 taskQuiet 没动静」的运行中任务的播报文案,并把它们的活跃时间
191
+ // 推到此刻 —— 同一条任务因此每 taskHeartbeat 才播一次,秒数持续增长。
192
+ func (b *Board) quietLines() []string {
193
+ b.mu.Lock()
194
+ defer b.mu.Unlock()
195
+ now := time.Now()
196
+ var out []string
197
+ for _, id := range b.order {
198
+ row := b.rows[id]
199
+ if row == nil || row.Status != RowRunning {
200
+ continue
201
+ }
202
+ last := b.last[id]
203
+ if now.Sub(last) < taskQuiet {
204
+ continue
205
+ }
206
+ b.last[id] = now
207
+ out = append(out, fmt.Sprintf("团队模式:%s「%s」仍在运行…(已 %d 秒无新输出)",
208
+ id, row.Title, int(now.Sub(last).Seconds())))
209
+ }
210
+ return out
211
+ }
212
+
213
+ // notice 往主会话发一条过程提示(不属于任何子任务)。
214
+ func (b *Board) notice(text string) {
215
+ b.emit(agent.StreamEvent{Kind: agent.KindNotice, Text: text})
216
+ }
@@ -0,0 +1,28 @@
1
+ package team
2
+
3
+ // event.go - 团队模式自己合成的**少量**事件。
4
+ //
5
+ // 原则:能用引擎已有的形状就用,不新造 kind。
6
+ // · 计划 → 一条 notice(一行提示 + 计划文档路径),任务本身在画布上表现为
7
+ // 后续的委派卡,不需要一张额外的「计划卡」;
8
+ // · 每个子任务 → tool_use(name=Task) 开卡 + 带 Parent 的子事件 + tool_result 收卡;
9
+ // · 降级/汇总 → notice。
10
+ //
11
+ // 于是客户端(三端共用同一份渲染内核)**零改动**就能画出团队模式 —— 它看起来
12
+ // 就是主代理连续委派了几次。要加「计划总览卡」时再补新 kind,那是一次独立的
13
+ // 契约变更(见 CLIENT-CONTRACT.md 的只增字段约定)。
14
+
15
+ import (
16
+ "strconv"
17
+
18
+ "github.com/darren/magic-agent/internal/agent"
19
+ )
20
+
21
+ // emitPlanEvent 播一条「计划已生成」提示。
22
+ func emitPlanEvent(emit func(agent.StreamEvent), planPath string, plan Plan, planOnly bool) {
23
+ text := "计划已生成(" + strconv.Itoa(len(plan.Tasks)) + " 个子任务):" + planPath
24
+ if planOnly {
25
+ text += "(仅出计划,不执行)"
26
+ }
27
+ emit(agent.StreamEvent{Kind: agent.KindNotice, Text: text})
28
+ }