magic-agent 2.4.2 → 2.6.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 +290 -30
- package/README.md +15 -14
- package/client-ui/pc/app.css +32 -0
- package/client-ui/pc/js/04-task-list.js +5 -1
- package/client-ui/pc/js/07-chat-stream-render.js +3 -3
- package/client-ui/pc/js/90-glue.js +37 -2
- package/client-ui/pc/js/92-composer.js +19 -4
- package/client-ui/pc/js/93-rail.js +16 -5
- package/client-ui/shared/conv/02-media-kinds.js +2 -2
- package/client-ui/shared/conv/03-chat-message.js +241 -23
- package/client-ui/shared/conv/06-ask-answer.js +75 -1
- package/client-ui/shared/conv/09-stream-apply.js +63 -7
- package/client-ui/shared/conv/19-attachments.js +157 -6
- package/client-ui/shared/conv/27-conv-ledger.js +5 -2
- package/client-ui/shared/conv/29-msg-variants.js +81 -0
- package/client-ui/shared/conv/conv.css +79 -19
- package/client-ui/shared/conv/dom-map.md +1 -0
- package/client-ui/shared/conversation.css +13 -2
- package/client-ui/shared/conversation.js +336 -50
- package/client-ui/shared/transport-http.js +14 -0
- package/client-ui-mobile/index.html +2932 -333
- package/client-ui-mobile/reqs.css +102 -0
- package/client-ui-mobile/reqs.js +485 -70
- package/client-ui-mobile/reqshot.js +505 -0
- package/client-ui-mobile/scripts.css +21 -0
- package/client-ui-mobile/scripts.js +539 -0
- package/client-ui-mobile/vendor/cose-base.js +3214 -0
- package/client-ui-mobile/vendor/cytoscape-fcose.js +1549 -0
- package/client-ui-mobile/vendor/cytoscape.min.js +32 -0
- package/client-ui-mobile/vendor/layout-base.js +5230 -0
- package/cmd/magic-client/browsectl.go +199 -0
- package/cmd/magic-client/clientctl.go +33 -9
- package/cmd/magic-client/main.go +57 -26
- package/internal/agent/ask.go +45 -0
- package/internal/agent/ask_test.go +39 -0
- package/internal/agent/attachments_test.go +6 -2
- package/internal/agent/capability.go +7 -1
- package/internal/agent/claude.go +20 -15
- package/internal/agent/codebuddy.go +125 -34
- package/internal/agent/codebuddy_account_test.go +54 -0
- package/internal/agent/codebuddy_stream_test.go +103 -0
- package/internal/agent/credits.go +713 -0
- package/internal/agent/credits_minimax_test.go +97 -0
- package/internal/agent/credits_test.go +211 -0
- package/internal/agent/custom_models.go +155 -0
- package/internal/agent/custom_models_test.go +125 -0
- package/internal/agent/dsh.go +123 -49
- package/internal/agent/dsh_resume.go +13 -2
- package/internal/agent/dsh_test.go +108 -6
- package/internal/agent/engine.go +37 -1
- package/internal/agent/engine_base.go +21 -17
- package/internal/agent/engine_base_test.go +2 -2
- package/internal/agent/engine_test.go +47 -86
- package/internal/agent/fallback.go +313 -0
- package/internal/agent/fallback_test.go +340 -0
- package/internal/agent/launch.go +43 -1
- package/internal/agent/llmhttp.go +46 -4
- package/internal/agent/llmhttp_test.go +157 -0
- package/internal/agent/models.go +53 -1
- package/internal/agent/models_test.go +20 -5
- package/internal/agent/pidalive_windows_test.go +40 -0
- package/internal/agent/runcmd.go +26 -0
- package/internal/agent/runcmd_windows.go +36 -5
- package/internal/agent/stream.go +136 -6
- package/internal/agent/stream_test.go +25 -38
- package/internal/agent/stream_timeline_test.go +2 -37
- package/internal/agent/streamjson.go +108 -6
- package/internal/agent/tools_suffix_test.go +6 -3
- package/internal/agent/trae.go +50 -243
- package/internal/agent/trae_exec.go +433 -0
- package/internal/agent/trae_exec_test.go +181 -0
- package/internal/agent/wraplaunch_test.go +113 -0
- package/internal/cli/ask.go +160 -37
- package/internal/cli/ask_test.go +1 -1
- package/internal/cli/checkin.go +77 -0
- package/internal/cli/clientcmd.go +3 -2
- package/internal/cli/control.go +46 -5
- package/internal/cli/control_test.go +65 -0
- package/internal/cli/events.go +4 -0
- package/internal/cli/quotas.go +125 -0
- package/internal/cli/requirements.go +51 -0
- package/internal/cli/root.go +122 -5
- package/internal/cli/session.go +7 -10
- package/internal/cli/suggest.go +7 -5
- package/internal/cli/team.go +37 -9
- package/internal/cli/team_test.go +39 -0
- package/internal/client/ab.go +372 -0
- package/internal/client/ab_test.go +287 -0
- package/internal/client/browse.go +84 -36
- package/internal/client/browse_test.go +107 -9
- package/internal/client/browseinput.go +481 -0
- package/internal/client/browseinput_test.go +216 -0
- package/internal/client/corecmd.go +23 -0
- package/internal/client/corecmd_other.go +8 -0
- package/internal/client/corecmd_windows.go +24 -0
- package/internal/client/daemon.go +90 -17
- package/internal/client/daemon_test.go +33 -0
- package/internal/client/engines_checkin_test.go +57 -0
- package/internal/client/fallback_flags_test.go +64 -0
- package/internal/client/jobs.go +294 -21
- package/internal/client/jobs_test.go +299 -2
- package/internal/client/knowledge.go +854 -0
- package/internal/client/knowledge_test.go +510 -0
- package/internal/client/lan.go +193 -7
- package/internal/client/lan_test.go +110 -0
- package/internal/client/quotas_merge_test.go +107 -0
- package/internal/client/requirements.go +198 -1
- package/internal/client/requirements_shots_test.go +200 -0
- package/internal/client/restart.go +134 -0
- package/internal/client/restart_test.go +151 -0
- package/internal/client/scripts.go +1047 -0
- package/internal/client/scripts_decode_other.go +27 -0
- package/internal/client/scripts_decode_test.go +125 -0
- package/internal/client/scripts_decode_windows.go +206 -0
- package/internal/client/scripts_test.go +726 -0
- package/internal/client/service.go +322 -18
- package/internal/client/service_test.go +51 -0
- package/internal/client/suggest.go +105 -2
- package/internal/client/suggest_test.go +86 -0
- package/internal/client/team_flags.go +25 -11
- package/internal/client/team_flags_test.go +39 -0
- package/internal/requirements/bootstrap.go +16 -0
- package/internal/requirements/consolidate.go +100 -9
- package/internal/requirements/doc.go +154 -3
- package/internal/requirements/graph.go +138 -0
- package/internal/requirements/memory.go +7 -0
- package/internal/requirements/modules.go +312 -0
- package/internal/requirements/modules_test.go +373 -0
- package/internal/requirements/relations.go +155 -0
- package/internal/requirements/relations_test.go +231 -0
- package/internal/requirements/requirements.go +10 -0
- package/internal/requirements/shots.go +418 -0
- package/internal/requirements/shots_test.go +387 -0
- package/internal/session/session.go +92 -0
- package/internal/session/sessionlog.go +23 -0
- package/internal/session/sessionlog_read_test.go +39 -0
- package/internal/suggest/suggest.go +23 -1
- package/internal/team/event.go +3 -1
- package/internal/team/execute.go +21 -6
- package/internal/team/fallback_route_test.go +80 -0
- package/internal/team/planner.go +15 -3
- package/internal/team/route.go +89 -17
- package/internal/team/team.go +18 -4
- package/npm/apk/magic-agent-mobile-release.apk +0 -0
- package/npm/apk/version.json +1 -5
- 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 +8 -2
|
@@ -15,11 +15,13 @@ package client
|
|
|
15
15
|
import (
|
|
16
16
|
"bytes"
|
|
17
17
|
"context"
|
|
18
|
+
"crypto/sha256"
|
|
19
|
+
"encoding/hex"
|
|
18
20
|
"encoding/json"
|
|
19
21
|
"io"
|
|
20
22
|
"net/http"
|
|
21
|
-
"os/exec"
|
|
22
23
|
"strings"
|
|
24
|
+
"sync"
|
|
23
25
|
"time"
|
|
24
26
|
"unicode/utf8"
|
|
25
27
|
|
|
@@ -41,6 +43,77 @@ const (
|
|
|
41
43
|
// 2 = 允许「当前会话收尾」与「刚切过去的另一个会话」各一条,超出直接 429。
|
|
42
44
|
var suggestSlots = make(chan struct{}, 2)
|
|
43
45
|
|
|
46
|
+
// 建议缓存:同一轮问答(user+reply+引擎/模型都相同)的建议只跑一次模型调用。
|
|
47
|
+
// 界面在会话切换 / 重渲染时会重复请求同一轮的建议,没缓存就每次都起一个旁路
|
|
48
|
+
// 引擎进程白烧一次调用(用户 2026-10-24:「会话的建议要有缓存 不能每次都重复跑」)。
|
|
49
|
+
// 只缓存**成功**响应(含空数组——空也是合法结果);失败不缓存,下次重试。
|
|
50
|
+
const (
|
|
51
|
+
suggestCacheTTL = 30 * time.Minute
|
|
52
|
+
suggestCacheMaxSize = 128
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
type suggestCacheEntry struct {
|
|
56
|
+
payload []byte // 成功响应的 JSON(已保证 Suggestions 非 nil)
|
|
57
|
+
expireAt time.Time
|
|
58
|
+
usedAt time.Time // LRU 淘汰用
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
var suggestCache = struct {
|
|
62
|
+
sync.Mutex
|
|
63
|
+
m map[string]suggestCacheEntry
|
|
64
|
+
}{m: make(map[string]suggestCacheEntry)}
|
|
65
|
+
|
|
66
|
+
// suggestCacheKey 按完整请求内容算 key:user/reply 一样但换了引擎/模型就分开算。
|
|
67
|
+
func suggestCacheKey(body struct {
|
|
68
|
+
User string `json:"user"`
|
|
69
|
+
Reply string `json:"reply"`
|
|
70
|
+
Engine string `json:"engine"`
|
|
71
|
+
Model string `json:"model"`
|
|
72
|
+
FallbackEngine string `json:"fallbackEngine"`
|
|
73
|
+
FallbackModel string `json:"fallbackModel"`
|
|
74
|
+
}) string {
|
|
75
|
+
h := sha256.Sum256([]byte(strings.Join([]string{
|
|
76
|
+
body.User, body.Reply, body.Engine, body.Model, body.FallbackEngine, body.FallbackModel,
|
|
77
|
+
}, "\x00")))
|
|
78
|
+
return hex.EncodeToString(h[:])
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// suggestCacheGet 命中返回响应 JSON;顺带做惰性过期与 LRU 容量控制。
|
|
82
|
+
func suggestCacheGet(key string) ([]byte, bool) {
|
|
83
|
+
suggestCache.Lock()
|
|
84
|
+
defer suggestCache.Unlock()
|
|
85
|
+
e, ok := suggestCache.m[key]
|
|
86
|
+
if !ok {
|
|
87
|
+
return nil, false
|
|
88
|
+
}
|
|
89
|
+
if time.Now().After(e.expireAt) {
|
|
90
|
+
delete(suggestCache.m, key)
|
|
91
|
+
return nil, false
|
|
92
|
+
}
|
|
93
|
+
e.usedAt = time.Now()
|
|
94
|
+
suggestCache.m[key] = e
|
|
95
|
+
return e.payload, true
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
func suggestCachePut(key string, payload []byte) {
|
|
99
|
+
suggestCache.Lock()
|
|
100
|
+
defer suggestCache.Unlock()
|
|
101
|
+
if len(suggestCache.m) >= suggestCacheMaxSize {
|
|
102
|
+
// 淘汰最久未用的一条(缓存最多 128 条,线性找足够快)。
|
|
103
|
+
var oldestKey string
|
|
104
|
+
var oldest time.Time
|
|
105
|
+
first := true
|
|
106
|
+
for k, v := range suggestCache.m {
|
|
107
|
+
if first || v.usedAt.Before(oldest) {
|
|
108
|
+
oldestKey, oldest, first = k, v.usedAt, false
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
delete(suggestCache.m, oldestKey)
|
|
112
|
+
}
|
|
113
|
+
now := time.Now()
|
|
114
|
+
suggestCache.m[key] = suggestCacheEntry{payload: payload, expireAt: now.Add(suggestCacheTTL), usedAt: now}
|
|
115
|
+
}
|
|
116
|
+
|
|
44
117
|
// handleSuggest POST /desk/suggest。
|
|
45
118
|
//
|
|
46
119
|
// 请求:{"user":"用户上一句","reply":"本轮回答正文","engine":"可选","model":"可选"}
|
|
@@ -55,6 +128,10 @@ func (s *Service) handleSuggest(w http.ResponseWriter, r *http.Request) {
|
|
|
55
128
|
Reply string `json:"reply"`
|
|
56
129
|
Engine string `json:"engine"`
|
|
57
130
|
Model string `json:"model"`
|
|
131
|
+
// 建议引擎的主备(2026-10-24,只增字段):主建议引擎失败时先试它,
|
|
132
|
+
// 再落到 core 内置的 llm 兜底。空 = 老行为。
|
|
133
|
+
FallbackEngine string `json:"fallbackEngine"`
|
|
134
|
+
FallbackModel string `json:"fallbackModel"`
|
|
58
135
|
}
|
|
59
136
|
if r.Body != nil {
|
|
60
137
|
if err := json.NewDecoder(io.LimitReader(r.Body, suggestMaxBody)).Decode(&body); err != nil {
|
|
@@ -70,6 +147,14 @@ func (s *Service) handleSuggest(w http.ResponseWriter, r *http.Request) {
|
|
|
70
147
|
body.User = clipRunes(strings.TrimSpace(body.User), suggestMaxUserRunes)
|
|
71
148
|
body.Reply = clipRunes(body.Reply, suggestMaxReplyRunes)
|
|
72
149
|
|
|
150
|
+
// 同一轮问答的重复请求直接回缓存(切换会话/重渲染会反复要同一轮的建议)。
|
|
151
|
+
cacheKey := suggestCacheKey(body)
|
|
152
|
+
if payload, ok := suggestCacheGet(cacheKey); ok {
|
|
153
|
+
w.Header().Set("X-Suggest-Cache", "hit")
|
|
154
|
+
writeJSONBytes(w, http.StatusOK, payload)
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
|
|
73
158
|
select {
|
|
74
159
|
case suggestSlots <- struct{}{}:
|
|
75
160
|
defer func() { <-suggestSlots }()
|
|
@@ -90,7 +175,13 @@ func (s *Service) handleSuggest(w http.ResponseWriter, r *http.Request) {
|
|
|
90
175
|
if m := strings.TrimSpace(body.Model); m != "" {
|
|
91
176
|
args = append(args, "-m", m)
|
|
92
177
|
}
|
|
93
|
-
|
|
178
|
+
if fb := strings.TrimSpace(body.FallbackEngine); fb != "" {
|
|
179
|
+
args = append(args, "--suggest-fallback-engine", fb)
|
|
180
|
+
if m := strings.TrimSpace(body.FallbackModel); m != "" {
|
|
181
|
+
args = append(args, "--suggest-fallback-model", m)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
cmd := newCoreCmd(ctx, s.opts.MagicAgent, args...)
|
|
94
185
|
cmd.Stdin = bytes.NewReader(payload)
|
|
95
186
|
var stdout, stderr bytes.Buffer
|
|
96
187
|
cmd.Stdout = &stdout
|
|
@@ -119,9 +210,21 @@ func (s *Service) handleSuggest(w http.ResponseWriter, r *http.Request) {
|
|
|
119
210
|
if res.Suggestions == nil {
|
|
120
211
|
res.Suggestions = []string{}
|
|
121
212
|
}
|
|
213
|
+
if payload, err := json.Marshal(res); err == nil {
|
|
214
|
+
suggestCachePut(cacheKey, payload)
|
|
215
|
+
writeJSONBytes(w, http.StatusOK, payload)
|
|
216
|
+
return
|
|
217
|
+
}
|
|
122
218
|
writeJSON(w, http.StatusOK, res)
|
|
123
219
|
}
|
|
124
220
|
|
|
221
|
+
// writeJSONBytes 按预序列化的字节写响应(缓存命中与缓存写入共用同一份形状)。
|
|
222
|
+
func writeJSONBytes(w http.ResponseWriter, code int, payload []byte) {
|
|
223
|
+
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
224
|
+
w.WriteHeader(code)
|
|
225
|
+
_, _ = w.Write(payload)
|
|
226
|
+
}
|
|
227
|
+
|
|
125
228
|
// clipRunes 按 rune 截断(中文不能按字节切)。
|
|
126
229
|
func clipRunes(s string, max int) string {
|
|
127
230
|
if max <= 0 || utf8.RuneCountInString(s) <= max {
|
|
@@ -6,15 +6,31 @@ import (
|
|
|
6
6
|
"net/http/httptest"
|
|
7
7
|
"os"
|
|
8
8
|
"path/filepath"
|
|
9
|
+
"runtime"
|
|
9
10
|
"strings"
|
|
10
11
|
"testing"
|
|
11
12
|
)
|
|
12
13
|
|
|
13
14
|
// fakeSuggestCore 写一个假 core 脚本:记录 argv → 吃掉 stdin → 打印一份建议 JSON。
|
|
15
|
+
//
|
|
16
|
+
// Windows 上 exec 不了 sh 脚本(既有基线),改写等价的 .cmd 批处理 —— scriptBody
|
|
17
|
+
// 仍收 sh 语法,由映射表翻成 cmd 等价物;没翻译的写法直接 Fatal,防止静默跑错。
|
|
14
18
|
func fakeSuggestCore(t *testing.T, dir, scriptBody string) (string, string) {
|
|
15
19
|
t.Helper()
|
|
16
20
|
argLog := filepath.Join(dir, "args.txt")
|
|
17
21
|
p := filepath.Join(dir, "magic-agent")
|
|
22
|
+
if runtime.GOOS == "windows" {
|
|
23
|
+
p += ".cmd"
|
|
24
|
+
win, ok := shToCmd[scriptBody]
|
|
25
|
+
if !ok {
|
|
26
|
+
t.Fatalf("没给该 sh 脚本体准备 Windows 等价物:%q", scriptBody)
|
|
27
|
+
}
|
|
28
|
+
body := "@echo off\r\necho %*>\"" + argLog + "\"\r\nfind /v \"\" >NUL\r\n" + win + "\r\n"
|
|
29
|
+
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
|
|
30
|
+
t.Fatal(err)
|
|
31
|
+
}
|
|
32
|
+
return p, argLog
|
|
33
|
+
}
|
|
18
34
|
body := "#!/bin/sh\nprintf '%s' \"$*\" > " + argLog + "\ncat >/dev/null\n" + scriptBody + "\n"
|
|
19
35
|
if err := os.WriteFile(p, []byte(body), 0o755); err != nil {
|
|
20
36
|
t.Fatal(err)
|
|
@@ -22,6 +38,15 @@ func fakeSuggestCore(t *testing.T, dir, scriptBody string) (string, string) {
|
|
|
22
38
|
return p, argLog
|
|
23
39
|
}
|
|
24
40
|
|
|
41
|
+
// shToCmd 测试里用到的假 core 脚本体的 cmd 等价写法。
|
|
42
|
+
var shToCmd = map[string]string{
|
|
43
|
+
`printf '{"suggestions":["好的,执行吧","先看一下 diff"],"engine":"fake","elapsedMs":12}\n'`: `echo {"suggestions":["好的,执行吧","先看一下 diff"],"engine":"fake","elapsedMs":12}`,
|
|
44
|
+
`printf '{"suggestions":["继续"],"engine":"fake","elapsedMs":9}\n'`: `echo {"suggestions":["继续"],"engine":"fake","elapsedMs":9}`,
|
|
45
|
+
`printf '{"suggestions":[]}\n'`: `echo {"suggestions":[]}`,
|
|
46
|
+
"echo 'llm CLI not found' >&2\nexit 3": `echo llm CLI not found 1>&2&exit /b 3`,
|
|
47
|
+
`printf 'not json\n'`: `echo not json`,
|
|
48
|
+
}
|
|
49
|
+
|
|
25
50
|
func TestSuggestEndpoint(t *testing.T) {
|
|
26
51
|
dir := t.TempDir()
|
|
27
52
|
core, argLog := fakeSuggestCore(t, dir, `printf '{"suggestions":["好的,执行吧","先看一下 diff"],"engine":"fake","elapsedMs":12}\n'`)
|
|
@@ -53,6 +78,67 @@ func TestSuggestEndpoint(t *testing.T) {
|
|
|
53
78
|
}
|
|
54
79
|
}
|
|
55
80
|
|
|
81
|
+
func TestSuggestEndpointCacheHit(t *testing.T) {
|
|
82
|
+
dir := t.TempDir()
|
|
83
|
+
core, _ := fakeSuggestCore(t, dir, `printf '{"suggestions":["继续"],"engine":"fake","elapsedMs":9}\n'`)
|
|
84
|
+
s := &Service{opts: Options{MagicAgent: core}}
|
|
85
|
+
body := `{"user":"改完了吗","reply":"都改完了,要我继续吗?","engine":"fake-engine"}`
|
|
86
|
+
|
|
87
|
+
call := func() *httptest.ResponseRecorder {
|
|
88
|
+
rec := httptest.NewRecorder()
|
|
89
|
+
s.handleSuggest(rec, httptest.NewRequest(http.MethodPost, "/desk/suggest", strings.NewReader(body)))
|
|
90
|
+
return rec
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
first := call()
|
|
94
|
+
if first.Code != http.StatusOK {
|
|
95
|
+
t.Fatalf("首次 code = %d,body=%s", first.Code, first.Body.String())
|
|
96
|
+
}
|
|
97
|
+
if first.Header().Get("X-Suggest-Cache") == "hit" {
|
|
98
|
+
t.Fatalf("首次请求不应命中缓存")
|
|
99
|
+
}
|
|
100
|
+
second := call()
|
|
101
|
+
if second.Code != http.StatusOK {
|
|
102
|
+
t.Fatalf("二次 code = %d,body=%s", second.Code, second.Body.String())
|
|
103
|
+
}
|
|
104
|
+
if second.Header().Get("X-Suggest-Cache") != "hit" {
|
|
105
|
+
t.Fatalf("同内容的第二次请求应命中缓存")
|
|
106
|
+
}
|
|
107
|
+
if second.Body.String() != first.Body.String() {
|
|
108
|
+
t.Fatalf("缓存响应与首次不一致:%s vs %s", second.Body.String(), first.Body.String())
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// 换引擎 = 换 key,不能复用旧缓存。
|
|
112
|
+
third := httptest.NewRecorder()
|
|
113
|
+
s.handleSuggest(third, httptest.NewRequest(http.MethodPost, "/desk/suggest",
|
|
114
|
+
strings.NewReader(`{"user":"改完了吗","reply":"都改完了,要我继续吗?","engine":"other-engine"}`)))
|
|
115
|
+
if third.Header().Get("X-Suggest-Cache") == "hit" {
|
|
116
|
+
t.Fatalf("换了引擎应重新生成,不该命中缓存")
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// 失败不缓存:core 挂了下次要重试。
|
|
120
|
+
failing := &Service{opts: Options{MagicAgent: fakeSuggestCoreFailing(t, dir)}}
|
|
121
|
+
f1 := httptest.NewRecorder()
|
|
122
|
+
failing.handleSuggest(f1, httptest.NewRequest(http.MethodPost, "/desk/suggest",
|
|
123
|
+
strings.NewReader(`{"user":"x","reply":"失败那轮"}`)))
|
|
124
|
+
if f1.Code != http.StatusBadGateway {
|
|
125
|
+
t.Fatalf("失败轮 code = %d,期望 502", f1.Code)
|
|
126
|
+
}
|
|
127
|
+
f2 := httptest.NewRecorder()
|
|
128
|
+
failing.handleSuggest(f2, httptest.NewRequest(http.MethodPost, "/desk/suggest",
|
|
129
|
+
strings.NewReader(`{"user":"x","reply":"失败那轮"}`)))
|
|
130
|
+
if f2.Code != http.StatusBadGateway {
|
|
131
|
+
t.Fatalf("失败后重试仍应走 core(code = %d,期望 502)", f2.Code)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// fakeSuggestCoreFailing 恒定失败的假 core(复用 fakeSuggestCore 的平台适配)。
|
|
136
|
+
func fakeSuggestCoreFailing(t *testing.T, dir string) string {
|
|
137
|
+
t.Helper()
|
|
138
|
+
p, _ := fakeSuggestCore(t, dir, "echo 'llm CLI not found' >&2\nexit 3")
|
|
139
|
+
return p
|
|
140
|
+
}
|
|
141
|
+
|
|
56
142
|
func TestSuggestEndpointBadRequests(t *testing.T) {
|
|
57
143
|
dir := t.TempDir()
|
|
58
144
|
core, _ := fakeSuggestCore(t, dir, `printf '{"suggestions":[]}\n'`)
|
|
@@ -92,21 +92,35 @@ func teamFlags(t *teamRequest, profile Profile, roles []string) ([]string, error
|
|
|
92
92
|
r := t.Routes[rawKind]
|
|
93
93
|
engine := strings.TrimSpace(r.Engine)
|
|
94
94
|
model := strings.TrimSpace(r.Model)
|
|
95
|
-
|
|
95
|
+
fbEngine := strings.TrimSpace(r.FallbackEngine)
|
|
96
|
+
fbModel := strings.TrimSpace(r.FallbackModel)
|
|
97
|
+
/* 备用可以单独配(主引擎留空 = 跟随主引擎 + 这一路的备):所以这里不能用
|
|
98
|
+
「engine 为空就 continue」把整条略掉 —— 有备用就得下发。 */
|
|
99
|
+
if engine != "" {
|
|
100
|
+
if !profile.AllowsEngine(engine) {
|
|
101
|
+
return nil, fmt.Errorf("team.routes.%s 的引擎 %q 不在 profile 白名单内", kind, engine)
|
|
102
|
+
}
|
|
103
|
+
spec := kind + "=" + engine
|
|
96
104
|
if model != "" {
|
|
97
|
-
|
|
98
|
-
return nil, fmt.Errorf("team.routes.%s 给了 model 但没给 engine", kind)
|
|
105
|
+
spec += "/" + model
|
|
99
106
|
}
|
|
100
|
-
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
return nil, fmt.Errorf("team.routes.%s
|
|
107
|
+
args = append(args, "--team-route", spec)
|
|
108
|
+
} else if model != "" {
|
|
109
|
+
// 只给模型不给引擎:语义不明(模型属于某一家引擎),不猜。
|
|
110
|
+
return nil, fmt.Errorf("team.routes.%s 给了 model 但没给 engine", kind)
|
|
104
111
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
112
|
+
if fbEngine != "" {
|
|
113
|
+
if !profile.AllowsEngine(fbEngine) {
|
|
114
|
+
return nil, fmt.Errorf("team.routes.%s 的备用引擎 %q 不在 profile 白名单内", kind, fbEngine)
|
|
115
|
+
}
|
|
116
|
+
spec := kind + "=" + fbEngine
|
|
117
|
+
if fbModel != "" {
|
|
118
|
+
spec += "/" + fbModel
|
|
119
|
+
}
|
|
120
|
+
args = append(args, "--team-fallback", spec)
|
|
121
|
+
} else if fbModel != "" {
|
|
122
|
+
return nil, fmt.Errorf("team.routes.%s 给了备用 model 但没给备用 engine", kind)
|
|
108
123
|
}
|
|
109
|
-
args = append(args, "--team-route", spec)
|
|
110
124
|
}
|
|
111
125
|
return args, nil
|
|
112
126
|
}
|
|
@@ -146,6 +146,45 @@ func TestTeamFlagsRejectsModelWithoutEngine(t *testing.T) {
|
|
|
146
146
|
}
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
+
func TestTeamFlagsFallbackRoute(t *testing.T) {
|
|
150
|
+
// 主备(2026-10-24):主 + 备都要翻成 flag;只配备用也要下发(主引擎跟随主引擎)。
|
|
151
|
+
args, err := teamFlags(&teamRequest{Routes: map[string]teamRoute{
|
|
152
|
+
"image": {Engine: "codebuddy", Model: "hy3", FallbackEngine: "dsh", FallbackModel: "v3"},
|
|
153
|
+
"text": {FallbackEngine: "claude"},
|
|
154
|
+
}}, Profile{}, nil)
|
|
155
|
+
if err != nil {
|
|
156
|
+
t.Fatalf("unexpected err: %v", err)
|
|
157
|
+
}
|
|
158
|
+
joined := strings.Join(args, " ")
|
|
159
|
+
if !strings.Contains(joined, "--team-route image=codebuddy/hy3") {
|
|
160
|
+
t.Fatalf("主引擎没下发:%v", args)
|
|
161
|
+
}
|
|
162
|
+
if !strings.Contains(joined, "--team-fallback image=dsh/v3") {
|
|
163
|
+
t.Fatalf("备用引擎没下发:%v", args)
|
|
164
|
+
}
|
|
165
|
+
if !strings.Contains(joined, "--team-fallback text=claude") {
|
|
166
|
+
t.Fatalf("只配备用的路由没下发:%v", args)
|
|
167
|
+
}
|
|
168
|
+
if strings.Contains(joined, "--team-route text=") {
|
|
169
|
+
t.Fatalf("只配备用不该伪造主引擎:%v", args)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
func TestTeamFlagsRejectsBadFallback(t *testing.T) {
|
|
174
|
+
// 备用引擎同样要过白名单。
|
|
175
|
+
if _, err := teamFlags(&teamRequest{Routes: map[string]teamRoute{
|
|
176
|
+
"image": {Engine: "llm", FallbackEngine: "claude"},
|
|
177
|
+
}}, Profile{Engines: []string{"llm"}}, nil); err == nil {
|
|
178
|
+
t.Fatal("备用引擎不在白名单时应报错")
|
|
179
|
+
}
|
|
180
|
+
// 备用 model 没有备用 engine:语义不明,不猜。
|
|
181
|
+
if _, err := teamFlags(&teamRequest{Routes: map[string]teamRoute{
|
|
182
|
+
"image": {FallbackModel: "sonnet"},
|
|
183
|
+
}}, Profile{}, nil); err == nil || !strings.Contains(err.Error(), "备用") {
|
|
184
|
+
t.Fatalf("err = %v,应提到备用", err)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
149
188
|
func TestTeamFlagsAcceptsPlanRoute(t *testing.T) {
|
|
150
189
|
// plan = 计划阶段(谁来拆任务),与五类子任务一样可以单独指定引擎。
|
|
151
190
|
args, err := teamFlags(&teamRequest{Routes: map[string]teamRoute{"plan": {Engine: "claude"}}}, Profile{}, nil)
|
|
@@ -187,6 +187,10 @@ type bootstrapResult struct {
|
|
|
187
187
|
Created int
|
|
188
188
|
Points int
|
|
189
189
|
Conclusions int
|
|
190
|
+
// Merged / MergedModules 模块去重合并掉的模块(目录基线自己也可能造近义模块,
|
|
191
|
+
// 之前 auto 归纳攒下的也顺手合并;见 modules.go)。
|
|
192
|
+
Merged int
|
|
193
|
+
MergedModules []string
|
|
190
194
|
}
|
|
191
195
|
|
|
192
196
|
// bootstrapWorkspace 按工作区目录反推一版需求基线。
|
|
@@ -275,6 +279,18 @@ func bootstrapWorkspace(ctx context.Context, ws workspaceEntry, opts Options) (b
|
|
|
275
279
|
与 features:[] 同样处理:标记初始化完成,回落到会话增量,不要把旧会话吞掉。 */
|
|
276
280
|
res.Empty = true
|
|
277
281
|
}
|
|
282
|
+
/* 模块去重:目录基线自己也可能造出近义模块(模型一次输出 5-20 个功能点),
|
|
283
|
+
之前 auto 归纳攒下的相似模块也顺手合并。用初始化引擎包一个文本调用器 ——
|
|
284
|
+
不能走 newCaller(测试钉死:首次初始化不碰文本归纳器)。 */
|
|
285
|
+
if mods := listModules(ws.ID); len(mods) >= 2 && len(similarModulePairs(mods)) > 0 {
|
|
286
|
+
mergeCall := func(prompt string, schema *agent.JSONSchema) (string, error) {
|
|
287
|
+
return runBootstrapAgent(ctx, engineName, bootstrapModel, ws.Dir, prompt, timeout)
|
|
288
|
+
}
|
|
289
|
+
if merged := dedupeModules(ws.ID, mergeCall); len(merged) > 0 {
|
|
290
|
+
res.Merged = len(merged)
|
|
291
|
+
res.MergedModules = merged
|
|
292
|
+
}
|
|
293
|
+
}
|
|
278
294
|
return res, nil
|
|
279
295
|
}
|
|
280
296
|
|
|
@@ -60,6 +60,9 @@ var outSchema = &agent.JSONSchema{
|
|
|
60
60
|
"points": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
|
|
61
61
|
"conclusions": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
|
|
62
62
|
"sources": map[string]any{"type": "array", "items": map[string]any{"type": "integer"}},
|
|
63
|
+
// page_url:该功能点对应的页面地址(**可选**)。
|
|
64
|
+
// 不进 required:多数功能点没有页面,硬要求会让模型编一个出来。
|
|
65
|
+
"page_url": map[string]any{"type": "string"},
|
|
63
66
|
},
|
|
64
67
|
"required": []string{"module", "name", "desc", "points", "conclusions", "sources"},
|
|
65
68
|
},
|
|
@@ -76,6 +79,8 @@ type featureOut struct {
|
|
|
76
79
|
Points []string
|
|
77
80
|
Conclusions []string
|
|
78
81
|
Sources []int
|
|
82
|
+
// PageURL 该功能点对应的页面地址(记忆里有才填,空 = 没有)。
|
|
83
|
+
PageURL string `json:"page_url"`
|
|
79
84
|
}
|
|
80
85
|
|
|
81
86
|
// UnmarshalJSON 容忍 sources 里混进字符串编号(模型偶尔给 "#12")。
|
|
@@ -87,12 +92,14 @@ func (f *featureOut) UnmarshalJSON(b []byte) error {
|
|
|
87
92
|
Points []string `json:"points"`
|
|
88
93
|
Conclusions []string `json:"conclusions"`
|
|
89
94
|
Sources []json.RawMessage `json:"sources"`
|
|
95
|
+
PageURL string `json:"page_url"`
|
|
90
96
|
}
|
|
91
97
|
if err := json.Unmarshal(b, &raw); err != nil {
|
|
92
98
|
return err
|
|
93
99
|
}
|
|
94
100
|
f.Module, f.Name, f.Desc = raw.Module, raw.Name, raw.Desc
|
|
95
101
|
f.Points, f.Conclusions = raw.Points, raw.Conclusions
|
|
102
|
+
f.PageURL = strings.TrimSpace(raw.PageURL)
|
|
96
103
|
for _, s := range raw.Sources {
|
|
97
104
|
var n int
|
|
98
105
|
if json.Unmarshal(s, &n) == nil {
|
|
@@ -181,6 +188,8 @@ func Consolidate(ctx context.Context, where string, opts Options) (Result, error
|
|
|
181
188
|
if boot.Called {
|
|
182
189
|
res.Calls++ // 反推是一次带工具的 agent 调用(与下面的归纳共用 calls 口径)
|
|
183
190
|
}
|
|
191
|
+
res.Merged += boot.Merged
|
|
192
|
+
res.MergedModules = append(res.MergedModules, boot.MergedModules...)
|
|
184
193
|
if !boot.Empty {
|
|
185
194
|
res.Updated += boot.Updated
|
|
186
195
|
res.Created += boot.Created
|
|
@@ -222,7 +231,30 @@ func Consolidate(ctx context.Context, where string, opts Options) (Result, error
|
|
|
222
231
|
if replay {
|
|
223
232
|
entries = mem
|
|
224
233
|
}
|
|
234
|
+
engineName := strings.TrimSpace(opts.Engine)
|
|
235
|
+
if engineName == "" {
|
|
236
|
+
engineName = "llm"
|
|
237
|
+
}
|
|
238
|
+
timeout := opts.Timeout
|
|
239
|
+
if timeout <= 0 {
|
|
240
|
+
timeout = DefaultTimeout
|
|
241
|
+
}
|
|
225
242
|
if len(entries) == 0 && !opts.Force {
|
|
243
|
+
/* 手动空间级「整理」:没有新记忆也顺手做一次模块去重 —— 用户点「整理」
|
|
244
|
+
就是想清理列表,只回一句 noop 等于没修(2026-10-25 报障:magic-agent
|
|
245
|
+
空间 12 个模块里躺着多组近义,点整理却只听到「没有新的对话需要整理」)。
|
|
246
|
+
构造不出 caller(引擎不存在等)就静默回落 noop,不改变现有行为。 */
|
|
247
|
+
if mods := listModules(ws.ID); moduleDedupeWanted(opts, res, mods) {
|
|
248
|
+
if call, cerr := newCaller(ctx, engineName, opts.Model, timeout); cerr == nil {
|
|
249
|
+
if merged := dedupeModules(ws.ID, call); len(merged) > 0 {
|
|
250
|
+
res.Merged = len(merged)
|
|
251
|
+
res.MergedModules = merged
|
|
252
|
+
res.OK = true
|
|
253
|
+
res.Pending = max(0, len(mem)-cursor)
|
|
254
|
+
return res, nil
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
226
258
|
res.OK = true
|
|
227
259
|
res.Noop = true
|
|
228
260
|
res.Pending = 0
|
|
@@ -238,14 +270,6 @@ func Consolidate(ctx context.Context, where string, opts Options) (Result, error
|
|
|
238
270
|
use = use[:MaxEntriesPerRun]
|
|
239
271
|
}
|
|
240
272
|
|
|
241
|
-
engineName := strings.TrimSpace(opts.Engine)
|
|
242
|
-
if engineName == "" {
|
|
243
|
-
engineName = "llm"
|
|
244
|
-
}
|
|
245
|
-
timeout := opts.Timeout
|
|
246
|
-
if timeout <= 0 {
|
|
247
|
-
timeout = DefaultTimeout
|
|
248
|
-
}
|
|
249
273
|
call, callErr := newCaller(ctx, engineName, opts.Model, timeout)
|
|
250
274
|
if callErr != nil {
|
|
251
275
|
res.Error = callErr.Error()
|
|
@@ -335,6 +359,24 @@ func Consolidate(ctx context.Context, where string, opts Options) (Result, error
|
|
|
335
359
|
}
|
|
336
360
|
return out
|
|
337
361
|
},
|
|
362
|
+
onWrite: func(module, feature string, sources []int) {
|
|
363
|
+
rels := make([]Relation, 0, len(sources))
|
|
364
|
+
for _, n := range sources {
|
|
365
|
+
e, ok := byN[n]
|
|
366
|
+
if !ok || e.Session == "" {
|
|
367
|
+
continue
|
|
368
|
+
}
|
|
369
|
+
rels = append(rels, Relation{
|
|
370
|
+
Ts: time.Now().UnixMilli(),
|
|
371
|
+
Session: e.Session,
|
|
372
|
+
STitle: e.Title,
|
|
373
|
+
Module: module,
|
|
374
|
+
Feature: feature,
|
|
375
|
+
N: n,
|
|
376
|
+
})
|
|
377
|
+
}
|
|
378
|
+
_ = appendRelations(ws.ID, rels)
|
|
379
|
+
},
|
|
338
380
|
})
|
|
339
381
|
if !w.ok {
|
|
340
382
|
continue
|
|
@@ -349,6 +391,18 @@ func Consolidate(ctx context.Context, where string, opts Options) (Result, error
|
|
|
349
391
|
}
|
|
350
392
|
}
|
|
351
393
|
|
|
394
|
+
/* 模块去重:把跨轮堆积的相似模块合并掉(「引擎管理 / 引擎调用」这种近义一级分组,
|
|
395
|
+
单靠循环里的「新模块二审」挡不住 —— 那只能管本轮新出现的名字)。模块级整理
|
|
396
|
+
不做(moduleDedupeWanted 里挡着),自动路径只在本轮真写了新文档时顺手做。 */
|
|
397
|
+
if mods := listModules(ws.ID); moduleDedupeWanted(opts, res, mods) {
|
|
398
|
+
if merged := dedupeModules(ws.ID, call); len(merged) > 0 {
|
|
399
|
+
res.Merged = len(merged)
|
|
400
|
+
res.MergedModules = merged
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
_ = compactRelations(ws.ID) // 归纳收尾去重一次(写入期只 append)
|
|
405
|
+
|
|
352
406
|
consumed := cursor
|
|
353
407
|
if !replay && moduleScope == "" {
|
|
354
408
|
consumed = cursor + len(use)
|
|
@@ -573,6 +627,9 @@ func buildPrompt(wsName string, docs []existsDoc, entries []Entry, modules []str
|
|
|
573
627
|
"不要当结论写进去;结论必须是「已经定下来的做法/取舍」。",
|
|
574
628
|
" 与「已有结论」语义相同的同样逐字照抄;**已有结论为空但记忆里有明确结论的必须补上**;",
|
|
575
629
|
" · sources:本条目引用的记忆编号(整数数组,取自上面 `#编号`);",
|
|
630
|
+
" · page_url:**该功能点对应的页面地址**(可选)。只有当记忆里明确出现了这个页面" +
|
|
631
|
+
"(http/https 开头)才填,并**原样照抄**那个地址;记忆里没有就填空串 \"\"," +
|
|
632
|
+
"**绝不猜、绝不编**(编出来的地址会让人点开一个不存在的页面);",
|
|
576
633
|
"5. 只归纳记忆里真实出现的内容,不要发明未提到的功能,**不要写任何日期**;",
|
|
577
634
|
"6. 没有任何新信息时输出空数组 features: []。",
|
|
578
635
|
}
|
|
@@ -610,10 +667,23 @@ func buildPrompt(wsName string, docs []existsDoc, entries []Entry, modules []str
|
|
|
610
667
|
b.WriteString(strings.Join(rules, "\n"))
|
|
611
668
|
b.WriteString("\n\n只输出 JSON:{\"features\":[{\"module\":\"≤" + strconv.Itoa(ModuleMaxChars) +
|
|
612
669
|
"字大功能点\",\"name\":\"≤" + strconv.Itoa(TitleMaxChars) +
|
|
613
|
-
"字要点\",\"desc\":\"\",\"points\":[\"≤30字\"],\"conclusions\":[\"≤30字\"],\"sources\":[12]}]}")
|
|
670
|
+
"字要点\",\"desc\":\"\",\"points\":[\"≤30字\"],\"conclusions\":[\"≤30字\"],\"sources\":[12],\"page_url\":\"\"}]}")
|
|
614
671
|
return b.String()
|
|
615
672
|
}
|
|
616
673
|
|
|
674
|
+
// validPageURL 页面地址是否可用:只认 http/https(其余一律丢弃)。
|
|
675
|
+
//
|
|
676
|
+
// 为什么这么严:模型偶尔会填「无」「待定」「见上文」,或把本地路径当地址。
|
|
677
|
+
// 这些进了文档会变成一个点不开的链接 —— 比没有更糟。
|
|
678
|
+
func validPageURL(u string) bool {
|
|
679
|
+
v := strings.TrimSpace(u)
|
|
680
|
+
if v == "" || isPlaceholder(v) {
|
|
681
|
+
return false
|
|
682
|
+
}
|
|
683
|
+
low := strings.ToLower(v)
|
|
684
|
+
return strings.HasPrefix(low, "http://") || strings.HasPrefix(low, "https://")
|
|
685
|
+
}
|
|
686
|
+
|
|
617
687
|
// aliasSchema 模块同义二审的输出契约。
|
|
618
688
|
var aliasSchema = &agent.JSONSchema{
|
|
619
689
|
Type: "object",
|
|
@@ -769,6 +839,11 @@ type writeCtx struct {
|
|
|
769
839
|
kind string
|
|
770
840
|
all bool
|
|
771
841
|
sourcesOf func(featureOut) []string
|
|
842
|
+
/* onWrite 文档落盘后的关系回调:module/feature 是**改写后**的最终归属
|
|
843
|
+
(alias、hit 沿用都算过了),sources 是模型给的记忆编号 —— 调用方据此
|
|
844
|
+
记「会话↔需求」关系(relations.jsonl)。事后用 featureOut 重算会记错
|
|
845
|
+
模块名(归属在这函数里才定),必须回调。 */
|
|
846
|
+
onWrite func(module, feature string, sources []int)
|
|
772
847
|
}
|
|
773
848
|
|
|
774
849
|
// writeResult 一条增量的落盘结果。
|
|
@@ -845,6 +920,17 @@ func writeFeatureItem(id string, out featureOut, ctx *writeCtx) writeResult {
|
|
|
845
920
|
}
|
|
846
921
|
sources := mergeUnique(prev.Sources, srcLines, MaxSources, false, sourceKey)
|
|
847
922
|
|
|
923
|
+
/* 页面地址(2026-10-25 用户:「涉及页面改造时截图标注放进文档」):
|
|
924
|
+
新值合法才覆盖;本轮没给(或给了非法值)就**保留旧的** ——
|
|
925
|
+
页面地址是文档里长期有效的事实,不该被某一轮的空值抹掉。 */
|
|
926
|
+
pageURL := prev.PageURL
|
|
927
|
+
if u := strings.TrimSpace(out.PageURL); u != "" && validPageURL(u) {
|
|
928
|
+
pageURL = u
|
|
929
|
+
}
|
|
930
|
+
/* 截图段必须**原样带回**:RenderDoc 是全量重写文档,不带回就会把用户
|
|
931
|
+
逐张攒的完成情况截图全部抹掉。 */
|
|
932
|
+
shots := TrimShots(prev.Shots)
|
|
933
|
+
|
|
848
934
|
dPts := len(points) - len(prev.Points)
|
|
849
935
|
if dPts < 0 {
|
|
850
936
|
dPts = 0
|
|
@@ -874,6 +960,8 @@ func writeFeatureItem(id string, out featureOut, ctx *writeCtx) writeResult {
|
|
|
874
960
|
Conclusions: conclusions,
|
|
875
961
|
Sources: sources,
|
|
876
962
|
Log: logLines,
|
|
963
|
+
PageURL: pageURL,
|
|
964
|
+
Shots: shots,
|
|
877
965
|
}
|
|
878
966
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
|
879
967
|
return writeResult{}
|
|
@@ -881,6 +969,9 @@ func writeFeatureItem(id string, out featureOut, ctx *writeCtx) writeResult {
|
|
|
881
969
|
if err := os.WriteFile(full, []byte(RenderDoc(name, doc)), 0o600); err != nil {
|
|
882
970
|
return writeResult{}
|
|
883
971
|
}
|
|
972
|
+
if ctx.onWrite != nil && len(out.Sources) > 0 {
|
|
973
|
+
ctx.onWrite(rawMod, name, out.Sources)
|
|
974
|
+
}
|
|
884
975
|
if !has {
|
|
885
976
|
ctx.merger.add(featureFile{Module: rawMod, Name: name, File: full})
|
|
886
977
|
}
|