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
@@ -14,6 +14,8 @@ import (
14
14
  "path/filepath"
15
15
  "strings"
16
16
  "testing"
17
+
18
+ "github.com/darren/magic-agent/internal/agent"
17
19
  )
18
20
 
19
21
  // writeAttachFile 写一个测试文件(内容随意 —— MIME 由魔数/扩展名嗅探,这里只关心路径链路)。
@@ -156,48 +158,58 @@ func TestAttachmentPromptFallbackWarnUnit(t *testing.T) {
156
158
  if !strings.Contains(gotOff, "--engines") {
157
159
  t.Errorf("提示应指引到 --engines 的 attachments 字段: %q", gotOff)
158
160
  }
159
- // dsh / openclaw:同样无原生通道,但 --tools 在它们身上没有落地通道
160
- //(自带工具循环)→ 提示里**不该**出现「请改用 --tools on」(传了也不改变行为)。
161
- for _, e := range []string{"dsh", "openclaw"} {
162
- got := attachmentPromptFallbackWarn(e, 1, true)
163
- if !strings.Contains(got, e) || !strings.Contains(got, "没有附件输入通道") {
164
- t.Errorf("%s 应提示降级: %q", e, got)
165
- }
166
- if strings.Contains(got, "--tools on") {
167
- t.Errorf("%s 的 --tools 无落地通道,不该提 --tools on: %q", e, got)
168
- }
161
+ // openclaw:无原生通道,且 --tools 在它身上没有落地通道(自带工具循环)→
162
+ // 提示里**不该**出现「请改用 --tools on」(传了也不改变行为)。
163
+ gotOpenclaw := attachmentPromptFallbackWarn("openclaw", 1, true)
164
+ if !strings.Contains(gotOpenclaw, "openclaw") || !strings.Contains(gotOpenclaw, "没有附件输入通道") {
165
+ t.Errorf("openclaw 应提示降级: %q", gotOpenclaw)
166
+ }
167
+ if strings.Contains(gotOpenclaw, "--tools on") {
168
+ t.Errorf("openclaw 的 --tools 无落地通道,不该提 --tools on: %q", gotOpenclaw)
169
+ }
170
+ // dsh:SDK 通道(默认)原生收图 → 有原生通道,不该有任何降级提示。
171
+ t.Setenv(agent.DshProfileEnv, "")
172
+ if got := attachmentPromptFallbackWarn("dsh", 1, true); got != "" {
173
+ t.Errorf("dsh(SDK 通道) 有原生通道,不该提示降级: %q", got)
169
174
  }
170
175
  }
171
176
 
172
- // 端到端:trae(无原生通道)带附件 + 默认 --tools off → stderr 出现降级提示。
177
+ // 端到端:trae(无原生通道)带附件 → stderr 出现降级提示,但不再提 --tools off
178
+ // (2026-10-01 默认从 off 反转为 on —— trae 默认工具已开,附件还是降级,但**不要**
179
+ // 再提示「请改用 --tools on」,因为已经是 on 了;提示要瘦身、避免噪音)。
173
180
  func TestAttachFlagWarnsOnPromptFallbackEngine(t *testing.T) {
174
181
  registerFake(&stringEngine{name: "trae", text: "ok"})
175
182
  img := writeAttachFile(t, "shot.png", "\x89PNG\r\n\x1a\n")
176
183
 
184
+ // 默认 --tools on:仍提示降级(trae 自身问题,与工具开关无关),但**不**提 --tools off
177
185
  _, stderr, err := runAskCmd(t, "", "-e", "trae", "-p", "看图", "-a", img)
178
186
  if err != nil {
179
187
  t.Fatalf("ask: %v", err)
180
188
  }
181
- if !strings.Contains(stderr, "没有附件输入通道") || !strings.Contains(stderr, "--tools off") {
182
- t.Errorf("stderr 应给出降级提示, got %q", stderr)
189
+ if !strings.Contains(stderr, "没有附件输入通道") {
190
+ t.Errorf("默认状态仍应提示降级, got %q", stderr)
191
+ }
192
+ if strings.Contains(stderr, "--tools off") {
193
+ t.Errorf("默认 --tools on,不该再提 --tools off: %q", stderr)
183
194
  }
184
195
 
185
- // --tools on → 只提示降级,不再提读不到
186
- _, stderrOn, err := runAskCmd(t, "", "-e", "trae", "-p", "看图", "--tools", "on", "-a", img)
196
+ // --tools off → 必须点明读不到(反向场景仍然要明确警告)
197
+ _, stderrOff, err := runAskCmd(t, "", "-e", "trae", "-p", "看图", "--tools", "off", "-a", img)
187
198
  if err != nil {
188
199
  t.Fatalf("ask: %v", err)
189
200
  }
190
- if !strings.Contains(stderrOn, "没有附件输入通道") {
191
- t.Errorf("仍应提示降级, got %q", stderrOn)
201
+ if !strings.Contains(stderrOff, "没有附件输入通道") {
202
+ t.Errorf("仍应提示降级, got %q", stderrOff)
192
203
  }
193
- if strings.Contains(stderrOn, "--tools off") {
194
- t.Errorf("工具已开,不该再提 --tools off: %q", stderrOn)
204
+ if !strings.Contains(stderrOff, "--tools off") || !strings.Contains(stderrOff, "--tools on") {
205
+ t.Errorf("显式 --tools off 时必须点明读不到 + 指引 --tools on: %q", stderrOff)
195
206
  }
196
207
  }
197
208
 
198
209
  // ── --engines 的 attachments 字段 ────────────────────────────
199
210
 
200
211
  func TestEnginesFlagCarriesAttachmentsCapability(t *testing.T) {
212
+ t.Setenv(agent.DshProfileEnv, "") // dsh 默认 SDK 通道 → 原生收图
201
213
  registerFake(&stringEngine{name: "codex"})
202
214
  registerFake(&stringEngine{name: "claude"})
203
215
  registerFake(&stringEngine{name: "arkclaw"})
@@ -224,7 +236,7 @@ func TestEnginesFlagCarriesAttachmentsCapability(t *testing.T) {
224
236
  "claude": "stdin:stream-json",
225
237
  "arkclaw": "part:file",
226
238
  "trae": "prompt",
227
- "dsh": "prompt", // headless 无附件参数 → 路径写进提示词
239
+ "dsh": "sdk:content-blocks", // SDK 通道原生 image 块 → 真进模型上下文
228
240
  } {
229
241
  if got[engine] != want {
230
242
  t.Errorf("--engines 里 %s 的 attachments = %q want %q", engine, got[engine], want)
@@ -0,0 +1,156 @@
1
+ package cli
2
+
3
+ // clientcmd.go - `magic-agent client <args...>`:把客户端插件的后台管理动作转给 magic-client。
4
+ //
5
+ // ── 为什么是「转发」而不是「在这里实现」 ──
6
+ // `internal/boundary` 钉着一条硬约束:core 的依赖闭包里不许出现 `internal/client`
7
+ //(桌面 / 移动端是**可选**插件,不装插件时 core 必须照旧能跑)。所以 core 不能调用
8
+ // StartDaemon 这类函数 —— 它只做一件 core 该做的事:找到那个可执行文件,把参数原样交出去。
9
+ //
10
+ // ── 为什么不解析参数 ──
11
+ // 参数集合由 magic-client 自己定义(--lan / --ui-dir / --port…)。在 core 里再抄一份,
12
+ // 两份迟早分叉,而分叉的表现是「core 认的 flag 插件不认」这种最难查的错。
13
+ // 所以这里 DisableFlagParsing,`client` 之后的一切原样透传,**包括 --help**。
14
+ //
15
+ // ── 为什么设置 MAGIC_AGENT_BIN ──
16
+ // magic-client 启动服务时要调 core 的 `--contract`。让它拿 PATH 里那份,
17
+ // 会和「你正在用的这份 core」不一致(版本不同 → 契约校验直接失败)。
18
+ // 把自己交出去,版本必然对得上。
19
+
20
+ import (
21
+ "errors"
22
+ "fmt"
23
+ "os"
24
+ "os/exec"
25
+ "path/filepath"
26
+ "runtime"
27
+ "strings"
28
+
29
+ "github.com/spf13/cobra"
30
+ )
31
+
32
+ // clientSubcommand 构造 `client` 子命令。
33
+ func clientSubcommand() *cobra.Command {
34
+ return &cobra.Command{
35
+ Use: "client <start|stop|status> [flags]",
36
+ Short: "客户端插件(magic-client):后台启动 / 停止 / 看状态,手机据此连接",
37
+ Long: `把参数原样交给 magic-client(客户端插件,随 npm 包一起安装)。
38
+
39
+ magic-agent client start --lan # 后台常驻,打印手机可用的地址与配对二维码
40
+ magic-agent client status # 在不在、端口、手机地址、健康检查
41
+ magic-agent client stop # 停掉(本来没跑也算成功)
42
+
43
+ 为什么要有 start 而不是直接 serve:serve 是前台进程,关掉终端就没了;端口又由系统分配,
44
+ 下次启动换一个 —— 用户既记不住也没法配对。start 会把 pid / 端口 / 令牌落成一份 0600 的
45
+ 状态文件(~/.magic-client/client.json),三个动作围绕它工作,令牌跨重启稳定
46
+ (存 ~/.magic-client/token,删掉它才会换)。
47
+
48
+ ⚠️ --lan 只绑局域网:手机要和这台电脑在**同一个 Wi-Fi**。跨网访问不在本命令范围内。
49
+
50
+ 本命令不在 core 里实现,只负责转发(桌面 / 移动端是可选插件,core 不依赖它)。
51
+ 详细 flag 见 magic-client start --help。`,
52
+ // 参数原样透传:包括 --lan / --help。cobra 自己别再解析。
53
+ DisableFlagParsing: true,
54
+ // 兼容 `magic-agent client`(无子动作)—— 交给 magic-client,它会打印自己的用法。
55
+ Args: cobra.ArbitraryArgs,
56
+ RunE: func(cmd *cobra.Command, args []string) error {
57
+ exe, err := resolveClientExe()
58
+ if err != nil {
59
+ return err
60
+ }
61
+ return execClient(cmd, exe, args)
62
+ },
63
+ }
64
+ }
65
+
66
+ // resolveClientExe 定位 magic-client 可执行文件。
67
+ //
68
+ // 顺序:MAGIC_CLIENT_BIN > 与 core 同目录 > PATH > npm 全局前缀。
69
+ // 同目录排第二是因为 npm 包里两个二进制并排放(npm/dist/<plat>/),
70
+ // 而 PATH 里那份可能是别处装的、版本不一致的旧货。
71
+ func resolveClientExe() (string, error) {
72
+ name := "magic-client"
73
+ if runtime.GOOS == "windows" {
74
+ name = "magic-client.exe"
75
+ }
76
+ if p := strings.TrimSpace(os.Getenv("MAGIC_CLIENT_BIN")); p != "" {
77
+ if _, err := os.Stat(p); err == nil {
78
+ return p, nil
79
+ }
80
+ return "", fmt.Errorf("MAGIC_CLIENT_BIN 指向的文件不存在:%s", p)
81
+ }
82
+ if self, err := os.Executable(); err == nil {
83
+ // npm 的 bin 转发层拉起的是 npm/dist/<plat>/magic-agent,邻居正是 magic-client。
84
+ if cand := filepath.Join(filepath.Dir(self), name); isRegularFile(cand) {
85
+ return cand, nil
86
+ }
87
+ }
88
+ if p, err := exec.LookPath(name); err == nil {
89
+ return p, nil
90
+ }
91
+ // npm 全局前缀兜底:PATH 没带上时(nvm / 自定义 prefix)也能找到。
92
+ if p := npmPrefixClientExe(name); p != "" {
93
+ return p, nil
94
+ }
95
+ return "", fmt.Errorf("找不到 %s:重装 npm 包(npm i -g magic-agent)应自带它,或用 MAGIC_CLIENT_BIN 指定路径", name)
96
+ }
97
+
98
+ // npmPrefixClientExe 在 npm 全局前缀下找 magic-client。
99
+ //
100
+ // 与引擎探测的最后一条同源(见 internal/agent/engine_base.go):读 NPM_CONFIG_PREFIX
101
+ // 而不是 `npm config get prefix` —— 后者会跑一个进程且结果随 PATH 变,慢且不稳。
102
+ func npmPrefixClientExe(name string) string {
103
+ prefix := strings.TrimSpace(os.Getenv("NPM_CONFIG_PREFIX"))
104
+ if prefix == "" {
105
+ return ""
106
+ }
107
+ cand := filepath.Join(prefix, "bin", name)
108
+ if runtime.GOOS == "windows" {
109
+ cand = filepath.Join(prefix, name)
110
+ }
111
+ if isRegularFile(cand) {
112
+ return cand
113
+ }
114
+ return ""
115
+ }
116
+
117
+ func isRegularFile(p string) bool {
118
+ st, err := os.Stat(p)
119
+ return err == nil && !st.IsDir()
120
+ }
121
+
122
+ // execClient 原样转发 stdio 与退出码。
123
+ func execClient(cmd *cobra.Command, exe string, args []string) error {
124
+ c := exec.Command(exe, args...)
125
+ c.Stdin, c.Stdout, c.Stderr = os.Stdin, cmd.OutOrStdout(), cmd.ErrOrStderr()
126
+ if wd, err := os.Getwd(); err == nil {
127
+ c.Dir = wd // 沿用调用方的 cwd:core 不传 -w 时默认工作空间就是它
128
+ }
129
+ env := os.Environ()
130
+ if self, err := os.Executable(); err == nil {
131
+ // 让插件用**这一份** core 校验契约,而不是 PATH 里那份。
132
+ env = append(env, "MAGIC_AGENT_BIN="+self)
133
+ }
134
+ c.Env = env
135
+
136
+ if err := c.Run(); err != nil {
137
+ var ee *exec.ExitError
138
+ if errors.As(err, &ee) {
139
+ // 退出码是契约的一部分(2 = 参数错),原样带出去,别一律 1。
140
+ // 子进程已经把话说完了(它自己的 stderr),这里不要再叠一句。
141
+ return &exitCodeError{code: ee.ExitCode()}
142
+ }
143
+ return fmt.Errorf("启动 %s 失败:%w", filepath.Base(exe), err)
144
+ }
145
+ return nil
146
+ }
147
+
148
+ // exitCodeError 子进程已给出退出码与输出,只差把它带出去。
149
+ //
150
+ // Is(errReportedSentinel) 让 Execute 跳过「再打一遍错误」:转发层的错误文案
151
+ // 只会盖住插件自己的说明(那才是用户要看的那句)。
152
+ type exitCodeError struct{ code int }
153
+
154
+ func (e *exitCodeError) Error() string { return fmt.Sprintf("子进程退出码 %d", e.code) }
155
+
156
+ func (e *exitCodeError) Is(target error) bool { return target == errReportedSentinel }
@@ -35,6 +35,7 @@ type eventSink struct {
35
35
  mu sync.Mutex
36
36
  seq int
37
37
  session string
38
+ run string
38
39
  }
39
40
 
40
41
  func newEventSink(w io.Writer, engine string, enabled bool) *eventSink {
@@ -69,6 +70,25 @@ func (s *eventSink) sessionID() string {
69
70
  return s.session
70
71
  }
71
72
 
73
+ // setRunID 记住本次运行 id(core 自己生成,引擎可能整轮都不给 session_id)。
74
+ // 只在 ready 行带出去:客户端要靠它把「正在跑但还没有 session_id」的轮次
75
+ // 对应回登记表里以 run_id 为键的那条会话(见 /desk/asks 的 run 字段)。
76
+ func (s *eventSink) setRunID(id string) {
77
+ if id == "" {
78
+ return
79
+ }
80
+ s.mu.Lock()
81
+ s.run = id
82
+ s.mu.Unlock()
83
+ }
84
+
85
+ // runID 当前运行的 run_id。
86
+ func (s *eventSink) runID() string {
87
+ s.mu.Lock()
88
+ defer s.mu.Unlock()
89
+ return s.run
90
+ }
91
+
72
92
  func (s *eventSink) writeLine(b []byte) error {
73
93
  s.mu.Lock()
74
94
  defer s.mu.Unlock()
@@ -143,6 +163,9 @@ func (s *eventSink) ready(model string) error {
143
163
  if model != "" {
144
164
  attrs["model"] = model
145
165
  }
166
+ if run := s.runID(); run != "" {
167
+ attrs["run_id"] = run
168
+ }
146
169
  return s.eventAttrs(attrs)
147
170
  }
148
171
 
@@ -161,7 +184,11 @@ func (s *eventSink) notice(kind string, attrs map[string]any) error {
161
184
  }
162
185
 
163
186
  // streamEventPayload 流式事件的 wire 形状 —— `--stream` 与 `--stream --events` 共用。
164
- // 两边的字段集必须只差 v / seq,test 里有专门的防漂断言。
187
+ // 两条边的字段集必须只差 v / seq,test 里有专门的防漂断言。
188
+ //
189
+ // ⚠️ 状态收敛字段(item_id / item_revision / status / snapshot / error / reason)
190
+ // 只在**非空时**出现(全部 omitempty)—— 老引擎(trae 走 delta 但无 item 概念)、
191
+ // 老事件(turn_end 之外的轮次事件)不带这些字段时与历史逐字节一致。
165
192
  func streamEventPayload(ev agent.StreamEvent) any {
166
193
  return struct {
167
194
  Type string `json:"type"`
@@ -171,14 +198,32 @@ func streamEventPayload(ev agent.StreamEvent) any {
171
198
  ToolKind string `json:"tool_kind,omitempty"`
172
199
  SessionID string `json:"session_id,omitempty"`
173
200
  Ask *agent.AskRequest `json:"ask,omitempty"`
201
+ /* 状态收敛四元组(2026-10-02,对齐 agents-anywhere 的 timeline item)。
202
+ 消费方按 (item_id, item_revision) 覆盖式 upsert 即可:
203
+ 同一 item 的多个 partial 帧 + 最后一个 final 帧,
204
+ 乱序到达也能收敛到正确结果(重复投递同 revision 天然幂等)。 */
205
+ ItemID string `json:"item_id,omitempty"`
206
+ ItemRevision uint64 `json:"item_revision,omitempty"`
207
+ Status string `json:"status,omitempty"`
208
+ Snapshot string `json:"snapshot,omitempty"`
209
+ /* 异常收尾(仅 turn_failed):Error 是完整错误链,Reason 是最内层根因,
210
+ 与 -o json 的失败 envelope 同源(都走 agent.ReasonOf)。 */
211
+ Error string `json:"error,omitempty"`
212
+ Reason string `json:"reason,omitempty"`
174
213
  }{
175
- Type: string(ev.Kind),
176
- Text: ev.Text,
177
- Name: ev.Name,
178
- ID: ev.ID,
179
- ToolKind: toolKindOfEvent(ev),
180
- SessionID: ev.SessionID,
181
- Ask: ev.Ask,
214
+ Type: string(ev.Kind),
215
+ Text: ev.Text,
216
+ Name: ev.Name,
217
+ ID: ev.ID,
218
+ ToolKind: toolKindOfEvent(ev),
219
+ SessionID: ev.SessionID,
220
+ Ask: ev.Ask,
221
+ ItemID: ev.ItemID,
222
+ ItemRevision: ev.ItemRevision,
223
+ Status: string(ev.Status),
224
+ Snapshot: ev.Snapshot,
225
+ Error: ev.Error,
226
+ Reason: ev.Reason,
182
227
  }
183
228
  }
184
229
 
@@ -19,6 +19,18 @@ func sinkSamples() []agent.StreamEvent {
19
19
  {Kind: agent.KindToolResult, Text: "输出", ID: "toolu_1"},
20
20
  {Kind: agent.KindTurnEnd, Text: "本轮正文", SessionID: "sess_abc"},
21
21
  {Kind: agent.KindAsk, Text: "要选一个"},
22
+ // 状态收敛样本(2026-10-02):这两个字段为空时全部 omitempty,
23
+ // 所以老形状逐字节不变;非空时按契约出现在 wire 上。
24
+ {
25
+ Kind: agent.KindText, Text: "片", ItemID: "claude_msg_1",
26
+ ItemRevision: 3, Status: agent.StatusRunning, Snapshot: "整段",
27
+ },
28
+ {
29
+ Kind: agent.KindTurnFailed, Text: "", SessionID: "sess_abc",
30
+ Error: "claude CLI stream ended without result line",
31
+ Reason: "without result line",
32
+ Status: agent.StatusFailed, ItemID: "claude_msg_1", ItemRevision: 4,
33
+ },
22
34
  }
23
35
  }
24
36
 
@@ -164,3 +176,221 @@ func TestControlConflictsWithStdinPrompt(t *testing.T) {
164
176
  t.Errorf("报错应解释 stdin 冲突,实际: %v", err)
165
177
  }
166
178
  }
179
+
180
+ // TestStreamEventPayloadConvergenceFields 状态收敛字段必须**只增不改**:
181
+ // 老消费者按老形状解析时,这些字段要么不出现(omitempty),要么是它
182
+ // 完全不认识但无害的扩展字段。契约的「只增字段不升版本」规则靠这条钉住。
183
+ func TestStreamEventPayloadConvergenceFields(t *testing.T) {
184
+ // 1) 空的收敛字段 → 老形状逐字节不变(不带任何新键)。
185
+ var buf bytes.Buffer
186
+ if err := newEventSink(&buf, "claude", false).stream(agent.StreamEvent{
187
+ Kind: agent.KindText, Text: "正文",
188
+ }); err != nil {
189
+ t.Fatal(err)
190
+ }
191
+ m := decodeEventLine(t, buf.String())
192
+ for _, k := range []string{"item_id", "item_revision", "status", "snapshot", "error", "reason"} {
193
+ if _, ok := m[k]; ok {
194
+ t.Errorf("空收敛字段不该出现在 wire 上:%q -> %v", k, m)
195
+ }
196
+ }
197
+
198
+ // 2) 非空 → 按契约名出现,值逐项透传。
199
+ buf.Reset()
200
+ ev := agent.StreamEvent{
201
+ Kind: agent.KindText, Text: "片", ItemID: "claude_msg_1",
202
+ ItemRevision: 3, Status: agent.StatusRunning, Snapshot: "整段",
203
+ }
204
+ if err := newEventSink(&buf, "claude", true).stream(ev); err != nil {
205
+ t.Fatal(err)
206
+ }
207
+ m = decodeEventLine(t, buf.String())
208
+ if m["item_id"] != "claude_msg_1" {
209
+ t.Errorf("item_id = %v", m["item_id"])
210
+ }
211
+ if int(m["item_revision"].(float64)) != 3 {
212
+ t.Errorf("item_revision = %v", m["item_revision"])
213
+ }
214
+ if m["status"] != "running" {
215
+ t.Errorf("status = %v", m["status"])
216
+ }
217
+ if m["snapshot"] != "整段" {
218
+ t.Errorf("snapshot = %v", m["snapshot"])
219
+ }
220
+ // text 仍是增量(不因收敛字段改成整段)。
221
+ if m["text"] != "片" {
222
+ t.Errorf("text 应保持增量语义,实际 %v", m["text"])
223
+ }
224
+ }
225
+
226
+ // TestTurnFailedPayloadFields turn_failed 必须带 error / reason ——
227
+ // 消费方(jq 管道 / 桌面壳)靠它才知道这一轮为什么没结果。
228
+ func TestTurnFailedPayloadFields(t *testing.T) {
229
+ var buf bytes.Buffer
230
+ s := newEventSink(&buf, "claude", true)
231
+ if err := s.stream(agent.StreamEvent{
232
+ Kind: agent.KindTurnFailed, SessionID: "s1",
233
+ Error: "claude CLI stream ended without result line",
234
+ Reason: "without result line", Status: agent.StatusFailed,
235
+ }); err != nil {
236
+ t.Fatal(err)
237
+ }
238
+ m := decodeEventLine(t, buf.String())
239
+ if m["type"] != "turn_failed" {
240
+ t.Errorf("type = %v", m["type"])
241
+ }
242
+ if m["error"] == "" || m["reason"] == "" {
243
+ t.Errorf("turn_failed 必须带 error 与 reason:%v", m)
244
+ }
245
+ if m["status"] != "failed" {
246
+ t.Errorf("status = %v,want failed", m["status"])
247
+ }
248
+ if m["session_id"] != "s1" {
249
+ t.Errorf("session_id = %v", m["session_id"])
250
+ }
251
+ }
252
+
253
+ // ── 黄金样本:钉住 Claude / CodeBuddy 在 wire 上的真实字节形状 ──
254
+ //
255
+ // 为什么不复用上面的 TestStreamEventPayloadConvergenceFields:
256
+ // 那条验的是「字段在不在、值对不对」;这条验的是**引擎实际跑出来的形状**
257
+ // 与 2026-10-02 真实抓包逐字节一致。两者不能互相替代:
258
+ // · 字段级断言在「字段改名」时会跟着改,永远发现不了改名带来的破坏;
259
+ // · 本文件一旦要改(比如某天决定 snapshot 不再随 text 下发),
260
+ // 这里的差异会直接把改动暴露出来,逼人确认影响面。
261
+ //
262
+ // 样本来自真实运行(claude / codebuddy 各自一轮,见 2026-10-01 日志)。
263
+ // 形状的**关键不变量**(改动时最容易被忽略的三条):
264
+ // 1. `text` 是增量,不是 snapshot —— 终端直打靠它逐行拼。
265
+ // 2. `snapshot` 与 `item_revision` 同时出现且 revision 从 1 起单调。
266
+ // 3. thinking 与 text 的 item_id **不同但后缀相同**(同一原生 message id)。
267
+ // 曾经写成同一个 id,导致覆盖式 upsert 静默吞掉思考内容。
268
+
269
+ // TestClaudeWireShapeGolden 真实 Claude 一轮的完整事件序列(黄金样本)。
270
+ func TestClaudeWireShapeGolden(t *testing.T) {
271
+ // 实测:message_start 给出原生 id msg_070d0c14…,思考与正文各自成 lane。
272
+ live := []agent.StreamEvent{
273
+ {
274
+ Kind: agent.KindThinking, Text: "先",
275
+ ItemID: "claude_think_msg_070d0c14", ItemRevision: 1,
276
+ Status: agent.StatusRunning, Snapshot: "先",
277
+ },
278
+ {
279
+ Kind: agent.KindThinking, Text: "想一下",
280
+ ItemID: "claude_think_msg_070d0c14", ItemRevision: 2,
281
+ Status: agent.StatusRunning, Snapshot: "先想一下",
282
+ },
283
+ {
284
+ Kind: agent.KindText, Text: "2",
285
+ ItemID: "claude_msg_msg_070d0c14", ItemRevision: 1,
286
+ Status: agent.StatusRunning, Snapshot: "2",
287
+ },
288
+ {
289
+ Kind: agent.KindTurnEnd, Text: "2", SessionID: "sess_1",
290
+ ItemID: "claude_msg_msg_070d0c14", ItemRevision: 2,
291
+ Status: agent.StatusFinal, Snapshot: "2",
292
+ },
293
+ }
294
+
295
+ var buf bytes.Buffer
296
+ s := newEventSink(&buf, "claude", false)
297
+ for _, ev := range live {
298
+ if err := s.stream(ev); err != nil {
299
+ t.Fatal(err)
300
+ }
301
+ }
302
+ lines := strings.Split(strings.TrimSpace(buf.String()), "\n")
303
+ if len(lines) != len(live) {
304
+ t.Fatalf("行数 = %d,want %d", len(lines), len(live))
305
+ }
306
+
307
+ // 不变量 1:text 恒为增量。
308
+ for i, ev := range live {
309
+ m := decodeEventLine(t, lines[i])
310
+ if m["text"] != ev.Text {
311
+ t.Errorf("[%d] text = %v,want %v(text 必须是增量,不能被 snapshot 顶掉)", i, m["text"], ev.Text)
312
+ }
313
+ }
314
+
315
+ // 不变量 2:同一 item 内 revision 从 1 起单调。
316
+ lastByItem := map[string]float64{}
317
+ for i, ln := range lines {
318
+ m := decodeEventLine(t, ln)
319
+ id, _ := m["item_id"].(string)
320
+ rev, _ := m["item_revision"].(float64)
321
+ if id == "" || rev == 0 {
322
+ t.Errorf("[%d] 缺 item_id / item_revision: %v", i, m)
323
+ continue
324
+ }
325
+ if prev := lastByItem[id]; rev <= prev {
326
+ t.Errorf("[%d] item %q 的 revision 未单调:%v 之后是 %v", i, id, prev, rev)
327
+ }
328
+ lastByItem[id] = rev
329
+ }
330
+
331
+ // 不变量 3:思考与正文两条 item,后缀相同(同一原生 message id)。
332
+ thinkItem := decodeEventLine(t, lines[0])["item_id"].(string)
333
+ textItem := decodeEventLine(t, lines[2])["item_id"].(string)
334
+ if thinkItem == textItem {
335
+ t.Fatalf("思考与正文共用 item id(会静默吞掉思考内容): %q", thinkItem)
336
+ }
337
+ if !strings.HasSuffix(thinkItem, "msg_070d0c14") || !strings.HasSuffix(textItem, "msg_070d0c14") {
338
+ t.Errorf("两条 item 应内嵌同一原生 id:%q / %q", thinkItem, textItem)
339
+ }
340
+ if !strings.Contains(thinkItem, "think_") {
341
+ t.Errorf("思考 item id 应含 think_ 段:%q", thinkItem)
342
+ }
343
+
344
+ // 终帧:final 挂在正文 item 上,且是它的最高 revision。
345
+ end := decodeEventLine(t, lines[3])
346
+ if end["status"] != string(agent.StatusFinal) {
347
+ t.Errorf("turn_end.status = %v,want final", end["status"])
348
+ }
349
+ if end["item_id"] != textItem {
350
+ t.Errorf("turn_end 应挂在正文 item 上:%v vs %v", end["item_id"], textItem)
351
+ }
352
+ }
353
+
354
+ // TestCodeBuddyWireShapeGolden 真实 CodeBuddy 一轮的形状(黄金样本)。
355
+ //
356
+ // 与 Claude 的**关键差异**:codebuddy 是 AggregateOnly(正文只在 assistant
357
+ // 聚合行出现),所以一轮里通常**没有 thinking lane** —— 只有正文一条 item,
358
+ // 且 text 已是整段(不是逐字增量)。
359
+ func TestCodeBuddyWireShapeGolden(t *testing.T) {
360
+ live := []agent.StreamEvent{
361
+ {
362
+ Kind: agent.KindText, Text: "2",
363
+ ItemID: "codebuddy_msg_01a0f598", ItemRevision: 1,
364
+ Status: agent.StatusRunning, Snapshot: "2",
365
+ },
366
+ {
367
+ Kind: agent.KindTurnEnd, Text: "2", SessionID: "sess_cb",
368
+ ItemID: "codebuddy_msg_01a0f598", ItemRevision: 2,
369
+ Status: agent.StatusFinal, Snapshot: "2",
370
+ },
371
+ }
372
+ var buf bytes.Buffer
373
+ s := newEventSink(&buf, "codebuddy", false)
374
+ for _, ev := range live {
375
+ if err := s.stream(ev); err != nil {
376
+ t.Fatal(err)
377
+ }
378
+ }
379
+ lines := strings.Split(strings.TrimSpace(buf.String()), "\n")
380
+ if len(lines) != 2 {
381
+ t.Fatalf("行数 = %d,want 2", len(lines))
382
+ }
383
+ // 不带 thinking lane:整轮只有一条 item。
384
+ first := decodeEventLine(t, lines[0])
385
+ second := decodeEventLine(t, lines[1])
386
+ if first["item_id"] != second["item_id"] {
387
+ t.Errorf("codebuddy 整轮应共用一条 item:%v vs %v", first["item_id"], second["item_id"])
388
+ }
389
+ if first["status"] != string(agent.StatusRunning) || second["status"] != string(agent.StatusFinal) {
390
+ t.Errorf("状态序列 = %v → %v,want running → final", first["status"], second["status"])
391
+ }
392
+ // 前缀正确(不同引擎的 id 空间不互通,撞了会让跨引擎回放串台)。
393
+ if !strings.HasPrefix(first["item_id"].(string), "codebuddy_msg_") {
394
+ t.Errorf("codebuddy item id 前缀不对:%v", first["item_id"])
395
+ }
396
+ }