magic-agent 1.0.3 → 1.0.49

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 (153) hide show
  1. package/CLIENT-CONTRACT.md +254 -8
  2. package/README.md +48 -5
  3. package/client-ui/index.html +32 -0
  4. package/client-ui/pc/app.css +89 -1
  5. package/client-ui/pc/index.html +18 -8
  6. package/client-ui/pc/js/00-base.js +8 -30
  7. package/client-ui/pc/js/05-task-list-render.js +4 -1
  8. package/client-ui/pc/js/07-chat-stream-render.js +2 -85
  9. package/client-ui/pc/js/10-markdown.js +3 -171
  10. package/client-ui/pc/js/18-askbox.js +2 -176
  11. package/client-ui/pc/js/90-glue.js +1 -1
  12. package/client-ui/pc/js/91-workbench-stubs.js +4 -10
  13. package/client-ui/pc/js/93-rail.js +10 -3
  14. package/client-ui/shared/conv/01-dom.js +52 -0
  15. package/client-ui/shared/conv/02-media-kinds.js +34 -0
  16. package/client-ui/shared/conv/03-chat-message.js +1561 -0
  17. package/client-ui/shared/conv/06-ask-answer.js +377 -0
  18. package/client-ui/shared/conv/07-stream-delegation.js +158 -0
  19. package/client-ui/{pc/js → shared/conv}/09-stream-apply.js +121 -6
  20. package/client-ui/shared/conv/10-markdown-render.js +252 -0
  21. package/client-ui/shared/conv/18-askbox-markup.js +185 -0
  22. package/client-ui/{pc/js → shared/conv}/19-attachments.js +365 -21
  23. package/client-ui/shared/conv/27-conv-ledger.js +381 -0
  24. package/client-ui/{pc/js → shared/conv}/29-msg-variants.js +112 -3
  25. package/client-ui/shared/conv/README.md +257 -0
  26. package/client-ui/shared/conv/conv.css +1638 -0
  27. package/client-ui/shared/conv/dom-map.md +170 -0
  28. package/client-ui/shared/conv/manifest.json +44 -0
  29. package/client-ui/shared/conversation.css +368 -190
  30. package/client-ui/shared/conversation.js +1722 -788
  31. package/client-ui/shared/host-glue.js +143 -0
  32. package/client-ui/shared/transport-http.js +130 -6
  33. package/client-ui-mobile/index.html +2417 -103
  34. package/cmd/magic-client/clientctl.go +273 -0
  35. package/cmd/magic-client/main.go +71 -5
  36. package/go.mod +6 -3
  37. package/go.sum +4 -1
  38. package/internal/agent/arkclaw.go +3 -2
  39. package/internal/agent/arkclaw_stream.go +126 -8
  40. package/internal/agent/arkclaw_stream_test.go +97 -15
  41. package/internal/agent/arkclaw_test.go +3 -3
  42. package/internal/agent/attachments.go +18 -5
  43. package/internal/agent/attachments_test.go +7 -1
  44. package/internal/agent/capability_test.go +11 -1
  45. package/internal/agent/claude.go +96 -158
  46. package/internal/agent/codebuddy.go +108 -133
  47. package/internal/agent/codebuddy_gateway.go +8 -4
  48. package/internal/agent/codebuddy_gateway_stream.go +82 -2
  49. package/internal/agent/codebuddy_gateway_test.go +236 -1
  50. package/internal/agent/codebuddy_stream_test.go +262 -0
  51. package/internal/agent/codex.go +50 -9
  52. package/internal/agent/codex_stream_test.go +30 -0
  53. package/internal/agent/codex_test.go +24 -0
  54. package/internal/agent/custom_models.go +109 -3
  55. package/internal/agent/custom_models_test.go +74 -0
  56. package/internal/agent/dsh.go +464 -130
  57. package/internal/agent/dsh_resume.go +258 -0
  58. package/internal/agent/dsh_sdk.go +68 -17
  59. package/internal/agent/dsh_sdk_test.go +102 -2
  60. package/internal/agent/dsh_test.go +409 -36
  61. package/internal/agent/engine.go +26 -6
  62. package/internal/agent/engine_base.go +46 -2
  63. package/internal/agent/engine_base_test.go +29 -0
  64. package/internal/agent/engine_test.go +101 -0
  65. package/internal/agent/env.go +69 -1
  66. package/internal/agent/itemtracker.go +271 -0
  67. package/internal/agent/launch.go +192 -0
  68. package/internal/agent/launch_test.go +206 -0
  69. package/internal/agent/launch_unix.go +15 -0
  70. package/internal/agent/launch_windows.go +24 -0
  71. package/internal/agent/limitnotice.go +103 -0
  72. package/internal/agent/limitnotice_test.go +211 -0
  73. package/internal/agent/llmengine.go +2 -2
  74. package/internal/agent/models.go +3 -2
  75. package/internal/agent/openclaw.go +4 -3
  76. package/internal/agent/openclaw_acp.go +7 -0
  77. package/internal/agent/openclaw_test.go +2 -2
  78. package/internal/agent/permission.go +100 -11
  79. package/internal/agent/permission_test.go +100 -36
  80. package/internal/agent/runcmd.go +13 -3
  81. package/internal/agent/runner.go +7 -1
  82. package/internal/agent/runner_test.go +1 -1
  83. package/internal/agent/stream.go +777 -43
  84. package/internal/agent/stream_test.go +3 -2
  85. package/internal/agent/stream_timeline_test.go +905 -0
  86. package/internal/agent/streamjson.go +36 -2
  87. package/internal/agent/toolkind.go +20 -1
  88. package/internal/agent/toolkind_test.go +7 -1
  89. package/internal/agent/tools_suffix_test.go +10 -3
  90. package/internal/agent/trae.go +5 -3
  91. package/internal/agent/workspace_test.go +52 -0
  92. package/internal/cli/ask.go +260 -33
  93. package/internal/cli/ask_test.go +15 -11
  94. package/internal/cli/attach_test.go +32 -20
  95. package/internal/cli/clientcmd.go +156 -0
  96. package/internal/cli/events.go +53 -8
  97. package/internal/cli/events_test.go +230 -0
  98. package/internal/cli/keepalive_test.go +101 -6
  99. package/internal/cli/permission_test.go +5 -1
  100. package/internal/cli/root.go +174 -32
  101. package/internal/cli/session.go +175 -28
  102. package/internal/cli/session_test.go +144 -5
  103. package/internal/cli/sessionlog.go +385 -38
  104. package/internal/cli/sessionlog_test.go +708 -0
  105. package/internal/cli/sessionlog_user_test.go +142 -1
  106. package/internal/cli/sessiontitle.go +141 -0
  107. package/internal/client/browse.go +456 -0
  108. package/internal/client/browse_test.go +199 -0
  109. package/internal/client/cors_test.go +35 -0
  110. package/internal/client/daemon.go +525 -0
  111. package/internal/client/daemon_test.go +212 -0
  112. package/internal/client/detach_unix.go +19 -0
  113. package/internal/client/detach_windows.go +27 -0
  114. package/internal/client/pair.go +3 -3
  115. package/internal/client/plugins.go +376 -0
  116. package/internal/client/plugins_test.go +223 -0
  117. package/internal/client/proxy.go +374 -0
  118. package/internal/client/proxy_test.go +212 -0
  119. package/internal/client/service.go +1464 -48
  120. package/internal/client/service_test.go +649 -206
  121. package/internal/client/session_events_test.go +130 -0
  122. package/internal/client/wsproxy.go +113 -0
  123. package/internal/client/wsproxy_test.go +102 -0
  124. package/internal/session/delete.go +151 -0
  125. package/internal/session/delete_test.go +103 -0
  126. package/internal/session/session.go +155 -20
  127. package/internal/session/session_append.go +79 -21
  128. package/internal/session/session_test.go +124 -10
  129. package/internal/session/sessionlog.go +572 -6
  130. package/internal/session/sessionlog_read_test.go +410 -0
  131. package/internal/session/title.go +486 -0
  132. package/internal/session/title_test.go +298 -0
  133. package/npm/bin/magic-agent.js +101 -10
  134. package/npm/bin/magic-client.js +118 -0
  135. package/npm/dist/darwin-arm64/magic-agent +0 -0
  136. package/npm/dist/darwin-arm64/magic-client +0 -0
  137. package/npm/dist/darwin-x64/magic-agent +0 -0
  138. package/npm/dist/darwin-x64/magic-client +0 -0
  139. package/npm/dist/linux-arm64/magic-agent +0 -0
  140. package/npm/dist/linux-arm64/magic-client +0 -0
  141. package/npm/dist/linux-x64/magic-agent +0 -0
  142. package/npm/dist/linux-x64/magic-client +0 -0
  143. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  144. package/npm/dist/win32-x64/magic-client.exe +0 -0
  145. package/npm/install.js +36 -13
  146. package/npm/lib/platform.js +27 -5
  147. package/package.json +8 -3
  148. package/client-ui/pc/js/03-chat-message.js +0 -880
  149. package/client-ui/pc/js/06-ask-answer.js +0 -204
  150. /package/client-ui/{pc/js → shared/conv}/08-ask-event-map.js +0 -0
  151. /package/client-ui/{pc → shared}/vendor/highlight.min.js +0 -0
  152. /package/client-ui/{pc → shared}/vendor/marked.min.js +0 -0
  153. /package/client-ui/{pc → shared}/vendor/purify.min.js +0 -0
