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.
Files changed (52) hide show
  1. package/CLIENT-CONTRACT.md +51 -12
  2. package/README.md +10 -4
  3. package/client-ui/pc/app.css +14 -0
  4. package/client-ui/pc/js/93-rail.js +3 -1
  5. package/client-ui/shared/conv/03-chat-message.js +20 -3
  6. package/client-ui/shared/conv/27-conv-ledger.js +1 -1
  7. package/client-ui/shared/conv/conv.css +17 -0
  8. package/client-ui/shared/conversation.js +170 -39
  9. package/client-ui/shared/transport-http.js +183 -38
  10. package/client-ui-mobile/index.html +370 -68
  11. package/client-ui-mobile/reqs.js +42 -29
  12. package/internal/agent/capability.go +8 -1
  13. package/internal/agent/claude.go +5 -3
  14. package/internal/agent/codebuddy.go +6 -2
  15. package/internal/agent/codex.go +7 -2
  16. package/internal/agent/dsh_sdk.go +8 -0
  17. package/internal/agent/dsh_sdk_test.go +17 -0
  18. package/internal/agent/engine.go +20 -0
  19. package/internal/agent/engine_test.go +52 -0
  20. package/internal/agent/llmengine.go +14 -0
  21. package/internal/agent/llmengine_test.go +24 -0
  22. package/internal/agent/llmhttp.go +6 -0
  23. package/internal/agent/llmhttp_test.go +27 -0
  24. package/internal/agent/llmmodels.go +48 -0
  25. package/internal/agent/permission.go +5 -0
  26. package/internal/cli/ask.go +32 -17
  27. package/internal/cli/requirements.go +2 -0
  28. package/internal/cli/sessionlog.go +5 -0
  29. package/internal/cli/sessionlog_user_test.go +49 -0
  30. package/internal/client/requirements.go +27 -7
  31. package/internal/client/requirements_test.go +51 -0
  32. package/internal/client/service.go +24 -2
  33. package/internal/client/service_test.go +35 -0
  34. package/internal/requirements/consolidate.go +86 -24
  35. package/internal/requirements/requirements.go +9 -0
  36. package/internal/requirements/requirements_test.go +129 -0
  37. package/internal/requirements/store.go +11 -3
  38. package/internal/suggest/suggest.go +118 -7
  39. package/internal/suggest/suggest_test.go +50 -0
  40. package/npm/apk/magic-agent-mobile-release.apk +0 -0
  41. package/npm/apk/version.json +3 -3
  42. package/npm/dist/darwin-arm64/magic-agent +0 -0
  43. package/npm/dist/darwin-arm64/magic-client +0 -0
  44. package/npm/dist/darwin-x64/magic-agent +0 -0
  45. package/npm/dist/darwin-x64/magic-client +0 -0
  46. package/npm/dist/linux-arm64/magic-agent +0 -0
  47. package/npm/dist/linux-arm64/magic-client +0 -0
  48. package/npm/dist/linux-x64/magic-agent +0 -0
  49. package/npm/dist/linux-x64/magic-client +0 -0
  50. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  51. package/npm/dist/win32-x64/magic-client.exe +0 -0
  52. package/package.json +3 -2
@@ -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 string
66
- events bool // --stream 的事件流带契约版本与行号(--events;客户端消费用)
67
- control bool // 从 stdin 读 NDJSON 控制命令(--control;打断 / 收工 / 回审批)
68
- jsonOut bool
69
- stream bool
70
- noThinking bool
71
- maxTokens int
72
- temperature float64
73
- tempSet bool
74
- reasoning string // 推理档位(--reasoning;默认 max)
75
- jsonSchema string // 内联 JSON Schema(仅 llm 引擎走 --schema)
76
- session string // 会话续接 id(空 = 新会话,默认)
77
- continueF bool // 续接最近一次会话(-c/--continue;不需要 id)
78
- workspace string // 工作目录(-w/--workspace;空 = 用调用方 cwd)
79
- stop string // 停止指定会话/运行(--stop <session_id|run_id>)
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|目录>", "force":false, "all":false,
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
- out, err := cmd.Output()
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 out, nil
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())