magic-agent 2.3.0 → 2.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLIENT-CONTRACT.md +51 -12
- package/README.md +10 -4
- package/client-ui/pc/app.css +14 -0
- package/client-ui/pc/js/93-rail.js +3 -1
- package/client-ui/shared/conv/03-chat-message.js +20 -3
- package/client-ui/shared/conv/27-conv-ledger.js +1 -1
- package/client-ui/shared/conv/conv.css +17 -0
- package/client-ui/shared/conversation.js +170 -39
- package/client-ui/shared/transport-http.js +183 -38
- package/client-ui-mobile/index.html +370 -68
- package/client-ui-mobile/reqs.js +42 -29
- package/internal/agent/capability.go +8 -1
- package/internal/agent/claude.go +5 -3
- package/internal/agent/codebuddy.go +6 -2
- package/internal/agent/codex.go +7 -2
- package/internal/agent/dsh_sdk.go +8 -0
- package/internal/agent/dsh_sdk_test.go +17 -0
- package/internal/agent/engine.go +20 -0
- package/internal/agent/engine_test.go +52 -0
- package/internal/agent/llmengine.go +14 -0
- package/internal/agent/llmengine_test.go +24 -0
- package/internal/agent/llmhttp.go +6 -0
- package/internal/agent/llmhttp_test.go +27 -0
- package/internal/agent/llmmodels.go +48 -0
- package/internal/agent/permission.go +5 -0
- package/internal/cli/ask.go +32 -17
- package/internal/cli/requirements.go +2 -0
- package/internal/cli/sessionlog.go +5 -0
- package/internal/cli/sessionlog_user_test.go +49 -0
- package/internal/client/requirements.go +27 -7
- package/internal/client/requirements_test.go +51 -0
- package/internal/client/service.go +24 -2
- package/internal/client/service_test.go +35 -0
- package/internal/requirements/consolidate.go +86 -24
- package/internal/requirements/requirements.go +9 -0
- package/internal/requirements/requirements_test.go +129 -0
- package/internal/requirements/store.go +11 -3
- package/internal/suggest/suggest.go +118 -7
- package/internal/suggest/suggest_test.go +50 -0
- package/npm/apk/magic-agent-mobile-release.apk +0 -0
- package/npm/apk/version.json +3 -3
- 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 +3 -2
package/internal/cli/ask.go
CHANGED
|
@@ -62,21 +62,22 @@ type askOptions struct {
|
|
|
62
62
|
login string // 拉起某引擎自己的交互式登录会话(--login <engine>)
|
|
63
63
|
// repairModels 还原被反向同步写坏的桌面端 models.json(--repair-models <engine>):
|
|
64
64
|
// 2026-09-30 Windows 事故的收尾手段,见 internal/agent/custom_models.go 的事故还原一节。
|
|
65
|
-
repairModels
|
|
66
|
-
events
|
|
67
|
-
control
|
|
68
|
-
jsonOut
|
|
69
|
-
stream
|
|
70
|
-
noThinking
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
65
|
+
repairModels string
|
|
66
|
+
events bool // --stream 的事件流带契约版本与行号(--events;客户端消费用)
|
|
67
|
+
control bool // 从 stdin 读 NDJSON 控制命令(--control;打断 / 收工 / 回审批)
|
|
68
|
+
jsonOut bool
|
|
69
|
+
stream bool
|
|
70
|
+
noThinking bool // 只隐藏思考事件(--no-thinking,历史语义)
|
|
71
|
+
disableThinking bool // 请求模型真正关闭思考(--disable-thinking / --no-think)
|
|
72
|
+
maxTokens int
|
|
73
|
+
temperature float64
|
|
74
|
+
tempSet bool
|
|
75
|
+
reasoning string // 推理档位(--reasoning;默认 max)
|
|
76
|
+
jsonSchema string // 内联 JSON Schema(仅 llm 引擎走 --schema)
|
|
77
|
+
session string // 会话续接 id(空 = 新会话,默认)
|
|
78
|
+
continueF bool // 续接最近一次会话(-c/--continue;不需要 id)
|
|
79
|
+
workspace string // 工作目录(-w/--workspace;空 = 用调用方 cwd)
|
|
80
|
+
stop string // 停止指定会话/运行(--stop <session_id|run_id>)
|
|
80
81
|
// deleteSession 删除指定会话/运行(--delete-session <session_id|run_id>):
|
|
81
82
|
// 登记记录 + 事件日志 + 标题覆盖一起删。与插件的 DELETE /desk/session/{id} 同一份实现
|
|
82
83
|
// (见 internal/session/delete.go)——「一端删了、另一端还在」的根因就是删除没落在
|
|
@@ -118,6 +119,11 @@ type askOptions struct {
|
|
|
118
119
|
appendTo string // 向常驻会话追加消息(--append <session_id|run_id>)
|
|
119
120
|
idle time.Duration // 常驻会话空闲收工时长(--idle,仅 --keep-alive 有效)
|
|
120
121
|
|
|
122
|
+
// hosted 本轮提问由客户端「会话托管」自动发送(--hosted,2026-10-23)。
|
|
123
|
+
// 只写进会话历史的 user 事件做标识,不改变提示词、引擎或权限语义 ——
|
|
124
|
+
// 执行路径与用户亲手发的完全一样,客户端回放时据此画成「托管发送」。
|
|
125
|
+
hosted bool
|
|
126
|
+
|
|
121
127
|
// 会话标题(2026-10-15;见 internal/session/title.go)。
|
|
122
128
|
// 归纳口径收进 core 后,客户端不再各自实现 —— 否则 PC 与移动端标题不一致,
|
|
123
129
|
// 且从移动端发起的会话没人替它归纳。
|
|
@@ -171,6 +177,7 @@ type askOptions struct {
|
|
|
171
177
|
reqAll bool // --requirement-all:全量重放
|
|
172
178
|
reqEngine string // --requirement-engine(默认 llm)
|
|
173
179
|
reqModel string // --requirement-model
|
|
180
|
+
reqModule string // --requirement-module:只整理这个模块
|
|
174
181
|
reqTimeout time.Duration // --requirement-timeout
|
|
175
182
|
reqAuto bool // --requirement-auto:自动归纳,不触发首次初始化
|
|
176
183
|
}
|
|
@@ -224,6 +231,7 @@ func bindAskFlags(cmd *cobra.Command, opts *askOptions) {
|
|
|
224
231
|
f.BoolVar(&opts.keepAlive, "keep-alive", true, "常驻会话:首轮结束后不退出、等 --append 追加(需 --stream)。claude/codebuddy 默认开;dsh 默认关(显式传 --keep-alive 开启);其余引擎不支持")
|
|
225
232
|
f.StringVar(&opts.appendTo, "append", "", "向常驻会话追加一条消息:传 session_id 或 run_id,内容用 -p/位置参数给")
|
|
226
233
|
f.DurationVar(&opts.idle, "idle", 5*time.Minute, "常驻会话空闲收工时长(默认 5m;0 = 本轮结束就收工,追加窗口只在任务运行期间)")
|
|
234
|
+
f.BoolVar(&opts.hosted, "hosted", false, "仅标记:本轮提问由客户端「会话托管」自动发送(写进会话历史,执行语义不变)")
|
|
227
235
|
f.IntVar(&opts.maxTokens, "max-tokens", 0, "输出 token 上限(0=不指定;claude/codebuddy 经 --settings 注入,llm 透传,trae/dsh 忽略)")
|
|
228
236
|
f.Float64Var(&opts.temperature, "temperature", -1, "采样温度(-1=不指定;仅 llm 引擎透传,其余引擎忽略)")
|
|
229
237
|
f.StringVar(&opts.reasoning, "reasoning", "max", "推理/思考档位: minimal | low | medium | high | xhigh | max | off(默认 max;claude/codebuddy 走 --effort,codex 走 -c model_reasoning_effort;其余引擎无此参数,忽略)")
|
|
@@ -242,7 +250,9 @@ func bindAskFlags(cmd *cobra.Command, opts *askOptions) {
|
|
|
242
250
|
f.BoolVar(&opts.stream, "stream", false, "流式输出:正文/思考增量实时打到 stdout(text 模式思考走 stderr)")
|
|
243
251
|
f.BoolVar(&opts.events, "events", false, "配合 --stream:事件流带契约版本与行号(每行加 v / seq,并先发一行 ready),供客户端消费;老消费者不要开(形状与 --stream 不同)")
|
|
244
252
|
f.BoolVar(&opts.control, "control", false, "配合 --stream:从 stdin 读 NDJSON 控制命令(ping / interrupt / stop / answer),让调用方能打断长任务、回审批")
|
|
245
|
-
f.BoolVar(&opts.noThinking, "no-thinking", false, "
|
|
253
|
+
f.BoolVar(&opts.noThinking, "no-thinking", false, "流式模式下不转发思考过程增量(只隐藏,不关闭模型思考)")
|
|
254
|
+
f.BoolVar(&opts.disableThinking, "disable-thinking", false, "请求引擎关闭模型思考(claude/codex/dsh/codebuddy/llm 各自用官方开关)")
|
|
255
|
+
f.BoolVar(&opts.disableThinking, "no-think", false, "同 --disable-thinking")
|
|
246
256
|
bindTeamFlags(cmd, opts)
|
|
247
257
|
bindRequirementFlags(cmd, opts)
|
|
248
258
|
bindPermissionFlags(cmd, opts)
|
|
@@ -720,7 +730,7 @@ func runStreamAsk(cmd *cobra.Command, args []string, opts *askOptions) error {
|
|
|
720
730
|
}
|
|
721
731
|
// 附件挂在 user 事件上:引擎事件里没有附件信息(`image` 只对应助手产出),
|
|
722
732
|
// 只有落这里,历史回放才复原得出「那一轮带了哪张图」。
|
|
723
|
-
sessionLog.push(sessionEvent{Kind: "user", Text: m.Content, Attachments: req.Attachments})
|
|
733
|
+
sessionLog.push(sessionEvent{Kind: "user", Text: m.Content, Attachments: req.Attachments, Hosted: opts.hosted})
|
|
724
734
|
}
|
|
725
735
|
}
|
|
726
736
|
|
|
@@ -1177,6 +1187,10 @@ func prepareAsk(cmd *cobra.Command, args []string, opts *askOptions) (agent.Engi
|
|
|
1177
1187
|
fmt.Fprintf(cmd.ErrOrStderr(),
|
|
1178
1188
|
"magic-agent: 提示:%s 引擎没有推理档位参数,--reasoning %s 已忽略\n", engine.Name(), reasoning)
|
|
1179
1189
|
}
|
|
1190
|
+
if opts.disableThinking && !agent.NoThinkingSupportOf(engine.Name()) {
|
|
1191
|
+
fmt.Fprintf(cmd.ErrOrStderr(),
|
|
1192
|
+
"magic-agent: 提示:%s 引擎没有已知的关闭思考参数,--disable-thinking 已忽略\n", engine.Name())
|
|
1193
|
+
}
|
|
1180
1194
|
toolsMode, err := parseToolsMode(opts.tools)
|
|
1181
1195
|
if err != nil {
|
|
1182
1196
|
return nil, "", agent.Request{}, err
|
|
@@ -1329,6 +1343,7 @@ func prepareAsk(cmd *cobra.Command, args []string, opts *askOptions) (agent.Engi
|
|
|
1329
1343
|
Continue: opts.continueF,
|
|
1330
1344
|
Workspace: workspace,
|
|
1331
1345
|
Reasoning: reasoning,
|
|
1346
|
+
NoThinking: opts.disableThinking,
|
|
1332
1347
|
Permission: tier,
|
|
1333
1348
|
PermissionOptions: agent.PermissionOptions{
|
|
1334
1349
|
ExcludedCommands: opts.sandboxExclude,
|
|
@@ -86,6 +86,7 @@ func runConsolidateRequirements(cmd *cobra.Command, opts *askOptions) error {
|
|
|
86
86
|
All: opts.reqAll,
|
|
87
87
|
Engine: opts.reqEngine,
|
|
88
88
|
Model: opts.reqModel,
|
|
89
|
+
Module: opts.reqModule,
|
|
89
90
|
Timeout: timeout,
|
|
90
91
|
Auto: opts.reqAuto,
|
|
91
92
|
})
|
|
@@ -114,6 +115,7 @@ func bindRequirementFlags(cmd *cobra.Command, opts *askOptions) {
|
|
|
114
115
|
f.BoolVar(&opts.reqAll, "requirement-all", false, "配合 --consolidate-requirements:全量重放历史记忆(回填 / 滚动校正)。仍走增量合并,只增不减,不会推翻已有内容")
|
|
115
116
|
f.StringVar(&opts.reqEngine, "requirement-engine", "", "--consolidate-requirements 用哪个引擎(空 = 自动:首次初始化挑能读目录的引擎,增量归纳用 llm);客户端应传自己的默认引擎")
|
|
116
117
|
f.StringVar(&opts.reqModel, "requirement-model", "", "配合 --consolidate-requirements:增量归纳用的模型(空 = 引擎默认;首次目录初始化始终用初始化引擎的默认模型)")
|
|
118
|
+
f.StringVar(&opts.reqModule, "requirement-module", "", "配合 --consolidate-requirements:只整理这个模块(空 = 整个空间)。不推进空间游标,不触发首次目录初始化")
|
|
117
119
|
f.DurationVar(&opts.reqTimeout, "requirement-timeout", requirements.DefaultTimeout, "配合 --consolidate-requirements:单次模型调用超时(默认归纳 4m / 首次目录初始化 9m)")
|
|
118
120
|
f.BoolVar(&opts.reqAuto, "requirement-auto", false, "配合 --consolidate-requirements:自动归纳(不触发首次按目录初始化;插件轮末 / 每日兜底用)")
|
|
119
121
|
}
|
|
@@ -122,6 +122,11 @@ type sessionEvent struct {
|
|
|
122
122
|
// (`image` 事件只对应助手**产出**),所以只有落在这里,历史回放才复原得出
|
|
123
123
|
// 「那一轮用了哪张图」—— 移动端据此在历史气泡里画出缩略图。
|
|
124
124
|
Attachments []agent.Attachment `json:"attachments,omitempty"`
|
|
125
|
+
// Hosted 这条用户提问由客户端的「会话托管」自动发出(仅 kind:"user" 有)。
|
|
126
|
+
// 2026-10-23 只增字段:用户要求「托管发送的要标识 显示时区别于“我”发送」。
|
|
127
|
+
// 它只影响历史回放的画法(机器人头像 + 「托管发送」小标),不参与执行;
|
|
128
|
+
// 老日志没有这个字段 = 用户亲手发的,回放与以前一致。
|
|
129
|
+
Hosted bool `json:"hosted,omitempty"`
|
|
125
130
|
}
|
|
126
131
|
|
|
127
132
|
// sessionWriter 每条流式调用一个实例;开着就一直 append 到
|
|
@@ -85,6 +85,9 @@ func TestStreamAskPersistsUserMessage(t *testing.T) {
|
|
|
85
85
|
if evs[0].SessionID != sid {
|
|
86
86
|
t.Fatalf("首条应带上 session_id(pending 刷盘时回填),实得 %q", evs[0].SessionID)
|
|
87
87
|
}
|
|
88
|
+
if evs[0].Hosted {
|
|
89
|
+
t.Fatal("普通提问不该带 hosted —— 只有客户端显式传 --hosted 的托管自动发送才标")
|
|
90
|
+
}
|
|
88
91
|
|
|
89
92
|
// ② 引擎事件也都在,且排在 user 之后。
|
|
90
93
|
sawText := false
|
|
@@ -122,6 +125,52 @@ func TestStreamAskPersistsUserMessage(t *testing.T) {
|
|
|
122
125
|
}
|
|
123
126
|
}
|
|
124
127
|
|
|
128
|
+
// TestStreamAskPersistsHostedUserMessage `--hosted` 只给 user 事件加一个标识
|
|
129
|
+
// (2026-10-23 用户:「是托管发送的要标识 显示时区别于“我”发送」)。
|
|
130
|
+
//
|
|
131
|
+
// 标识必须落在**会话历史**里:客户端只画实时那条不够 —— 重开会话时气泡是从
|
|
132
|
+
// kind:"user" 回放出来的,历史没这个字段就又会变回「我」。
|
|
133
|
+
func TestStreamAskPersistsHostedUserMessage(t *testing.T) {
|
|
134
|
+
dir := t.TempDir()
|
|
135
|
+
prev := SessionLogDirOverride
|
|
136
|
+
SessionLogDirOverride = dir
|
|
137
|
+
defer func() { SessionLogDirOverride = prev }()
|
|
138
|
+
|
|
139
|
+
const sid = "sess-hosted-log"
|
|
140
|
+
const prompt = "继续下一步"
|
|
141
|
+
registerFake(&userEchoStreamEngine{name: "fake-hosted-log", sessionID: sid})
|
|
142
|
+
|
|
143
|
+
if _, _, err := runAskCmd(t, "", "-e", "fake-hosted-log", "--stream", "--save-history", "--hosted", prompt); err != nil {
|
|
144
|
+
t.Fatalf("--stream --hosted: %v", err)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
evs, err := ReadSessionLog(dir, sid)
|
|
148
|
+
if err != nil {
|
|
149
|
+
t.Fatalf("读回会话日志失败:%v", err)
|
|
150
|
+
}
|
|
151
|
+
if len(evs) == 0 || evs[0].Kind != "user" {
|
|
152
|
+
t.Fatalf("首条应是 user,实得:%s", dumpKinds(evs))
|
|
153
|
+
}
|
|
154
|
+
if !evs[0].Hosted {
|
|
155
|
+
t.Fatal("--hosted 没有写进 user 事件(历史回放会退回「我」发送)")
|
|
156
|
+
}
|
|
157
|
+
// 正文不能被标识污染:发给引擎 / 回放的都是原句。
|
|
158
|
+
if evs[0].Text != prompt {
|
|
159
|
+
t.Fatalf("user 文本被改动:期望 %q,实得 %q", prompt, evs[0].Text)
|
|
160
|
+
}
|
|
161
|
+
raw := readLines(t, filepath.Join(dir, sid+".jsonl"))
|
|
162
|
+
if len(raw) == 0 {
|
|
163
|
+
t.Fatal("落盘文件是空的")
|
|
164
|
+
}
|
|
165
|
+
var first map[string]any
|
|
166
|
+
if err := json.Unmarshal([]byte(raw[0]), &first); err != nil {
|
|
167
|
+
t.Fatal(err)
|
|
168
|
+
}
|
|
169
|
+
if first["hosted"] != true {
|
|
170
|
+
t.Fatalf("落盘 JSON 应带 hosted:true(只增字段),实得 %v", first)
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
125
174
|
// TestStreamAskNoArchiveByDefault **默认什么都不存**(用户 2026-10-12:
|
|
126
175
|
// 「加一个参数 默认不存档」+「会话登记也不需要」)。
|
|
127
176
|
//
|
|
@@ -21,6 +21,7 @@ package client
|
|
|
21
21
|
// 「一句说完、下一句还没打完」的经验值,期间再来一轮就顺延、合并成一次。
|
|
22
22
|
|
|
23
23
|
import (
|
|
24
|
+
"bytes"
|
|
24
25
|
"context"
|
|
25
26
|
"encoding/json"
|
|
26
27
|
"errors"
|
|
@@ -82,12 +83,13 @@ func (s *Service) handleRequirements(w http.ResponseWriter, r *http.Request) {
|
|
|
82
83
|
// handleRequirementsConsolidate POST /desk/requirements/consolidate:
|
|
83
84
|
// 立即归纳一轮(用户点「整理」)。
|
|
84
85
|
//
|
|
85
|
-
// 请求体:{"workspace":"<ws_id|目录>", "
|
|
86
|
+
// 请求体:{"workspace":"<ws_id|目录>", "module":"<模块名>", "force":false,
|
|
86
87
|
//
|
|
87
|
-
// "engine":"claude", "model":"..."}
|
|
88
|
+
// "all":false, "engine":"claude", "model":"..."}
|
|
88
89
|
//
|
|
89
90
|
// workspace 留空 = 插件自己的工作目录(与 /desk/health 的 defaultWorkspace 一致 ——
|
|
90
91
|
// 那就是 core 不传 -w 时记录的 cwd)。
|
|
92
|
+
// module 留空 = 整理整个空间;给了就只整理那一个模块(移动端二级页的「整理」)。
|
|
91
93
|
// engine / model 是客户端当前配置的默认引擎与模型(移动端「我的 → 默认引擎」):
|
|
92
94
|
// 「整理的引擎就是配置的默认引擎」—— 客户端传什么,core 就按什么跑,不再自动替换。
|
|
93
95
|
func (s *Service) handleRequirementsConsolidate(w http.ResponseWriter, r *http.Request) {
|
|
@@ -102,6 +104,7 @@ func (s *Service) handleRequirementsConsolidate(w http.ResponseWriter, r *http.R
|
|
|
102
104
|
}
|
|
103
105
|
var body struct {
|
|
104
106
|
Workspace string `json:"workspace,omitempty"`
|
|
107
|
+
Module string `json:"module,omitempty"`
|
|
105
108
|
Force bool `json:"force,omitempty"`
|
|
106
109
|
All bool `json:"all,omitempty"`
|
|
107
110
|
Engine string `json:"engine,omitempty"`
|
|
@@ -122,6 +125,7 @@ func (s *Service) handleRequirementsConsolidate(w http.ResponseWriter, r *http.R
|
|
|
122
125
|
writeErr(w, http.StatusBadRequest, "workspace 不能为空(传 ws id 或目录)")
|
|
123
126
|
return
|
|
124
127
|
}
|
|
128
|
+
module := strings.TrimSpace(body.Module)
|
|
125
129
|
engine := strings.TrimSpace(body.Engine)
|
|
126
130
|
model := strings.TrimSpace(body.Model)
|
|
127
131
|
if model != "" && engine == "" {
|
|
@@ -133,7 +137,7 @@ func (s *Service) handleRequirementsConsolidate(w http.ResponseWriter, r *http.R
|
|
|
133
137
|
return
|
|
134
138
|
}
|
|
135
139
|
out, err := s.runCoreTimed(r.Context(), reqConsolidateTimeout,
|
|
136
|
-
consolidateArgs(ws, body.Force, body.All, false, engine, model)...)
|
|
140
|
+
consolidateArgs(ws, module, body.Force, body.All, false, engine, model)...)
|
|
137
141
|
if err != nil {
|
|
138
142
|
code := http.StatusBadGateway
|
|
139
143
|
if isCoreTimeout(err) {
|
|
@@ -150,8 +154,11 @@ func (s *Service) handleRequirementsConsolidate(w http.ResponseWriter, r *http.R
|
|
|
150
154
|
// engine/model 来自客户端配置的默认引擎(手动「整理」):传了就用它,core 不再自动换。
|
|
151
155
|
// 自动归纳(auto=true)不传:后台没有「当前设备默认引擎」这个概念,让 core 走自己的
|
|
152
156
|
// 默认(增量用 llm;首次初始化也不会发生,因为 --requirement-auto 明确禁止)。
|
|
153
|
-
func consolidateArgs(ws string, force, all, auto bool, engine, model string) []string {
|
|
157
|
+
func consolidateArgs(ws, module string, force, all, auto bool, engine, model string) []string {
|
|
154
158
|
args := []string{"--consolidate-requirements", ws}
|
|
159
|
+
if module != "" {
|
|
160
|
+
args = append(args, "--requirement-module", module)
|
|
161
|
+
}
|
|
155
162
|
if force {
|
|
156
163
|
args = append(args, "--requirement-force")
|
|
157
164
|
}
|
|
@@ -178,14 +185,27 @@ func (s *Service) runCoreTimed(ctx context.Context, timeout time.Duration, args
|
|
|
178
185
|
cmd := exec.CommandContext(ctx, s.opts.MagicAgent, args...)
|
|
179
186
|
// 与 runCore 同一道保险:core 的后代进程握着管道时 Wait 不能永久挂住。
|
|
180
187
|
cmd.WaitDelay = 5 * time.Second
|
|
181
|
-
|
|
188
|
+
var stdout, stderr bytes.Buffer
|
|
189
|
+
cmd.Stdout = &stdout
|
|
190
|
+
cmd.Stderr = &stderr
|
|
191
|
+
err := cmd.Run()
|
|
182
192
|
if err != nil {
|
|
183
193
|
if ctx.Err() != nil || errors.Is(err, exec.ErrWaitDelay) {
|
|
184
194
|
return nil, fmt.Errorf("%w(%s)", errCoreTimeout, timeout)
|
|
185
195
|
}
|
|
196
|
+
/* core 失败时也会先把结构化结果写到 stdout(`{ok:false,error:"..."}`),
|
|
197
|
+
再返回非零退出码。Output() 会把它丢掉,界面只剩一句「exit status 1」——
|
|
198
|
+
用户看不到真正原因(例如「模块不存在」)。这里把 stdout 交回调用方,
|
|
199
|
+
由它按 JSON 里的 error 展示;真的一个字都没输出才退化成 stderr/退出码。 */
|
|
200
|
+
if stdout.Len() > 0 {
|
|
201
|
+
return stdout.Bytes(), nil
|
|
202
|
+
}
|
|
203
|
+
if msg := strings.TrimSpace(stderr.String()); msg != "" {
|
|
204
|
+
return nil, fmt.Errorf("%s", msg)
|
|
205
|
+
}
|
|
186
206
|
return nil, err
|
|
187
207
|
}
|
|
188
|
-
return
|
|
208
|
+
return stdout.Bytes(), nil
|
|
189
209
|
}
|
|
190
210
|
|
|
191
211
|
// isCoreTimeout 判断错误链里是不是「等 core 超时」。
|
|
@@ -270,7 +290,7 @@ func (s *Service) runAutoConsolidate(ws string) {
|
|
|
270
290
|
|
|
271
291
|
// 归纳是后台行为:**不绑**任何 HTTP 请求的生命周期,用独立 context。
|
|
272
292
|
ctx, cancel := context.WithTimeout(context.Background(), reqConsolidateTimeout)
|
|
273
|
-
out, err := s.runCoreTimed(ctx, reqConsolidateTimeout, consolidateArgs(ws, false, false, true, "", "")...)
|
|
293
|
+
out, err := s.runCoreTimed(ctx, reqConsolidateTimeout, consolidateArgs(ws, "", false, false, true, "", "")...)
|
|
274
294
|
cancel()
|
|
275
295
|
if err != nil {
|
|
276
296
|
s.log.Printf("需求自动归纳失败 %s: %v", ws, err)
|
|
@@ -131,6 +131,23 @@ func TestRequirementsConsolidateRoute(t *testing.T) {
|
|
|
131
131
|
if !ok {
|
|
132
132
|
t.Errorf("应透传目录 / force / 配置的默认引擎与模型,实际 %v", readCalls(t, logPath))
|
|
133
133
|
}
|
|
134
|
+
// 二级页「整理」:module 要原样透传给 core,否则会退化成整空间。
|
|
135
|
+
recMod := httptest.NewRecorder()
|
|
136
|
+
bodyMod := strings.NewReader(`{"workspace":"/proj","module":"登录","engine":"claude"}`)
|
|
137
|
+
svc.Handler().ServeHTTP(recMod, httptest.NewRequest(http.MethodPost,
|
|
138
|
+
"/desk/requirements/consolidate?token=tok", bodyMod))
|
|
139
|
+
if recMod.Code != http.StatusOK {
|
|
140
|
+
t.Fatalf("模块级归纳应 200,实际 %d: %s", recMod.Code, recMod.Body.String())
|
|
141
|
+
}
|
|
142
|
+
var sawModule bool
|
|
143
|
+
for _, c := range readCalls(t, logPath) {
|
|
144
|
+
if strings.Contains(c, "--consolidate-requirements /proj --requirement-module 登录") {
|
|
145
|
+
sawModule = true
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if !sawModule {
|
|
149
|
+
t.Errorf("应透传 --requirement-module 登录,实际 %v", readCalls(t, logPath))
|
|
150
|
+
}
|
|
134
151
|
// 归纳的结果要能解出核心字段(客户端靠它显示「更新/新建/待整理」)。
|
|
135
152
|
var res struct {
|
|
136
153
|
OK bool `json:"ok"`
|
|
@@ -141,6 +158,40 @@ func TestRequirementsConsolidateRoute(t *testing.T) {
|
|
|
141
158
|
}
|
|
142
159
|
}
|
|
143
160
|
|
|
161
|
+
// TestRequirementsConsolidateSurfacesCoreError core 失败时先写 JSON 再 exit 1,
|
|
162
|
+
// 插件不能把它吞成「exit status 1」——界面要能显示「模块不存在」这类真正原因。
|
|
163
|
+
func TestRequirementsConsolidateSurfacesCoreError(t *testing.T) {
|
|
164
|
+
dir := t.TempDir()
|
|
165
|
+
core := filepath.Join(dir, "magic-agent")
|
|
166
|
+
script := `#!/bin/sh
|
|
167
|
+
case "$*" in
|
|
168
|
+
*--contract*)
|
|
169
|
+
echo '{"contractVersion":2,"features":["feature.session.events","feature.session.control","` + FeatureRequirements + `"],"engines":[]}'
|
|
170
|
+
exit 0
|
|
171
|
+
;;
|
|
172
|
+
*--consolidate-requirements*)
|
|
173
|
+
echo '{"ok":false,"workspace":"ws-1","module":"不存在","error":"模块不存在: 不存在"}'
|
|
174
|
+
exit 1
|
|
175
|
+
;;
|
|
176
|
+
esac
|
|
177
|
+
exit 0
|
|
178
|
+
`
|
|
179
|
+
if err := os.WriteFile(core, []byte(script), 0o755); err != nil {
|
|
180
|
+
t.Fatal(err)
|
|
181
|
+
}
|
|
182
|
+
svc := newTestService(t, core)
|
|
183
|
+
rec := httptest.NewRecorder()
|
|
184
|
+
body := strings.NewReader(`{"workspace":"ws-1","module":"不存在"}`)
|
|
185
|
+
svc.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodPost,
|
|
186
|
+
"/desk/requirements/consolidate?token=tok", body))
|
|
187
|
+
if rec.Code != http.StatusOK {
|
|
188
|
+
t.Fatalf("core 的结构化失败应原样回传,实际 %d: %s", rec.Code, rec.Body.String())
|
|
189
|
+
}
|
|
190
|
+
if !strings.Contains(rec.Body.String(), "模块不存在") {
|
|
191
|
+
t.Errorf("应保留 core 的 error 文案,实际 %s", rec.Body.String())
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
144
195
|
func TestMaybeAutoConsolidate(t *testing.T) {
|
|
145
196
|
logPath := filepath.Join(t.TempDir(), "calls.log")
|
|
146
197
|
core := reqCore(t, `"feature.session.events","feature.session.control",`+
|
|
@@ -49,6 +49,13 @@ const RequiredContractVersion = 2
|
|
|
49
49
|
// 没有 events 就没有带游标的事件流,没有 control 就无法打断与回审批。
|
|
50
50
|
var requiredFeatures = []string{"feature.session.events", "feature.session.control"}
|
|
51
51
|
|
|
52
|
+
// FeatureSessionHosted core 的「会话托管历史标识」能力位(与 internal/agent 同值)。
|
|
53
|
+
//
|
|
54
|
+
// 可选特性:老 core 没有它,插件照常发送,只是不追加 --hosted ——
|
|
55
|
+
// 移动端实时气泡仍由客户端自己标,历史回放则退回「用户亲手发的」画法。
|
|
56
|
+
// 不塞进 requiredFeatures,否则升插件就得同步升 core。
|
|
57
|
+
const FeatureSessionHosted = "feature.session.hosted"
|
|
58
|
+
|
|
52
59
|
// Options 启动参数。
|
|
53
60
|
type Options struct {
|
|
54
61
|
// MagicAgent core 可执行文件;空 = 从 PATH 找 magic-agent。
|
|
@@ -1783,6 +1790,12 @@ type askRequest struct {
|
|
|
1783
1790
|
// 方式,不是这台机器的属性。开关状态由客户端自己记(localStorage),每轮随 body
|
|
1784
1791
|
// 下发 —— core 因此完全无状态,「刚才那条为什么走了团队」在会话登记表里查得到。
|
|
1785
1792
|
Team *teamRequest `json:"team,omitempty"`
|
|
1793
|
+
// Hosted 本轮提问由客户端「会话托管」自动发送(2026-10-23,只增字段)。
|
|
1794
|
+
//
|
|
1795
|
+
// 只用于把 `kind:"user"` 历史事件标成 hosted(客户端回放画成「托管发送」),
|
|
1796
|
+
// 不改变执行语义。core 没有 feature.session.hosted 时插件**忽略**它 ——
|
|
1797
|
+
// 实时气泡仍由客户端自己标,老 core 上只是历史回放少一个标识,不该阻断发送。
|
|
1798
|
+
Hosted bool `json:"hosted,omitempty"`
|
|
1786
1799
|
}
|
|
1787
1800
|
|
|
1788
1801
|
// teamRequest 团队模式的请求体(POST /desk/ask 的 team 字段)。
|
|
@@ -1880,7 +1893,7 @@ const (
|
|
|
1880
1893
|
// 游标(handleAskEvents 里 `after == 0` 才放行它);
|
|
1881
1894
|
// - `prompt_id` 是客户端去重的键:core 首行到达前客户端会反复以 after=0 轮询,没有它
|
|
1882
1895
|
// 同一句提问会被画很多遍(见 client-ui/shared/conversation.js 的 render)。
|
|
1883
|
-
func (r *askRun) appendPrompt(text string, attachments []string) {
|
|
1896
|
+
func (r *askRun) appendPrompt(text string, attachments []string, hosted bool) {
|
|
1884
1897
|
atts := make([]map[string]string, 0, len(attachments))
|
|
1885
1898
|
for _, p := range attachments {
|
|
1886
1899
|
if p = strings.TrimSpace(p); p != "" {
|
|
@@ -1896,6 +1909,10 @@ func (r *askRun) appendPrompt(text string, attachments []string) {
|
|
|
1896
1909
|
if len(atts) > 0 {
|
|
1897
1910
|
ev["attachments"] = atts
|
|
1898
1911
|
}
|
|
1912
|
+
/* 与 core 落盘的同名字段同义:断线续传画回来的那条也要是「托管发送」。 */
|
|
1913
|
+
if hosted {
|
|
1914
|
+
ev["hosted"] = true
|
|
1915
|
+
}
|
|
1899
1916
|
b, err := json.Marshal(ev)
|
|
1900
1917
|
if err != nil {
|
|
1901
1918
|
return
|
|
@@ -2076,6 +2093,11 @@ func (s *Service) handleAsk(w http.ResponseWriter, r *http.Request) {
|
|
|
2076
2093
|
args = append(args, "-a", p)
|
|
2077
2094
|
}
|
|
2078
2095
|
args = append(args, "-p", req.Prompt)
|
|
2096
|
+
/* 托管标识:老 core 不认识 --hosted(exit 2),所以先看能力位;缺了就不传 ——
|
|
2097
|
+
发送本身不能因为这个**纯显示**字段被阻断(实时气泡由客户端自己标)。 */
|
|
2098
|
+
if req.Hosted && hasStr(s.contract.Features, FeatureSessionHosted) {
|
|
2099
|
+
args = append(args, "--hosted")
|
|
2100
|
+
}
|
|
2079
2101
|
|
|
2080
2102
|
/* 子进程**不绑 HTTP 请求的生命周期**(2026-10-19):此前用 r.Context(),手机端 SSE
|
|
2081
2103
|
一断(锁屏 / 切后台 / WebView 回收 / 页面刷新)就会 SIGKILL core —— core 来不及写
|
|
@@ -2124,7 +2146,7 @@ func (s *Service) handleAsk(w http.ResponseWriter, r *http.Request) {
|
|
|
2124
2146
|
cmd.Stderr = run.stderr
|
|
2125
2147
|
/* 用户这条提问先落进回放缓冲(不进 SSE 直播流):core 的事件流里没有 user 事件,
|
|
2126
2148
|
断线重连 / 打开运行中会话时补出来的画面会缺「用户问的是什么」。 */
|
|
2127
|
-
run.appendPrompt(req.Prompt, req.Attachments)
|
|
2149
|
+
run.appendPrompt(req.Prompt, req.Attachments, req.Hosted)
|
|
2128
2150
|
if err := cmd.Start(); err != nil {
|
|
2129
2151
|
writeErr(w, http.StatusBadGateway, "启动 core 失败:"+err.Error())
|
|
2130
2152
|
return
|
|
@@ -66,6 +66,9 @@ echo '{"v":1,"seq":4,"type":"result","text":"hi"}'
|
|
|
66
66
|
|
|
67
67
|
const goodContract = `{"contractVersion":2,"features":["feature.session.events","feature.session.control"],"engines":[{"engine":"claude","ok":true,"capabilities":["session.stream"]}]}`
|
|
68
68
|
|
|
69
|
+
// hostedContract 多了托管历史标识的能力位(2026-10-23 只增)。
|
|
70
|
+
const hostedContract = `{"contractVersion":2,"features":["feature.session.events","feature.session.control","feature.session.hosted"],"engines":[{"engine":"claude","ok":true,"capabilities":["session.stream"]}]}`
|
|
71
|
+
|
|
69
72
|
func newTestService(t *testing.T, core string) *Service {
|
|
70
73
|
t.Helper()
|
|
71
74
|
svc, err := New(Options{MagicAgent: core, Profile: DefaultProfile(), Token: "tok"})
|
|
@@ -418,6 +421,38 @@ func TestAskPassesSaveHistory(t *testing.T) {
|
|
|
418
421
|
}
|
|
419
422
|
}
|
|
420
423
|
|
|
424
|
+
// TestAskPassesHosted 托管自动发送的标识:core 有这个能力位时才透传 --hosted。
|
|
425
|
+
// 它只写会话历史(回放画成「托管发送」),执行语义不变。
|
|
426
|
+
func TestAskPassesHosted(t *testing.T) {
|
|
427
|
+
svc := newTestService(t, fakeCore(t, hostedContract))
|
|
428
|
+
srv := httptest.NewServer(svc.Handler())
|
|
429
|
+
defer srv.Close()
|
|
430
|
+
|
|
431
|
+
sse := openSSE(t, srv.URL, "tok", askRequest{Engine: "claude", Prompt: "继续", Hosted: true})
|
|
432
|
+
defer sse.close()
|
|
433
|
+
_ = sse.next(t)
|
|
434
|
+
raw, _ := sse.next(t)["raw"].(string)
|
|
435
|
+
if !strings.Contains(raw, "--hosted") {
|
|
436
|
+
t.Errorf("core 支持 feature.session.hosted 时应透传 --hosted,实际 %q", raw)
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// TestAskDropsHostedForOldCore 老 core 不认识 --hosted(会 exit 2),插件必须忽略。
|
|
441
|
+
// 这是**纯显示**字段,不能因为它阻断发送;实时气泡仍由客户端自己标。
|
|
442
|
+
func TestAskDropsHostedForOldCore(t *testing.T) {
|
|
443
|
+
svc := newTestService(t, fakeCore(t, goodContract))
|
|
444
|
+
srv := httptest.NewServer(svc.Handler())
|
|
445
|
+
defer srv.Close()
|
|
446
|
+
|
|
447
|
+
sse := openSSE(t, srv.URL, "tok", askRequest{Engine: "claude", Prompt: "继续", Hosted: true})
|
|
448
|
+
defer sse.close()
|
|
449
|
+
_ = sse.next(t)
|
|
450
|
+
raw, _ := sse.next(t)["raw"].(string)
|
|
451
|
+
if strings.Contains(raw, "--hosted") {
|
|
452
|
+
t.Errorf("老 core 没有 feature.session.hosted,不该透传 --hosted,实际 %q", raw)
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
421
456
|
func TestAskInjectsClientEnv(t *testing.T) {
|
|
422
457
|
svc := newTestService(t, fakeCore(t, goodContract))
|
|
423
458
|
srv := httptest.NewServer(svc.Handler())
|