magic-agent 1.0.46 → 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.
- package/CLIENT-CONTRACT.md +514 -7
- package/README.md +31 -2
- package/client-ui/pc/app.css +3 -41
- package/client-ui/pc/js/04-task-list.js +6 -2
- package/client-ui/pc/js/90-glue.js +23 -4
- package/client-ui/pc/js/92-composer.js +11 -4
- package/client-ui/pc/js/93-rail.js +178 -19
- package/client-ui/shared/conv/03-chat-message.js +145 -25
- package/client-ui/shared/conv/09-stream-apply.js +55 -6
- package/client-ui/shared/conv/19-attachments.js +70 -6
- package/client-ui/shared/conv/27-conv-ledger.js +18 -0
- package/client-ui/shared/conv/29-msg-variants.js +10 -0
- package/client-ui/shared/conv/README.md +6 -0
- package/client-ui/shared/conv/conv.css +77 -0
- package/client-ui/shared/conversation.css +229 -43
- package/client-ui/shared/conversation.js +1316 -131
- package/client-ui/shared/transport-http.js +164 -12
- package/client-ui-mobile/index.html +2168 -143
- package/internal/agent/capability.go +16 -2
- package/internal/agent/claude.go +3 -2
- package/internal/agent/codebuddy.go +7 -1
- package/internal/agent/codex_test.go +3 -0
- package/internal/agent/custom_models_test.go +5 -0
- package/internal/agent/dsh.go +18 -2
- package/internal/agent/dsh_test.go +53 -2
- package/internal/agent/engine_test.go +34 -19
- package/internal/agent/env_test.go +14 -9
- package/internal/agent/models_test.go +9 -0
- package/internal/agent/runcmd_test.go +24 -3
- package/internal/agent/stream.go +33 -0
- package/internal/cli/ask.go +94 -4
- package/internal/cli/contract_test.go +26 -0
- package/internal/cli/events.go +18 -7
- package/internal/cli/keepalive_test.go +69 -0
- package/internal/cli/requirements.go +119 -0
- package/internal/cli/requirements_test.go +36 -0
- package/internal/cli/root.go +56 -4
- package/internal/cli/session.go +64 -25
- package/internal/cli/sessionlog.go +76 -7
- package/internal/cli/sessionlog_test.go +58 -0
- package/internal/cli/team.go +374 -0
- package/internal/cli/team_session.go +156 -0
- package/internal/cli/team_test.go +360 -0
- package/internal/client/plugins.go +7 -4
- package/internal/client/requirements.go +309 -0
- package/internal/client/requirements_test.go +239 -0
- package/internal/client/service.go +545 -19
- package/internal/client/service_test.go +47 -2
- package/internal/client/session_events_test.go +320 -0
- package/internal/client/team_flags.go +204 -0
- package/internal/client/team_flags_test.go +254 -0
- package/internal/requirements/bootstrap.go +423 -0
- package/internal/requirements/consolidate.go +908 -0
- package/internal/requirements/doc.go +473 -0
- package/internal/requirements/memory.go +324 -0
- package/internal/requirements/requirements.go +169 -0
- package/internal/requirements/requirements_test.go +530 -0
- package/internal/requirements/store.go +289 -0
- package/internal/session/delete.go +8 -4
- package/internal/session/session.go +194 -17
- package/internal/session/session_append.go +4 -1
- package/internal/session/session_append_test.go +19 -0
- package/internal/session/session_mark_test.go +42 -3
- package/internal/session/session_test.go +43 -0
- package/internal/session/sessionlog.go +50 -34
- package/internal/session/sessionlog_read_test.go +69 -11
- package/internal/session/status.go +205 -0
- package/internal/session/status_test.go +144 -0
- package/internal/team/adaptive.go +293 -0
- package/internal/team/board.go +216 -0
- package/internal/team/event.go +28 -0
- package/internal/team/execute.go +648 -0
- package/internal/team/plan.go +903 -0
- package/internal/team/planner.go +294 -0
- package/internal/team/reduce.go +192 -0
- package/internal/team/role.go +334 -0
- package/internal/team/route.go +206 -0
- package/internal/team/schedule.go +241 -0
- package/internal/team/schedule_test.go +286 -0
- package/internal/team/subsession.go +27 -0
- package/internal/team/team.go +401 -0
- package/internal/team/team_test.go +1997 -0
- package/npm/bin/magic-agent.js +24 -1
- package/npm/bin/magic-client.js +20 -2
- package/npm/dist/darwin-arm64/magic-agent +0 -0
- package/npm/dist/darwin-arm64/magic-client +0 -0
- package/npm/dist/darwin-x64/magic-agent +0 -0
- package/npm/dist/darwin-x64/magic-client +0 -0
- package/npm/dist/linux-arm64/magic-agent +0 -0
- package/npm/dist/linux-arm64/magic-client +0 -0
- package/npm/dist/linux-x64/magic-agent +0 -0
- package/npm/dist/linux-x64/magic-client +0 -0
- package/npm/dist/win32-x64/magic-agent.exe +0 -0
- package/npm/dist/win32-x64/magic-client.exe +0 -0
- package/package.json +1 -1
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
package client
|
|
2
|
+
|
|
3
|
+
// requirements.go - 「会话自动聚合成需求」在插件侧的三个口子。
|
|
4
|
+
//
|
|
5
|
+
// 分工(与 core 的 §3.4.1 一一对应):
|
|
6
|
+
//
|
|
7
|
+
// GET /desk/requirements 读清单(纯读,不调模型)
|
|
8
|
+
// POST /desk/requirements/consolidate 手动「立即整理」一轮(首次 = 按目录初始化)
|
|
9
|
+
// 每轮 /desk/ask 收尾 → 防抖 12s 自动归纳该轮所在工作区(只增量,不初始化)
|
|
10
|
+
//
|
|
11
|
+
// 「首次 = 按目录初始化」的判断在 core(state.bootstrap_at),插件不重复实现:
|
|
12
|
+
// 手动请求不带 --requirement-auto,core 于是会先扫目录反推基线;自动路径带这个标记,
|
|
13
|
+
// 只消费上次游标之后的新会话 —— 否则每轮对话都可能悄悄花几分钟读代码。
|
|
14
|
+
//
|
|
15
|
+
// 为什么自动归纳放在插件、不放在 core:core 是**一次性进程**,一轮跑完就退出,
|
|
16
|
+
// 没有「一轮结束之后」这个概念;只有常驻的插件知道「这一轮刚刚收尾」。
|
|
17
|
+
// 而归纳本身仍在 core(`--consolidate-requirements`)—— 插件只负责挑时机。
|
|
18
|
+
//
|
|
19
|
+
// 为什么防抖 12s:用户连着追问三四句是常态,每句都归纳一次等于把同一段对话
|
|
20
|
+
// 喂给模型三四遍(既慢又容易把同一件事写成三四条)。12s 是业务项目实测下来
|
|
21
|
+
// 「一句说完、下一句还没打完」的经验值,期间再来一轮就顺延、合并成一次。
|
|
22
|
+
|
|
23
|
+
import (
|
|
24
|
+
"context"
|
|
25
|
+
"encoding/json"
|
|
26
|
+
"errors"
|
|
27
|
+
"fmt"
|
|
28
|
+
"io"
|
|
29
|
+
"net/http"
|
|
30
|
+
"os"
|
|
31
|
+
"os/exec"
|
|
32
|
+
"path/filepath"
|
|
33
|
+
"strings"
|
|
34
|
+
"sync"
|
|
35
|
+
"time"
|
|
36
|
+
|
|
37
|
+
"github.com/darren/magic-agent/internal/agent"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
// FeatureRequirements core 侧的能力位(与 internal/agent 的同名常量一致)。
|
|
41
|
+
//
|
|
42
|
+
// 为什么不直接引 agent.FeatureRequirements:插件本来就 import 了 agent(team_flags.go
|
|
43
|
+
// 引了 FeatureSessionTeam 之外的 team 请求类型),但这里刻意写成本包常量是为了让
|
|
44
|
+
// 「插件依赖哪些能力位」在 grep 时只有一处 —— 与 FeatureSessionTeam 的处理方式一致。
|
|
45
|
+
const FeatureRequirements = "feature.requirements"
|
|
46
|
+
|
|
47
|
+
// reqAutoDebounce 一轮收尾后等多久再归纳(期间再来一轮就顺延)。
|
|
48
|
+
const reqAutoDebounce = 12 * time.Second
|
|
49
|
+
|
|
50
|
+
// reqConsolidateTimeout 手动/自动归纳的上限。
|
|
51
|
+
//
|
|
52
|
+
// 比 proxyCoreTimeout(45s)长得多:一轮归纳是**一次或多次模型调用**(全量重放还会分片),
|
|
53
|
+
// core 侧单次调用默认 4 分钟。给到 10 分钟覆盖「首次回填一大段历史」这种最慢的情况。
|
|
54
|
+
const reqConsolidateTimeout = 10 * time.Minute
|
|
55
|
+
|
|
56
|
+
// reqAutoOffEnv 关掉自动归纳的环境变量(排查 / 省额度用)。
|
|
57
|
+
const reqAutoOffEnv = "MAGIC_CLIENT_NO_REQ_AUTO"
|
|
58
|
+
|
|
59
|
+
// handleRequirements GET /desk/requirements?ws=<ws_id|目录>:
|
|
60
|
+
// 转发 core 的需求清单。
|
|
61
|
+
//
|
|
62
|
+
// ws 留空 = 列全部工作区:移动端/PC 的需求页要一次拿全再在前端按工作区分组。
|
|
63
|
+
func (s *Service) handleRequirements(w http.ResponseWriter, r *http.Request) {
|
|
64
|
+
if r.Method != http.MethodGet {
|
|
65
|
+
writeErr(w, http.StatusMethodNotAllowed, "GET only")
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
/* 老 core 不认这个 flag(exit 2)。先查能力位并**说清楚为什么** ——
|
|
69
|
+
让用户看到「core 太旧,请升级」,比让它报一句 usage error 强得多。 */
|
|
70
|
+
if !hasStr(s.contract.Features, FeatureRequirements) {
|
|
71
|
+
writeErr(w, http.StatusNotImplemented,
|
|
72
|
+
"当前 core 不支持需求聚合(缺少 "+FeatureRequirements+" 能力位,请升级 magic-agent)")
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
ws := strings.TrimSpace(r.URL.Query().Get("ws"))
|
|
76
|
+
if ws == "" {
|
|
77
|
+
ws = "all"
|
|
78
|
+
}
|
|
79
|
+
s.proxyCore(w, r, "--requirements", ws)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// handleRequirementsConsolidate POST /desk/requirements/consolidate:
|
|
83
|
+
// 立即归纳一轮(用户点「整理」)。
|
|
84
|
+
//
|
|
85
|
+
// 请求体:{"workspace":"<ws_id|目录>", "force":false, "all":false,
|
|
86
|
+
//
|
|
87
|
+
// "engine":"claude", "model":"..."}
|
|
88
|
+
//
|
|
89
|
+
// workspace 留空 = 插件自己的工作目录(与 /desk/health 的 defaultWorkspace 一致 ——
|
|
90
|
+
// 那就是 core 不传 -w 时记录的 cwd)。
|
|
91
|
+
// engine / model 是客户端当前配置的默认引擎与模型(移动端「我的 → 默认引擎」):
|
|
92
|
+
// 「整理的引擎就是配置的默认引擎」—— 客户端传什么,core 就按什么跑,不再自动替换。
|
|
93
|
+
func (s *Service) handleRequirementsConsolidate(w http.ResponseWriter, r *http.Request) {
|
|
94
|
+
if r.Method != http.MethodPost {
|
|
95
|
+
writeErr(w, http.StatusMethodNotAllowed, "POST only")
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
if !hasStr(s.contract.Features, FeatureRequirements) {
|
|
99
|
+
writeErr(w, http.StatusNotImplemented,
|
|
100
|
+
"当前 core 不支持需求聚合(缺少 "+FeatureRequirements+" 能力位,请升级 magic-agent)")
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
var body struct {
|
|
104
|
+
Workspace string `json:"workspace,omitempty"`
|
|
105
|
+
Force bool `json:"force,omitempty"`
|
|
106
|
+
All bool `json:"all,omitempty"`
|
|
107
|
+
Engine string `json:"engine,omitempty"`
|
|
108
|
+
Model string `json:"model,omitempty"`
|
|
109
|
+
}
|
|
110
|
+
// 空 body 是合法调用(全部走默认),所以 Decode 的 EOF 不算错。
|
|
111
|
+
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&body); err != nil && err != io.EOF {
|
|
112
|
+
writeErr(w, http.StatusBadRequest, "请求体不是合法 JSON:"+err.Error())
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
ws := strings.TrimSpace(body.Workspace)
|
|
116
|
+
if ws == "" {
|
|
117
|
+
if wd, err := os.Getwd(); err == nil {
|
|
118
|
+
ws = wd
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if ws == "" {
|
|
122
|
+
writeErr(w, http.StatusBadRequest, "workspace 不能为空(传 ws id 或目录)")
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
engine := strings.TrimSpace(body.Engine)
|
|
126
|
+
model := strings.TrimSpace(body.Model)
|
|
127
|
+
if model != "" && engine == "" {
|
|
128
|
+
writeErr(w, http.StatusBadRequest, "model 不能脱离 engine 单独指定")
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
if engine != "" && !s.opts.Profile.AllowsEngine(engine) {
|
|
132
|
+
writeErr(w, http.StatusForbidden, fmt.Sprintf("profile 白名单不允许引擎 %q", engine))
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
out, err := s.runCoreTimed(r.Context(), reqConsolidateTimeout,
|
|
136
|
+
consolidateArgs(ws, body.Force, body.All, false, engine, model)...)
|
|
137
|
+
if err != nil {
|
|
138
|
+
code := http.StatusBadGateway
|
|
139
|
+
if isCoreTimeout(err) {
|
|
140
|
+
code = http.StatusGatewayTimeout
|
|
141
|
+
}
|
|
142
|
+
writeErr(w, code, fmt.Sprintf("%s --consolidate-requirements 失败:%v", s.opts.MagicAgent, err))
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
writeCoreJSON(w, out)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// consolidateArgs 组归纳命令。
|
|
149
|
+
//
|
|
150
|
+
// engine/model 来自客户端配置的默认引擎(手动「整理」):传了就用它,core 不再自动换。
|
|
151
|
+
// 自动归纳(auto=true)不传:后台没有「当前设备默认引擎」这个概念,让 core 走自己的
|
|
152
|
+
// 默认(增量用 llm;首次初始化也不会发生,因为 --requirement-auto 明确禁止)。
|
|
153
|
+
func consolidateArgs(ws string, force, all, auto bool, engine, model string) []string {
|
|
154
|
+
args := []string{"--consolidate-requirements", ws}
|
|
155
|
+
if force {
|
|
156
|
+
args = append(args, "--requirement-force")
|
|
157
|
+
}
|
|
158
|
+
if all {
|
|
159
|
+
args = append(args, "--requirement-all")
|
|
160
|
+
}
|
|
161
|
+
if auto {
|
|
162
|
+
// 自动路径只消费新会话,绝不触发首次目录初始化(那会每轮都读一遍代码)。
|
|
163
|
+
args = append(args, "--requirement-auto")
|
|
164
|
+
}
|
|
165
|
+
if e := strings.TrimSpace(engine); e != "" {
|
|
166
|
+
args = append(args, "--requirement-engine", e)
|
|
167
|
+
if m := strings.TrimSpace(model); m != "" {
|
|
168
|
+
args = append(args, "--requirement-model", m)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return args
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// runCoreTimed 与 runCore 同形,但超时可调(归纳不是 45s 能跑完的)。
|
|
175
|
+
func (s *Service) runCoreTimed(ctx context.Context, timeout time.Duration, args ...string) ([]byte, error) {
|
|
176
|
+
ctx, cancel := context.WithTimeout(ctx, timeout)
|
|
177
|
+
defer cancel()
|
|
178
|
+
cmd := exec.CommandContext(ctx, s.opts.MagicAgent, args...)
|
|
179
|
+
// 与 runCore 同一道保险:core 的后代进程握着管道时 Wait 不能永久挂住。
|
|
180
|
+
cmd.WaitDelay = 5 * time.Second
|
|
181
|
+
out, err := cmd.Output()
|
|
182
|
+
if err != nil {
|
|
183
|
+
if ctx.Err() != nil || errors.Is(err, exec.ErrWaitDelay) {
|
|
184
|
+
return nil, fmt.Errorf("%w(%s)", errCoreTimeout, timeout)
|
|
185
|
+
}
|
|
186
|
+
return nil, err
|
|
187
|
+
}
|
|
188
|
+
return out, nil
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// isCoreTimeout 判断错误链里是不是「等 core 超时」。
|
|
192
|
+
func isCoreTimeout(err error) bool {
|
|
193
|
+
return err != nil && strings.Contains(err.Error(), errCoreTimeout.Error())
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/* ─────────────── 轮末自动归纳(防抖 + 单飞) ─────────────── */
|
|
197
|
+
|
|
198
|
+
// pinnedWorkspace 这一轮算在哪个目录。
|
|
199
|
+
//
|
|
200
|
+
// 请求带 -w 用它;没带就是插件自己的 cwd(core spawn 时不换目录,Begin 记下的就是它)。
|
|
201
|
+
// 归一化到绝对路径:客户端可能传相对路径,而 core 侧的 workspace id 是
|
|
202
|
+
// sha1(绝对路径) —— 不归一就归到另一个工作区去了。
|
|
203
|
+
func pinnedWorkspace(reqWorkspace string) string {
|
|
204
|
+
d := strings.TrimSpace(reqWorkspace)
|
|
205
|
+
if d == "" {
|
|
206
|
+
if wd, err := os.Getwd(); err == nil {
|
|
207
|
+
d = wd
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if d == "" {
|
|
211
|
+
return ""
|
|
212
|
+
}
|
|
213
|
+
if abs, err := filepath.Abs(d); err == nil {
|
|
214
|
+
d = abs
|
|
215
|
+
}
|
|
216
|
+
return d
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// reqAuto 自动归纳的调度器:每个工作区一个防抖计时器 + 一个「正在跑」标记。
|
|
220
|
+
type reqAuto struct {
|
|
221
|
+
mu sync.Mutex
|
|
222
|
+
timers map[string]*time.Timer
|
|
223
|
+
running map[string]bool
|
|
224
|
+
queued map[string]bool // 归纳期间又来了新记忆 → 跑完补一轮
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// maybeAutoConsolidate 一轮对话收尾后调用(12s 内再来一轮就顺延)。
|
|
228
|
+
//
|
|
229
|
+
// ws 为空直接返回:不知道这条会话属于哪个目录时**不要猜** —— 归纳到错的工作区
|
|
230
|
+
// 比不归纳危险得多(会往用户的另一份需求文档里塞内容)。
|
|
231
|
+
func (s *Service) maybeAutoConsolidate(ws string) {
|
|
232
|
+
if strings.TrimSpace(os.Getenv(reqAutoOffEnv)) == "1" {
|
|
233
|
+
return
|
|
234
|
+
}
|
|
235
|
+
ws = strings.TrimSpace(ws)
|
|
236
|
+
if ws == "" || !hasStr(s.contract.Features, FeatureRequirements) {
|
|
237
|
+
return
|
|
238
|
+
}
|
|
239
|
+
s.reqAuto.mu.Lock()
|
|
240
|
+
defer s.reqAuto.mu.Unlock()
|
|
241
|
+
if s.reqAuto.timers == nil {
|
|
242
|
+
s.reqAuto.timers = map[string]*time.Timer{}
|
|
243
|
+
s.reqAuto.running = map[string]bool{}
|
|
244
|
+
s.reqAuto.queued = map[string]bool{}
|
|
245
|
+
}
|
|
246
|
+
if t := s.reqAuto.timers[ws]; t != nil {
|
|
247
|
+
t.Stop() // 顺延:这一批合并成一次
|
|
248
|
+
}
|
|
249
|
+
s.reqAuto.timers[ws] = time.AfterFunc(reqAutoDebounce, func() { s.runAutoConsolidate(ws) })
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// runAutoConsolidate 真正跑一次自动归纳(单飞 + 补跑)。
|
|
253
|
+
func (s *Service) runAutoConsolidate(ws string) {
|
|
254
|
+
s.reqAuto.mu.Lock()
|
|
255
|
+
// 防御:自动归纳也可能被测试 / 维护脚本直接调用(没经过 maybeAutoConsolidate
|
|
256
|
+
// 的 map 初始化)。这里补齐,避免 nil map 写入 panic。
|
|
257
|
+
if s.reqAuto.timers == nil {
|
|
258
|
+
s.reqAuto.timers = map[string]*time.Timer{}
|
|
259
|
+
s.reqAuto.running = map[string]bool{}
|
|
260
|
+
s.reqAuto.queued = map[string]bool{}
|
|
261
|
+
}
|
|
262
|
+
delete(s.reqAuto.timers, ws)
|
|
263
|
+
if s.reqAuto.running[ws] {
|
|
264
|
+
s.reqAuto.queued[ws] = true
|
|
265
|
+
s.reqAuto.mu.Unlock()
|
|
266
|
+
return
|
|
267
|
+
}
|
|
268
|
+
s.reqAuto.running[ws] = true
|
|
269
|
+
s.reqAuto.mu.Unlock()
|
|
270
|
+
|
|
271
|
+
// 归纳是后台行为:**不绑**任何 HTTP 请求的生命周期,用独立 context。
|
|
272
|
+
ctx, cancel := context.WithTimeout(context.Background(), reqConsolidateTimeout)
|
|
273
|
+
out, err := s.runCoreTimed(ctx, reqConsolidateTimeout, consolidateArgs(ws, false, false, true, "", "")...)
|
|
274
|
+
cancel()
|
|
275
|
+
if err != nil {
|
|
276
|
+
s.log.Printf("需求自动归纳失败 %s: %v", ws, err)
|
|
277
|
+
} else {
|
|
278
|
+
/* 结果只写日志:这是后台动作,用户没在等它。
|
|
279
|
+
但**要能事后查** —— 「刚才明明聊了,需求页却没变」的第一现场就是这里。 */
|
|
280
|
+
var res struct {
|
|
281
|
+
Noop bool `json:"noop"`
|
|
282
|
+
Updated int `json:"updated"`
|
|
283
|
+
Created int `json:"created"`
|
|
284
|
+
Pending int `json:"pending"`
|
|
285
|
+
Error string `json:"error"`
|
|
286
|
+
}
|
|
287
|
+
if json.Unmarshal(out, &res) == nil {
|
|
288
|
+
if res.Error != "" {
|
|
289
|
+
s.log.Printf("需求自动归纳未完成 %s: %s", ws, res.Error)
|
|
290
|
+
} else if !res.Noop {
|
|
291
|
+
s.log.Printf("需求自动归纳完成 %s: 更新 %d · 新建 %d · 待整理 %d",
|
|
292
|
+
ws, res.Updated, res.Created, res.Pending)
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
s.reqAuto.mu.Lock()
|
|
298
|
+
delete(s.reqAuto.running, ws)
|
|
299
|
+
again := s.reqAuto.queued[ws]
|
|
300
|
+
delete(s.reqAuto.queued, ws)
|
|
301
|
+
s.reqAuto.mu.Unlock()
|
|
302
|
+
if again {
|
|
303
|
+
// 归纳期间又来了新记忆:立刻补一轮(不重新防抖 —— 用户已经等过 12s 了)。
|
|
304
|
+
s.runAutoConsolidate(ws)
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// 编译期断言:能力位常量与 core 侧同值(改错一个字这里就编译不过)。
|
|
309
|
+
var _ = func() bool { return agent.FeatureRequirements == FeatureRequirements }
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
package client
|
|
2
|
+
|
|
3
|
+
// requirements_test.go - 插件侧需求接口的三条硬约束:
|
|
4
|
+
// · 老 core 缺能力位 → 501 且点名缺哪个位(不是 400/502 让人猜);
|
|
5
|
+
// · 读清单**绝不**触发归纳(只有 --requirements,没有 --consolidate-requirements);
|
|
6
|
+
// · 自动归纳只按「这一轮那个目录」触发,且 12s 内再来一轮只跑一次。
|
|
7
|
+
|
|
8
|
+
import (
|
|
9
|
+
"encoding/json"
|
|
10
|
+
"net/http"
|
|
11
|
+
"net/http/httptest"
|
|
12
|
+
"os"
|
|
13
|
+
"path/filepath"
|
|
14
|
+
"strings"
|
|
15
|
+
"testing"
|
|
16
|
+
"time"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
// reqCore 假 core:把收到的 flag 写进一个调用日志,便于断言「到底跑了哪些命令」。
|
|
20
|
+
func reqCore(t *testing.T, features string, logPath string) string {
|
|
21
|
+
t.Helper()
|
|
22
|
+
dir := t.TempDir()
|
|
23
|
+
path := filepath.Join(dir, "magic-agent")
|
|
24
|
+
script := `#!/bin/sh
|
|
25
|
+
echo "$*" >> ` + logPath + `
|
|
26
|
+
case "$*" in
|
|
27
|
+
*--contract*)
|
|
28
|
+
echo '{"contractVersion":2,"features":[` + features + `],"engines":[]}'
|
|
29
|
+
exit 0
|
|
30
|
+
;;
|
|
31
|
+
*--consolidate-requirements*)
|
|
32
|
+
echo '{"ok":true,"workspace":"ws-1","dir":"/x","updated":1,"created":0,"pending":0}'
|
|
33
|
+
exit 0
|
|
34
|
+
;;
|
|
35
|
+
*--requirements*)
|
|
36
|
+
echo '{"ok":true,"root":"/r","workspaces":[{"id":"ws-1","dir":"/x","name":"x","pending":1,"memory":2,"modules":[],"feature_count":0,"last_consolidated_at":0}]}'
|
|
37
|
+
exit 0
|
|
38
|
+
;;
|
|
39
|
+
esac
|
|
40
|
+
exit 0
|
|
41
|
+
`
|
|
42
|
+
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
|
|
43
|
+
t.Fatal(err)
|
|
44
|
+
}
|
|
45
|
+
return path
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
func TestRequirementsRouteNeedsFeature(t *testing.T) {
|
|
49
|
+
logPath := filepath.Join(t.TempDir(), "calls.log")
|
|
50
|
+
core := reqCore(t, `"feature.session.events","feature.session.control"`, logPath)
|
|
51
|
+
svc := newTestService(t, core)
|
|
52
|
+
|
|
53
|
+
req := httptest.NewRequest(http.MethodGet, "/desk/requirements?token=tok", nil)
|
|
54
|
+
rec := httptest.NewRecorder()
|
|
55
|
+
svc.Handler().ServeHTTP(rec, req)
|
|
56
|
+
if rec.Code != http.StatusNotImplemented {
|
|
57
|
+
t.Fatalf("缺能力位应回 501,实际 %d: %s", rec.Code, rec.Body.String())
|
|
58
|
+
}
|
|
59
|
+
if !strings.Contains(rec.Body.String(), FeatureRequirements) {
|
|
60
|
+
t.Errorf("报错应点名缺哪个能力位,实际 %s", rec.Body.String())
|
|
61
|
+
}
|
|
62
|
+
// 没跑任何 core 命令(除了启动时的 --contract)。
|
|
63
|
+
calls := readCalls(t, logPath)
|
|
64
|
+
for _, c := range calls {
|
|
65
|
+
if strings.Contains(c, "--requirements") {
|
|
66
|
+
t.Errorf("缺能力位时不该去调 core:%s", c)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
func TestRequirementsRouteForwardsWS(t *testing.T) {
|
|
72
|
+
logPath := filepath.Join(t.TempDir(), "calls.log")
|
|
73
|
+
core := reqCore(t, `"feature.session.events","feature.session.control",`+
|
|
74
|
+
`"`+FeatureRequirements+`"`, logPath)
|
|
75
|
+
svc := newTestService(t, core)
|
|
76
|
+
|
|
77
|
+
// 不给 ws → 传 all(列全部工作区)。
|
|
78
|
+
rec := httptest.NewRecorder()
|
|
79
|
+
svc.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/desk/requirements?token=tok", nil))
|
|
80
|
+
if rec.Code != http.StatusOK {
|
|
81
|
+
t.Fatalf("清单应 200,实际 %d: %s", rec.Code, rec.Body.String())
|
|
82
|
+
}
|
|
83
|
+
if !strings.Contains(rec.Body.String(), `"ws-1"`) {
|
|
84
|
+
t.Errorf("应原样转发 core 的清单,实际 %s", rec.Body.String())
|
|
85
|
+
}
|
|
86
|
+
// 给了 ws → 原样透传。
|
|
87
|
+
rec2 := httptest.NewRecorder()
|
|
88
|
+
svc.Handler().ServeHTTP(rec2, httptest.NewRequest(http.MethodGet, "/desk/requirements?token=tok&ws=ws-1", nil))
|
|
89
|
+
if rec2.Code != http.StatusOK {
|
|
90
|
+
t.Fatalf("按 ws 查应 200,实际 %d", rec2.Code)
|
|
91
|
+
}
|
|
92
|
+
var sawAll, sawWS bool
|
|
93
|
+
for _, c := range readCalls(t, logPath) {
|
|
94
|
+
if strings.Contains(c, "--requirements all") {
|
|
95
|
+
sawAll = true
|
|
96
|
+
}
|
|
97
|
+
if strings.Contains(c, "--requirements ws-1") {
|
|
98
|
+
sawWS = true
|
|
99
|
+
}
|
|
100
|
+
// 读清单绝不能顺带归纳:那会把「刷个列表」变成一次模型调用。
|
|
101
|
+
if strings.Contains(c, "--consolidate-requirements") {
|
|
102
|
+
t.Errorf("读清单不该触发归纳:%s", c)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if !sawAll || !sawWS {
|
|
106
|
+
t.Errorf("应分别透传 all 与 ws-1,实际 %v", readCalls(t, logPath))
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
func TestRequirementsConsolidateRoute(t *testing.T) {
|
|
111
|
+
logPath := filepath.Join(t.TempDir(), "calls.log")
|
|
112
|
+
core := reqCore(t, `"feature.session.events","feature.session.control",`+
|
|
113
|
+
`"`+FeatureRequirements+`"`, logPath)
|
|
114
|
+
svc := newTestService(t, core)
|
|
115
|
+
|
|
116
|
+
body := strings.NewReader(`{"workspace":"/proj","force":true,"engine":"claude","model":"opus-4.6"}`)
|
|
117
|
+
rec := httptest.NewRecorder()
|
|
118
|
+
svc.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodPost,
|
|
119
|
+
"/desk/requirements/consolidate?token=tok", body))
|
|
120
|
+
if rec.Code != http.StatusOK {
|
|
121
|
+
t.Fatalf("归纳应 200,实际 %d: %s", rec.Code, rec.Body.String())
|
|
122
|
+
}
|
|
123
|
+
var ok bool
|
|
124
|
+
for _, c := range readCalls(t, logPath) {
|
|
125
|
+
if strings.Contains(c, "--consolidate-requirements /proj --requirement-force") &&
|
|
126
|
+
strings.Contains(c, "--requirement-engine claude") &&
|
|
127
|
+
strings.Contains(c, "--requirement-model opus-4.6") {
|
|
128
|
+
ok = true
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if !ok {
|
|
132
|
+
t.Errorf("应透传目录 / force / 配置的默认引擎与模型,实际 %v", readCalls(t, logPath))
|
|
133
|
+
}
|
|
134
|
+
// 归纳的结果要能解出核心字段(客户端靠它显示「更新/新建/待整理」)。
|
|
135
|
+
var res struct {
|
|
136
|
+
OK bool `json:"ok"`
|
|
137
|
+
Updated int `json:"updated"`
|
|
138
|
+
}
|
|
139
|
+
if err := json.Unmarshal(rec.Body.Bytes(), &res); err != nil || !res.OK || res.Updated != 1 {
|
|
140
|
+
t.Errorf("响应应是 core 的归纳结果,实际 %s", rec.Body.String())
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
func TestMaybeAutoConsolidate(t *testing.T) {
|
|
145
|
+
logPath := filepath.Join(t.TempDir(), "calls.log")
|
|
146
|
+
core := reqCore(t, `"feature.session.events","feature.session.control",`+
|
|
147
|
+
`"`+FeatureRequirements+`"`, logPath)
|
|
148
|
+
svc := newTestService(t, core)
|
|
149
|
+
|
|
150
|
+
t.Run("空目录不触发", func(t *testing.T) {
|
|
151
|
+
svc.maybeAutoConsolidate("")
|
|
152
|
+
svc.reqAuto.mu.Lock()
|
|
153
|
+
n := len(svc.reqAuto.timers)
|
|
154
|
+
svc.reqAuto.mu.Unlock()
|
|
155
|
+
if n != 0 {
|
|
156
|
+
t.Errorf("不知道目录时不该猜(会往别人的文档里塞内容)")
|
|
157
|
+
}
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
t.Run("关了开关不触发", func(t *testing.T) {
|
|
161
|
+
t.Setenv(reqAutoOffEnv, "1")
|
|
162
|
+
svc.maybeAutoConsolidate("/proj")
|
|
163
|
+
svc.reqAuto.mu.Lock()
|
|
164
|
+
n := len(svc.reqAuto.timers)
|
|
165
|
+
svc.reqAuto.mu.Unlock()
|
|
166
|
+
if n != 0 {
|
|
167
|
+
t.Errorf("%s=1 时不该排期", reqAutoOffEnv)
|
|
168
|
+
}
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
t.Run("同一工作区连续触发只留一个计时器", func(t *testing.T) {
|
|
172
|
+
t.Setenv(reqAutoOffEnv, "")
|
|
173
|
+
svc.maybeAutoConsolidate("/proj")
|
|
174
|
+
svc.maybeAutoConsolidate("/proj")
|
|
175
|
+
svc.maybeAutoConsolidate("/other")
|
|
176
|
+
svc.reqAuto.mu.Lock()
|
|
177
|
+
n := len(svc.reqAuto.timers)
|
|
178
|
+
for _, timer := range svc.reqAuto.timers {
|
|
179
|
+
timer.Stop() // 别让 12s 后的归纳真跑起来
|
|
180
|
+
}
|
|
181
|
+
svc.reqAuto.mu.Unlock()
|
|
182
|
+
if n != 2 {
|
|
183
|
+
t.Errorf("应按工作区去重(2 个目录 = 2 个计时器),实际 %d", n)
|
|
184
|
+
}
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
func TestPinnedWorkspaceIsAbsolute(t *testing.T) {
|
|
189
|
+
wd, _ := os.Getwd()
|
|
190
|
+
if got := pinnedWorkspace(""); got != wd {
|
|
191
|
+
t.Errorf("空 = 插件自己的 cwd(core 记录的也是它),实际 %q", got)
|
|
192
|
+
}
|
|
193
|
+
if got := pinnedWorkspace("."); got != wd {
|
|
194
|
+
t.Errorf("相对路径要归一成绝对路径(ws id 是 sha1(绝对路径)),实际 %q", got)
|
|
195
|
+
}
|
|
196
|
+
if got := pinnedWorkspace("/tmp/x"); got != "/tmp/x" {
|
|
197
|
+
t.Errorf("绝对路径原样,实际 %q", got)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
func readCalls(t *testing.T, path string) []string {
|
|
202
|
+
t.Helper()
|
|
203
|
+
b, err := os.ReadFile(path)
|
|
204
|
+
if err != nil {
|
|
205
|
+
return nil
|
|
206
|
+
}
|
|
207
|
+
var out []string
|
|
208
|
+
for _, l := range strings.Split(string(b), "\n") {
|
|
209
|
+
if s := strings.TrimSpace(l); s != "" {
|
|
210
|
+
out = append(out, s)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return out
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// 防止误删防抖常量(12s 是产品口径,不是随手写的数字)。
|
|
217
|
+
var _ = func() bool { return reqAutoDebounce == 12*time.Second }
|
|
218
|
+
|
|
219
|
+
// TestAutoConsolidateMarksAuto 自动归纳必须显式带 --requirement-auto。
|
|
220
|
+
//
|
|
221
|
+
// 不带它 core 会把「首次自动归纳」当成用户手动整理,于是每轮都去扫目录、读代码
|
|
222
|
+
// (几分钟 + 一次带工具的模型调用)。这个标记是「自动路径绝不触发初始化」的开关。
|
|
223
|
+
func TestAutoConsolidateMarksAuto(t *testing.T) {
|
|
224
|
+
logPath := filepath.Join(t.TempDir(), "calls.log")
|
|
225
|
+
core := reqCore(t, `"feature.session.events","feature.session.control",`+
|
|
226
|
+
`"`+FeatureRequirements+`"`, logPath)
|
|
227
|
+
svc := newTestService(t, core)
|
|
228
|
+
|
|
229
|
+
svc.runAutoConsolidate("/proj")
|
|
230
|
+
found := false
|
|
231
|
+
for _, c := range readCalls(t, logPath) {
|
|
232
|
+
if strings.Contains(c, "--consolidate-requirements /proj") && strings.Contains(c, "--requirement-auto") {
|
|
233
|
+
found = true
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if !found {
|
|
237
|
+
t.Errorf("自动归纳应带 --requirement-auto,实际 %v", readCalls(t, logPath))
|
|
238
|
+
}
|
|
239
|
+
}
|