@@ -271,47 +271,10 @@ func (c codebuddyCore) complete(ctx context.Context, req Request) (Response, err
271
271
  // 自定义模型才补端点(用户可能刚在 App 里配好就直接 -m 调用,没打开过选择器)。
272
272
  c.ensureCustomModelEndpoint(req.Model)
273
273
 
274
- // 附件(截图):与 claude 同族,--print 文本模式收不了图,走
275
- // --input-format stream-json(图片 content block 经 stdin)。
276
- if hasImageAttachment(req.Attachments) {
277
- return c.completeWithAttachments(ctx, req, start)
278
- }
279
-
280
- args := c.buildArgs(req)
281
-
282
- // workspace:codebuddy 与 claude 同族(无工作目录 flag,--add-dir 只加额外目录)
283
- stdout, stderr, err := runCLIEnvIn(ctx, req.Workspace, c.extraEnv, bin, args...)
284
- if err != nil {
285
- return Response{}, wrapCliError(c.name, stdout, stderr, err)
286
- }
287
-
288
- raw := strings.TrimSpace(stdout)
289
- if raw == "" {
290
- return Response{}, fmt.Errorf("%s CLI returned empty output", c.name)
291
- }
292
-
293
- text := extractCodeBuddyResult(raw)
294
-
295
- // 兜底:个别版本会把 user 请求回显(<user_query>…</user_query>)当正文。
296
- if cleaned := stripUserQueryEcho(text); cleaned != text {
297
- text = cleaned
298
- }
299
- if strings.TrimSpace(text) == "" {
300
- return Response{}, fmt.Errorf("%s CLI 返回内容仅为请求回显(无模型正文)", c.name)
301
- }
302
-
303
- return Response{
304
- Text: text,
305
- Model: req.Model,
306
- SessionID: extractCodeBuddySession(raw),
307
- Latency: time.Since(start),
308
- }, nil
309
- }
310
-
311
- // completeWithAttachments 走 stream-json 输入通道跑一次带附件的调用
312
- // (codebuddy 与 claude 同族协议),把事件归约成单次 Response。
313
- func (c codebuddyCore) completeWithAttachments(ctx context.Context, req Request, start time.Time) (Response, error) {
314
- bin := c.bin()
274
+ // 提示词走 stdin 的 stream-json 通道(**所有**调用,含无附件的纯文本)。
275
+ // 与 claude 完全同构、同理由:位置参数在 Windows 上会被 .cmd 批处理拆坏
276
+ // (含换行时整段丢失),详见 claude.go Complete 的注释与 launch.go。
277
+ // 2026-10-01 起这不再是「附件专用」路径。
315
278
  prompt := FlattenPrompt(req.SystemPrompt, req.Messages, false)
316
279
  if prompt == "" {
317
280
  return Response{}, fmt.Errorf("%s: empty prompt", c.name)
@@ -321,19 +284,26 @@ func (c codebuddyCore) completeWithAttachments(ctx context.Context, req Request,
321
284
  if err != nil {
322
285
  return Response{}, err
323
286
  }
324
- acc := &streamAccumulator{Engine: c.name, OnSessionID: req.OnSessionID}
287
+ acc := &streamAccumulator{
288
+ Engine: c.name,
289
+ OnSessionID: req.OnSessionID,
290
+ SanitizeTurnText: stripThinkBlock,
291
+ }
325
292
  var fin streamJSONResult
326
- seen, err := runStreamJSONIn(ctx, req.Workspace, c.extraEnv, bin, args, strings.NewReader(stdin), acc, &fin)
293
+ // CLI 的 stderr 尾巴留一份:非零退出与「没有 result 行」都要拿它拼可读原因
294
+ //(见 streamNoResultErr;不带的话只剩一句「stream ended without result line」)。
295
+ var cliStderr streamStderrBuf
296
+ seen, err := runStreamJSONIn(ctx, req.Workspace, c.extraEnv, bin, args, strings.NewReader(stdin), acc, &fin, &cliStderr)
327
297
  if err != nil {
328
- return Response{}, wrapCliError(c.name, "", "", err)
298
+ return Response{}, wrapCliError(c.name, "", cliStderr.String(), err)
329
299
  }
330
300
  if !seen {
331
- return Response{}, fmt.Errorf("%s CLI stream ended without result line", c.name)
301
+ return Response{}, streamNoResultErr(c.name, cliStderr.String())
332
302
  }
333
303
  if fin.IsError {
334
304
  return Response{}, fmt.Errorf("%s CLI error (subtype=%s): %s", c.name, fin.Subtype, truncateStr(fin.Result, 500))
335
305
  }
336
- text := stripUserQueryEcho(finalizeStreamText(fin, acc))
306
+ text := stripThinkBlock(stripUserQueryEcho(finalizeStreamText(fin, acc)))
337
307
  if strings.TrimSpace(text) == "" {
338
308
  return Response{}, fmt.Errorf("%s CLI 返回内容仅为请求回显(无模型正文)", c.name)
339
309
  }
@@ -345,13 +315,15 @@ func (c codebuddyCore) completeWithAttachments(ctx context.Context, req Request,
345
315
  }, nil
346
316
  }
347
317
 
348
- // buildArgs 构造 codebuddy CLI 参数(Complete 与 Stream 共用)。
318
+ // buildArgs 构造 codebuddy CLI 参数(含末尾的位置参数 prompt)。
319
+ //
320
+ // ⚠️ 2026-10-01 起**生产路径已不再用它**(提示词全走 stdin),
321
+ // 保留是因为 buildArgsBase 的语义仍被 streamJSONArgs 依赖。
349
322
  func (c codebuddyCore) buildArgs(req Request) []string {
350
323
  return append(c.buildArgsBase(req), FlattenPrompt(req.SystemPrompt, req.Messages, false))
351
324
  }
352
325
 
353
- // buildArgsBase 构造参数(不含末尾的位置参数 prompt)——附件场景复用:
354
- // 那条路提示词走 stdin,不能再作为命令行参数传。
326
+ // buildArgsBase 构造参数(不含末尾的位置参数 prompt)——提示词走 stdin 的路径复用它。
355
327
  func (c codebuddyCore) buildArgsBase(req Request) []string {
356
328
  args := []string{"--print", "--output-format", "json"}
357
329
  switch {
@@ -381,18 +353,25 @@ func (c codebuddyCore) buildArgsBase(req Request) []string {
381
353
  // 四档权限档位:codebuddy 与 claude 同族,--permission-mode 取值完全一致
382
354
  //(default / acceptEdits / auto / bypassPermissions),故复用同一映射。
383
355
  // 改造前这里恒传 -y(= --dangerously-skip-permissions,第 4 档)。
384
- args = append(args, "--permission-mode", claudePermissionMode(req.Permission))
356
+ // ⚠️ 2026-10-01:与 claude 同坑 —— 只传 --permission-mode 不传 --tools 时,
357
+ // codebuddy CLI 在 stream-json 模式下也不发 result 行;必须显式 --tools default。
358
+ args = append(args, "--tools", "default",
359
+ "--permission-mode", claudePermissionMode(req.Permission))
385
360
  default:
386
361
  args = append(args, "--tools", strings.Join(tools.Allowlist(), ","),
387
362
  "--permission-mode", claudePermissionMode(req.Permission))
388
363
  }
389
- if m := stripModelPrefix(req.Model); m != "" {
364
+ if m := c.modelArg(req.Model); m != "" {
390
365
  args = append(args, "--model", m)
391
366
  } else if c.model != "" {
392
367
  // 默认模型(codebuddy=hy3;codebuddy-ai 故意留空 = CLI 自身默认,
393
368
  // 见文件头的模型注册表漂移说明);空串 = 不传 --model。
394
369
  args = append(args, "--model", c.model)
395
370
  }
371
+ // 推理档位:codebuddy 的 --effort 认 minimal/low/medium/high/xhigh/max。
372
+ if lv := ReasoningLevel(req.Reasoning); lv != "" {
373
+ args = append(args, "--effort", lv)
374
+ }
396
375
  // 统一参数矩阵:`--settings` 只接受一份载荷,故 MaxTokens 的 env 注入与
397
376
  // 四档模型的 sandbox / autoMode / permissions 合并进同一个 JSON。
398
377
  // 注意 codebuddy 的沙箱键集与 claude 略有差异(文档未列 failIfUnavailable),
@@ -414,97 +393,52 @@ func (c codebuddyCore) stream(ctx context.Context, req Request, onEvent func(Str
414
393
  // 自定义模型才补端点(同 complete,见 custom_models.go)。
415
394
  c.ensureCustomModelEndpoint(req.Model)
416
395
 
417
- args := c.buildArgs(req)
418
- if req.Append != nil || hasImageAttachment(req.Attachments) {
419
- // 附件 / 常驻会话 → stream-json 输入通道:提示词改走 stdin(见 streamjson.go)。
420
- prompt := FlattenPrompt(req.SystemPrompt, req.Messages, false)
421
- var stdin io.Reader
422
- if req.Append != nil {
423
- pipe, perr := streamJSONInputPipe(prompt, req.Attachments, req.Append)
424
- if perr != nil {
425
- return StreamResult{}, perr
426
- }
427
- stdin = pipe
428
- } else {
429
- line, serr := streamJSONUserLine(prompt, req.Attachments)
430
- if serr != nil {
431
- return StreamResult{}, serr
432
- }
433
- stdin = strings.NewReader(line)
434
- }
435
- args = streamJSONArgs(c.buildArgsBase(req), prompt, true)
436
- acc := &streamAccumulator{Engine: c.name, OnEvent: onEvent, OnSessionID: req.OnSessionID}
437
- var fin streamJSONResult
438
- seen, err := runStreamJSONIn(ctx, req.Workspace, c.extraEnv, bin, args, stdin, acc, &fin)
439
- if err != nil {
440
- return StreamResult{}, err
441
- }
442
- if !seen {
443
- return StreamResult{}, fmt.Errorf("%s CLI stream ended without result line", c.name)
444
- }
445
- if fin.IsError {
446
- return StreamResult{}, fmt.Errorf("%s CLI error (subtype=%s): %s", c.name, fin.Subtype, truncateStr(fin.Result, 500))
447
- }
448
- text := stripUserQueryEcho(finalizeStreamText(fin, acc))
449
- if strings.TrimSpace(text) == "" {
450
- return StreamResult{}, fmt.Errorf("%s CLI 返回内容仅为请求回显(无模型正文)", c.name)
451
- }
452
- return StreamResult{
453
- Response: Response{
454
- Engine: c.name,
455
- Text: text,
456
- Model: fin.Model,
457
- SessionID: fin.SessionID,
458
- Latency: time.Since(start),
459
- },
460
- Thinking: acc.Thinking.String(),
461
- Tools: acc.Tools,
462
- }, nil
463
- }
464
- for i := range args {
465
- if args[i] == "--output-format" {
466
- args[i+1] = "stream-json"
467
- break
468
- }
469
- }
470
- args = append(args, "--include-partial-messages", "--verbose")
471
-
472
- acc := &streamAccumulator{Engine: c.name, OnEvent: onEvent}
473
- var fin struct {
474
- Type string `json:"type"`
475
- Subtype string `json:"subtype"`
476
- IsError bool `json:"is_error"`
477
- Result string `json:"result"`
478
- SessionID string `json:"session_id"`
479
- Model string `json:"model"`
396
+ // 提示词一律走 stdin 的 stream-json 输入通道(不再当位置参数)。
397
+ // 与 claude 完全同构、同理由(2026-10-01 改,Windows 实测):
398
+ // 位置参数在 Windows 上会被 .cmd 批处理拆坏,含换行时整段丢失。
399
+ // 常驻会话(Request.Append 非 nil)的区别只在 stdin 是个**流**。
400
+ prompt := FlattenPrompt(req.SystemPrompt, req.Messages, false)
401
+ if prompt == "" {
402
+ return StreamResult{}, fmt.Errorf("%s: empty prompt", c.name)
480
403
  }
481
- seenResult := false
482
-
483
- err := runStreamCLIEnvIn(ctx, req.Workspace, c.extraEnv, bin, args, func(line string) error {
484
- isResult, perr := acc.handleNDJSONLine(line)
404
+ var stdin io.Reader
405
+ if req.Append != nil {
406
+ pipe, perr := streamJSONInputPipe(prompt, req.Attachments, req.Append)
485
407
  if perr != nil {
486
- return perr
408
+ return StreamResult{}, perr
487
409
  }
488
- if isResult {
489
- _ = json.Unmarshal([]byte(line), &fin)
490
- seenResult = true
410
+ stdin = pipe
411
+ } else {
412
+ line, serr := streamJSONUserLine(prompt, req.Attachments)
413
+ if serr != nil {
414
+ return StreamResult{}, serr
491
415
  }
492
- return nil
493
- })
416
+ stdin = strings.NewReader(line)
417
+ }
418
+ args := streamJSONArgs(c.buildArgsBase(req), prompt, true)
419
+ acc := &streamAccumulator{
420
+ Engine: c.name,
421
+ OnEvent: onEvent,
422
+ OnSessionID: req.OnSessionID,
423
+ AggregateOnly: true,
424
+ SanitizeTurnText: stripThinkBlock,
425
+ }
426
+ var fin streamJSONResult
427
+ // 同上:stderr 尾巴是「为什么没走完协议」的唯一线索。
428
+ var cliStderr streamStderrBuf
429
+ seen, err := runStreamJSONIn(ctx, req.Workspace, c.extraEnv, bin, args, stdin, acc, &fin, &cliStderr)
494
430
  if err != nil {
495
431
  return StreamResult{}, err
496
432
  }
497
- if !seenResult {
498
- return StreamResult{}, fmt.Errorf("%s CLI stream ended without result line", c.name)
433
+ if !seen {
434
+ err := streamNoResultErr(c.name, cliStderr.String())
435
+ acc.emitTurnFailed(acc.SessionID, err)
436
+ return StreamResult{}, err
499
437
  }
500
438
  if fin.IsError {
501
439
  return StreamResult{}, fmt.Errorf("%s CLI error (subtype=%s): %s", c.name, fin.Subtype, truncateStr(fin.Result, 500))
502
440
  }
503
-
504
- text := stripUserQueryEcho(fin.Result)
505
- if strings.TrimSpace(text) == "" {
506
- text = strings.TrimSpace(acc.Text.String())
507
- }
441
+ text := stripThinkBlock(stripUserQueryEcho(finalizeStreamText(fin, acc)))
508
442
  if strings.TrimSpace(text) == "" {
509
443
  return StreamResult{}, fmt.Errorf("%s CLI 返回内容仅为请求回显(无模型正文)", c.name)
510
444
  }
@@ -533,8 +467,8 @@ type CodeBuddyEngine struct {
533
467
  // Name 实现 Engine。
534
468
  func (e *CodeBuddyEngine) Name() string { return "codebuddy" }
535
469
 
536
- // DefaultCodeBuddyTimeout 单次尝试默认超时(codebuddy / codebuddy-ai 共用)。
537
- const DefaultCodeBuddyTimeout = 5 * time.Minute
470
+ // DefaultCodeBuddyTimeout 单次尝试默认超时(codebuddy / codebuddy-ai 共用;2026-10-10 由 5 分钟统一上调到 1 小时)。
471
+ const DefaultCodeBuddyTimeout = time.Hour
538
472
 
539
473
  // DefaultCodeBuddyModel codebuddy 引擎的默认模型。
540
474
  // 未显式 -m 指定时使用 hy3(与 magic-video 的 DefaultCreativeModel 一致)。
@@ -873,6 +807,47 @@ func (e *CodeBuddyAIEngine) buildArgsBase(req Request) []string { return e.core(
873
807
 
874
808
  // ── 输出解析(两个后端共用)──────────────────────────────────
875
809
 
810
+ // stripThinkBlock 剥离整段 <think>…</think> 思维链块。
811
+ //
812
+ // 为什么需要(2026-10-01 实测):codebuddy 在 --tools off + 「直接输出纯文本
813
+ // markdown」的约束下,仍会把推理写进 result 字段,形如
814
+ //
815
+ // <think>The user is asking me to…</think>\n\n1
816
+ //
817
+ // 也就是**推理与正文同处一个字符串**。不剥掉的话:
818
+ // - `-o text` 的正文里混着英文推理,用户看到的是一段莫名其妙的话;
819
+ // - 更糟的是 --tools off 注入的约束里有「第一行必须是答案正文」,
820
+ // 模型为遵守它而把答案挪到 think 块之后,客户端按行渲染会全乱。
821
+ //
822
+ // 与 stripUserQueryEcho 的关系:两者都是「剥掉不该当正文的前缀」,
823
+ // 串联使用(先剥回显、再剥思维链)。llm 引擎的 thinkSplitter 是**流式**版本
824
+ // (逐行喂、边喂边分流),这里是整段版本,用于 result 行一次性给全的场景。
825
+ //
826
+ // 找不到闭合标签时原样返回(宁可把标签露出来,也不要吞掉真内容)。
827
+ //
828
+ // ⚠️ 必须**循环**剥而不是只剥第一段:codebuddy 在多轮工具调用后会把每一轮的
829
+ // 推理各写一个 think 块(<think>a</think>正文<think>b</think>正文…)。
830
+ // 只剥第一段的话,第二段推理会原样漏进用户看到的正文 —— 症状是
831
+ // 「回答中间突然冒出一段英文」,比整段漏出来更难定位。
832
+ func stripThinkBlock(s string) string {
833
+ const (
834
+ open = "<think>"
835
+ close = "</think>"
836
+ )
837
+ for {
838
+ i := strings.Index(s, open)
839
+ if i < 0 {
840
+ return s
841
+ }
842
+ j := strings.Index(s[i:], close)
843
+ if j < 0 {
844
+ // 未闭合 → 整段原样(不吞真内容)
845
+ return s
846
+ }
847
+ s = strings.TrimSpace(s[i+j+len(close):])
848
+ }
849
+ }
850
+
876
851
  // stripUserQueryEcho 剥离 <user_query>…</user_query> 请求回显。
877
852
  func stripUserQueryEcho(s string) string {
878
853
  t := strings.TrimSpace(s)
@@ -79,10 +79,10 @@ import (
79
79
 
80
80
  // DefaultCBGatewayTimeout 单次调用的默认超时。
81
81
  //
82
- // 10 分钟:网关侧的 runTimeoutMs 默认就是 1_800_000(30 分钟,实测值来自网关内核
83
- // DEFAULT_RUN_TIMEOUT_MS),而 CLI 的 -t 默认也是 600s —— 取 10 分钟与 CLI 层一致,
84
- // 避免「CLI 说 600s、引擎内部却更短」这种自相矛盾。要跑更久的任务显式给 -t。
85
- const DefaultCBGatewayTimeout = 10 * time.Minute
82
+ // 网关侧的 runTimeoutMs 默认就是 1_800_000(30 分钟,实测值来自网关内核
83
+ // DEFAULT_RUN_TIMEOUT_MS),本引擎默认取 1 小时与 CLI 的 -t 默认一致(2026-10-10 由 10 分钟
84
+ // 上调),避免「CLI 说 1h、引擎内部却更短」这种自相矛盾。要跑更久的任务显式给 -t。
85
+ const DefaultCBGatewayTimeout = time.Hour
86
86
 
87
87
  // cbGatewayMaxBody 单次响应体读取上限(webhook 受理响应很小,留足余量即可)。
88
88
  const cbGatewayMaxBody = 1 << 20 // 1 MiB
@@ -337,6 +337,10 @@ func (e *CodeBuddyGatewayEngine) run(ctx context.Context, req Request, onEvent f
337
337
  sessionID: convID,
338
338
  }
339
339
  if err := e.consumeStream(httpCtx, client, r, runID, st, req.JSONSchema); err != nil {
340
+ // 流被截断 / 任务 failed / HTTP 拒绝都走这一条:已发出的正文 item
341
+ // 必须收成 failed,不能让它永远停在 running(消费方只能靠超时猜,
342
+ // 历史回放里那一条会永远显示「生成中」)。语义见 cbGatewayStreamState.fail。
343
+ st.fail(err)
340
344
  return st.result(e, req, start), err
341
345
  }
342
346
  res := st.result(e, req, start)
@@ -115,6 +115,21 @@ type cbGatewayStreamState struct {
115
115
  status string // 最近一次 status(错误信息用)
116
116
  sessionID string // 会话锚点(网关回 sessionId 时以它为准)
117
117
  tools []ToolCall
118
+
119
+ // tracker 状态收敛的身份与版本(见 itemtracker.go)。
120
+ // 原生 id = 网关分配的 runId(一个 run 恒定,见 codebuddy_gateway.go 的 deliver)。
121
+ textTrack *itemTracker
122
+ // textTrack 是否已经发过事件(发过就不能再换 item id)。
123
+ textStarted bool
124
+ }
125
+
126
+ // trackers 惰性建立跟踪器。
127
+ func (st *cbGatewayStreamState) trackers() *itemTracker {
128
+ if st.textTrack == nil {
129
+ st.textTrack = newItemTracker(cbGatewayEngineName, laneText)
130
+ st.textTrack.Begin(st.runID)
131
+ }
132
+ return st.textTrack
118
133
  }
119
134
 
120
135
  // Stream 实现 Streamer:走 webhook 投递 + SSE 收流(见文件头)。
@@ -241,6 +256,8 @@ func (st *cbGatewayStreamState) handleFrame(raw []byte, schema *JSONSchema) (boo
241
256
  st.final = text
242
257
  st.completed = true
243
258
  st.emitText(text)
259
+ // completed = 轮次终点:发 turn_end 把 item 收成 final(见 finish 的注释)。
260
+ st.finish(firstNonEmpty(st.convID, st.sessionID))
244
261
  return true, nil
245
262
 
246
263
  case "streaming":
@@ -279,9 +296,72 @@ func (st *cbGatewayStreamState) emitText(full string) {
279
296
  return
280
297
  }
281
298
  st.emitted.WriteString(full)
282
- if st.onEvent != nil {
283
- st.onEvent(StreamEvent{Kind: KindText, Text: full})
299
+ if st.onEvent == nil {
300
+ return
301
+ }
302
+ // ⚠️ snapshot 直接取 st.emitted(累积到此刻的全文),**不能**喂给
303
+ // tracker 的 Append —— Append 的语义是「把这段**增量**拼到已有 snapshot
304
+ // 后面」,而这里传进去的是「累积到此刻的全文」,两者混用会逐帧翻倍
305
+ // (第一帧"你好"、第二帧"你好世界",累加后得到"你好你好世界")。
306
+ //
307
+ // 正确分工见 itemtracker.go 文件头:tracker 管 id / revision / status,
308
+ // 内容累积由调用方独家负责。nextWith 就是为此准备的入口。
309
+ ev := StreamEvent{Kind: KindText, Text: full}
310
+ st.trackers().nextWith(StatusRunning, st.emitted.String()).apply(&ev)
311
+ st.textStarted = true
312
+ st.onEvent(ev)
313
+ }
314
+
315
+ // fail 异常收尾(status=failed):流被截断 / 任务 failed / HTTP 拒绝 / 读出错。
316
+ //
317
+ // 与 finish 的分工:turn_end = 网关给了 completed 帧(成功收尾),
318
+ // turn_failed = 没有(异常收尾),带 Error / Reason。
319
+ // 挂到**已有**正文 item 上:partial 的内容仍然存在,只是这条 item 不再增长。
320
+ // 从没建立过 item 时直接返回 —— 不该为「一句正文都没流出来」的轮次硬造 item。
321
+ //
322
+ // 只有一个调用点(run 里的 consumeStream 错误分支):它已经覆盖了
323
+ // handleFrame 报错、读流中断、无终帧、非 SSE 缺终帧等全部异常出口,
324
+ // 在这里收口比在每个 return err 处各写一遍可靠。
325
+ func (st *cbGatewayStreamState) fail(err error) {
326
+ if st.onEvent == nil || err == nil || !st.textStarted {
327
+ return
328
+ }
329
+ ev := StreamEvent{
330
+ Kind: KindTurnFailed,
331
+ SessionID: firstNonEmpty(st.convID, st.sessionID),
332
+ Error: err.Error(),
333
+ Reason: ReasonOf(err),
334
+ Status: StatusFailed,
335
+ }
336
+ if attrs, ok := st.trackers().Failed(st.emitted.String()); ok {
337
+ attrs.apply(&ev)
338
+ }
339
+ st.onEvent(ev)
340
+ }
341
+
342
+ // finish 收尾:把 item 标成 final(completed 帧已到,正文不会再变)。
343
+ //
344
+ // 发的是 **KindTurnEnd** 而不是又一条 KindText,原因有三个:
345
+ // 1. 语义对:completed 帧是**轮次边界**,不是「又来了一段正文」。
346
+ // 之前试过补一条空 Text 的 KindText 帧,能过测试但语义是错的 ——
347
+ // 消费方按 Text 拼正文的实现会收到一个空增量。
348
+ // 2. 不破坏既有收集器:现有测试与调用方都按「KindText 就是正文增量」收集,
349
+ // 多一条空帧会让它们数错条数。
350
+ // 3. 轮次边界有独立信号后,思考 lane(将来若有)也能靠它收尾 ——
351
+ // 与 claude / dsh 引擎的处理方式一致。
352
+ //
353
+ // ⚠️ keep-alive 的空闲收工挂在 KindTurnEnd 上(见 keepAlive 的调用点),
354
+ // 但 codebuddy-gateway 不在 AppendSupportOf 名单里,不会启用常驻会话,
355
+ // 所以这里发 turn_end 是安全的。
356
+ func (st *cbGatewayStreamState) finish(sessionID string) {
357
+ if st.onEvent == nil || !st.textStarted {
358
+ return
359
+ }
360
+ ev := StreamEvent{Kind: KindTurnEnd, Text: st.final, SessionID: sessionID}
361
+ if attrs, ok := st.trackers().Finalize(st.emitted.String()); ok {
362
+ attrs.apply(&ev)
284
363
  }
364
+ st.onEvent(ev)
285
365
  }
286
366
 
287
367
  // result 把累积状态收成 StreamResult(终态与错误路径共用)。