magic-agent 1.0.111 → 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.
- package/CLIENT-CONTRACT.md +117 -6
- package/client-ui/pc/app.css +3 -0
- package/client-ui/pc/index.html +5 -0
- package/client-ui/pc/js/05-task-list-render.js +28 -4
- package/client-ui/pc/js/07-chat-stream-render.js +19 -8
- package/client-ui/pc/js/93-rail.js +21 -1
- package/client-ui/shared/conv/03-chat-message.js +43 -0
- package/client-ui/shared/conv/06-ask-answer.js +30 -7
- package/client-ui/shared/conv/09-stream-apply.js +16 -0
- package/client-ui/shared/conv/19-attachments.js +75 -11
- package/client-ui/shared/conv/29-msg-variants.js +56 -3
- package/client-ui/shared/conv/README.md +6 -0
- package/client-ui/shared/conv/conv.css +16 -1
- package/client-ui/shared/conversation.css +42 -4
- package/client-ui/shared/conversation.js +655 -87
- package/client-ui/shared/transport-http.js +16 -0
- package/client-ui-mobile/index.html +1052 -595
- package/client-ui-mobile/reqs.css +39 -0
- package/client-ui-mobile/reqs.js +507 -0
- package/cmd/magic-client/main.go +2 -1
- package/internal/cli/ask.go +6 -0
- package/internal/cli/root.go +5 -0
- package/internal/cli/session.go +16 -14
- package/internal/cli/suggest.go +98 -0
- package/internal/cli/suggest_test.go +54 -0
- package/internal/cli/team.go +111 -24
- package/internal/client/apk.go +135 -0
- package/internal/client/apk_test.go +99 -0
- package/internal/client/prefs.go +99 -0
- package/internal/client/service.go +202 -1
- package/internal/client/suggest.go +131 -0
- package/internal/client/suggest_test.go +116 -0
- package/internal/session/delete.go +58 -11
- package/internal/session/delete_test.go +140 -0
- package/internal/session/session.go +25 -0
- package/internal/session/session_mark_test.go +62 -0
- package/internal/suggest/suggest.go +421 -0
- package/internal/suggest/suggest_test.go +218 -0
- package/internal/team/board.go +107 -15
- package/internal/team/execute.go +1 -1
- package/internal/team/team.go +28 -19
- package/internal/team/team_test.go +68 -15
- package/npm/apk/magic-agent-mobile-release.apk +0 -0
- package/npm/apk/version.json +5 -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 +6 -2
|
@@ -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
|
+
}
|
package/internal/team/board.go
CHANGED
|
@@ -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:
|
|
62
|
-
rows:
|
|
63
|
-
last:
|
|
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
|
-
|
|
185
|
-
if
|
|
186
|
-
b.
|
|
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
|
-
//
|
|
191
|
-
//
|
|
192
|
-
|
|
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
|
-
|
|
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
|
-
|
|
208
|
-
|
|
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 往主会话发一条过程提示(不属于任何子任务)。
|
package/internal/team/execute.go
CHANGED
package/internal/team/team.go
CHANGED
|
@@ -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,
|
|
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
|
-
//
|
|
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
|
-
//
|
|
335
|
-
//
|
|
336
|
-
|
|
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
|
-
|
|
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
|
-
|
|
392
|
-
board.notice(text)
|
|
393
|
-
}
|
|
402
|
+
board.quiet()
|
|
394
403
|
}
|
|
395
404
|
}
|
|
396
405
|
}()
|
|
@@ -1323,31 +1323,50 @@ func TestWriteOutputRecordsMissing(t *testing.T) {
|
|
|
1323
1323
|
}
|
|
1324
1324
|
}
|
|
1325
1325
|
|
|
1326
|
-
//
|
|
1327
|
-
|
|
1328
|
-
|
|
1326
|
+
// 执行阶段心跳:只给「还在跑且很久没动静」的那一行挂行内进度,收尾的不再挂。
|
|
1327
|
+
//
|
|
1328
|
+
// 2026-10-22 用户:「子任务进度也不需要显示多行 在任务列表中显示即可」——
|
|
1329
|
+
// 心跳落点从一串 notice 改成任务列表行的 Active,所以断言也看行。
|
|
1330
|
+
func TestBoardQuietOnlyMarksSilentRunningTasks(t *testing.T) {
|
|
1331
|
+
var events []agent.StreamEvent
|
|
1332
|
+
b := newBoard(func(ev agent.StreamEvent) { events = append(events, ev) })
|
|
1329
1333
|
t1 := Task{ID: "t1", Kind: KindText, Title: "写介绍"}
|
|
1330
1334
|
t2 := Task{ID: "t2", Kind: KindImage, Title: "画封面"}
|
|
1331
1335
|
t3 := Task{ID: "t3", Kind: KindText, Title: "汇总图"}
|
|
1332
1336
|
for _, task := range []Task{t1, t2, t3} {
|
|
1333
1337
|
b.add(task, Route{})
|
|
1334
1338
|
}
|
|
1339
|
+
b.announce()
|
|
1340
|
+
events = nil
|
|
1335
1341
|
b.start(t1, "e", "")
|
|
1336
1342
|
b.start(t2, "e", "")
|
|
1337
1343
|
b.finish(t3, TaskResult{ID: "t3", State: TaskDone})
|
|
1344
|
+
events = nil
|
|
1338
1345
|
|
|
1339
1346
|
// t1 刚刚有输出;t3 已收尾;t2 静默超过阈值。
|
|
1340
|
-
b.touch("t1")
|
|
1347
|
+
b.touch("t1", agent.StreamEvent{Kind: agent.KindText})
|
|
1341
1348
|
b.mu.Lock()
|
|
1342
1349
|
b.last["t2"] = time.Now().Add(-time.Minute)
|
|
1343
1350
|
b.mu.Unlock()
|
|
1344
1351
|
|
|
1345
|
-
|
|
1346
|
-
if len(
|
|
1347
|
-
t.Fatalf("
|
|
1352
|
+
b.quiet()
|
|
1353
|
+
if len(events) != 1 || events[0].Kind != agent.KindTasks {
|
|
1354
|
+
t.Fatalf("心跳应当整份重发一次任务列表快照,得到 %d 条 %v", len(events), events)
|
|
1355
|
+
}
|
|
1356
|
+
if !strings.Contains(events[0].Text, "无新输出") {
|
|
1357
|
+
t.Fatalf("快照里应当带上这一行的行内进度:%s", events[0].Text)
|
|
1358
|
+
}
|
|
1359
|
+
b.mu.Lock()
|
|
1360
|
+
active1, active2, active3 := b.rows["t1"].Active, b.rows["t2"].Active, b.rows["t3"].Active
|
|
1361
|
+
b.mu.Unlock()
|
|
1362
|
+
if active2 == "" || active1 != "" || active3 != "" {
|
|
1363
|
+
t.Fatalf("只该给静默中的 t2 挂进度,得到 t1=%q t2=%q t3=%q", active1, active2, active3)
|
|
1348
1364
|
}
|
|
1349
|
-
|
|
1350
|
-
|
|
1365
|
+
|
|
1366
|
+
events = nil
|
|
1367
|
+
b.quiet()
|
|
1368
|
+
if len(events) != 0 {
|
|
1369
|
+
t.Fatalf("刚刷过的行不应立刻重复发快照:%v", events)
|
|
1351
1370
|
}
|
|
1352
1371
|
}
|
|
1353
1372
|
|
|
@@ -1363,24 +1382,58 @@ func TestBoardTouchKeepsRunningRowAlive(t *testing.T) {
|
|
|
1363
1382
|
b.last["t2"] = time.Now().Add(-time.Minute)
|
|
1364
1383
|
b.mu.Unlock()
|
|
1365
1384
|
|
|
1366
|
-
b.touch("t2")
|
|
1367
|
-
|
|
1368
|
-
t.Fatalf("刚有动静的任务不该被心跳播报:%v", got)
|
|
1369
|
-
}
|
|
1385
|
+
b.touch("t2", agent.StreamEvent{Kind: agent.KindText})
|
|
1386
|
+
b.quiet()
|
|
1370
1387
|
b.mu.Lock()
|
|
1371
1388
|
row := b.rows["t2"]
|
|
1372
1389
|
b.mu.Unlock()
|
|
1373
1390
|
if row == nil || row.Status != RowRunning {
|
|
1374
1391
|
t.Fatalf("内部事件不该改变这一行的状态,得到 %+v", row)
|
|
1375
1392
|
}
|
|
1393
|
+
if row.Active != "" {
|
|
1394
|
+
t.Fatalf("刚有动静的任务不该挂着「无新输出」:%q", row.Active)
|
|
1395
|
+
}
|
|
1376
1396
|
|
|
1377
1397
|
// 真正的收尾由 finish 落定(之后不再进心跳候选)。
|
|
1378
1398
|
b.finish(t2, TaskResult{ID: "t2", State: TaskDone})
|
|
1379
1399
|
b.mu.Lock()
|
|
1380
1400
|
b.last["t2"] = time.Now().Add(-time.Minute)
|
|
1381
1401
|
b.mu.Unlock()
|
|
1382
|
-
|
|
1383
|
-
|
|
1402
|
+
b.quiet()
|
|
1403
|
+
b.mu.Lock()
|
|
1404
|
+
row = b.rows["t2"]
|
|
1405
|
+
b.mu.Unlock()
|
|
1406
|
+
if row.Active != "" {
|
|
1407
|
+
t.Fatalf("已收尾的任务不该被心跳挂进度:%q", row.Active)
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
// 卡在授权上的子任务:行内进度必须说「等待授权」,而不是「已 N 秒无新输出」。
|
|
1412
|
+
// 回归:2026-10-22 实测 --team-permission accept-edits 下子引擎读任务书也要授权,
|
|
1413
|
+
// 两个子任务一起停在那里十几分钟,主会话里没有任何一处看得出来它在等人。
|
|
1414
|
+
func TestBoardTouchShowsPendingAsk(t *testing.T) {
|
|
1415
|
+
var events []agent.StreamEvent
|
|
1416
|
+
b := newBoard(func(ev agent.StreamEvent) { events = append(events, ev) })
|
|
1417
|
+
t1 := Task{ID: "t1", Kind: KindText, Title: "写介绍"}
|
|
1418
|
+
b.add(t1, Route{})
|
|
1419
|
+
b.start(t1, "e", "")
|
|
1420
|
+
events = nil
|
|
1421
|
+
|
|
1422
|
+
b.touch("t1", agent.StreamEvent{Kind: agent.KindAsk, Text: "需要授权:Read(等待用户选择)"})
|
|
1423
|
+
if len(events) != 1 || events[0].Kind != agent.KindTasks {
|
|
1424
|
+
t.Fatalf("卡在授权上要当场重发一次快照,得到 %d 条", len(events))
|
|
1425
|
+
}
|
|
1426
|
+
if !strings.Contains(events[0].Text, "等待用户选择") {
|
|
1427
|
+
t.Fatalf("快照里应当带着等待授权的说明:%s", events[0].Text)
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
// 有后续输出(用户放行 / 引擎自己继续)→ 行内进度清空。
|
|
1431
|
+
b.touch("t1", agent.StreamEvent{Kind: agent.KindText, Text: "继续"})
|
|
1432
|
+
b.mu.Lock()
|
|
1433
|
+
active := b.rows["t1"].Active
|
|
1434
|
+
b.mu.Unlock()
|
|
1435
|
+
if active != "" {
|
|
1436
|
+
t.Fatalf("恢复输出后不该还挂着等待授权:%q", active)
|
|
1384
1437
|
}
|
|
1385
1438
|
}
|
|
1386
1439
|
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|