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
@@ -0,0 +1,905 @@
1
+ package agent
2
+
3
+ // stream_timeline_test.go - 状态收敛事件模型(2026-10-02)。
4
+ //
5
+ // 背景:magic-agent 原先的流式事件是**纯 delta 广播**(只说「又来了这几个字」),
6
+ // 消费方要自己拼、自己判断何时完整。对「终端直打」够用,但撑不起多端同步、
7
+ // 历史回放、断线重连 —— 那些场景里同一条消息被反复投递 / 乱序到达。
8
+ //
9
+ // 本文件把 agents-anywhere 的三个核心机制落地并钉住:
10
+ // 1. **稳定 item id**(引擎原生 message id 派生)—— 拿不到就不猜
11
+ // 2. **单调 revision + 整段 snapshot**(partial 是 running,final 沿用同 id 覆盖)
12
+ // 3. **终帧缺失必补 terminal 事件**(绝不让一轮悬空)
13
+ //
14
+ // 另外两条**不破坏既有行为**的护栏:
15
+ // · Text 仍是增量片段(终端直打 / 老客户端逐行拼接不受影响)
16
+ // · 拿不到原生 id 时降级到「本轮内稳定的序号 id」,不产生第二个 item
17
+
18
+ import (
19
+ "context"
20
+ "runtime"
21
+ "strings"
22
+ "testing"
23
+ )
24
+
25
+ // skipIfNoShCLI 端到端用例依赖 writeFakeCLI 写的 `#!/bin/sh` 脚本 ——
26
+ // Windows 上没有 shebang 解释器,子进程直接起不来("executable file not
27
+ // found in %PATH%")。整个 internal/agent 的 fake-CLI 测试在 Windows 上
28
+ // 都是这个下场(基线红的原因之一),所以这里显式跳过而不是让它红。
29
+ //
30
+ // ⚠️ 判断回归时**不能看失败数**,要跟 HEAD 基线做集合差 —— 跳过的用例在
31
+ // 两边都不产生失败行,看数字会以为"少测了"(见 MEMORY.md 的基线纪律)。
32
+ func skipIfNoShCLI(t *testing.T) {
33
+ t.Helper()
34
+ if runtime.GOOS == "windows" {
35
+ t.Skip("需要 #!/bin/sh 解释器;Windows 上 fake CLI 起不来(与本仓库其余 fake-CLI 测试同一限制)")
36
+ }
37
+ }
38
+
39
+ // claudeRealSample 精简自 2026-10-02 真实抓包(claude stream-json):
40
+ // message_start 带原生 id,该 id 贯穿后续 assistant 聚合行。
41
+ const claudeMsgStart = `{"type":"stream_event","event":{"type":"message_start","message":{"id":"msg_abc123"}}}`
42
+
43
+ // collector 挂一个 OnEvent 收集器,返回累积到的事件切片。
44
+ //
45
+ // 为什么要它(而不是直接用 ask_test.go 里的 feedNDJSON):那个辅助函数
46
+ // 在调用**之后**才把事件返回,而下面几个用例要在 feed 之后**继续**调
47
+ // emitTurnEnd / emitTurnFailed 再看新产生的帧 —— 收集器必须贯穿始终。
48
+ type collector struct{ evs []StreamEvent }
49
+
50
+ func newCollector(acc *streamAccumulator) *collector {
51
+ c := &collector{}
52
+ acc.OnEvent = func(ev StreamEvent) { c.evs = append(c.evs, ev) }
53
+ return c
54
+ }
55
+
56
+ // feedTo 把 NDJSON 行喂进累加器,并把产生的帧记进 c。
57
+ //
58
+ // ⚠️ 顺序很关键:必须**先** feed 再挂 collector —— feedNDJSON 自己会覆盖
59
+ // acc.OnEvent(它自己收集一份),先挂 collector 会被顶掉,
60
+ // 症状是 c.evs 恒为空(测试以「没事件」的形式失败,很容易误判成引擎没发)。
61
+ func feedTo(t *testing.T, acc *streamAccumulator, c *collector, lines []string) {
62
+ t.Helper()
63
+ got := feedNDJSON(t, acc, lines)
64
+ acc.OnEvent = func(ev StreamEvent) { c.evs = append(c.evs, ev) }
65
+ c.evs = append(c.evs, got...)
66
+ }
67
+
68
+ // TestStableItemIDFromNativeMessageID 原生 message id 派生出的 item id
69
+ // 必须贯穿本轮所有文本事件(partial 帧 + final 帧共用一个 id)。
70
+ func TestStableItemIDFromNativeMessageID(t *testing.T) {
71
+ lines := []string{
72
+ claudeMsgStart,
73
+ `{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"你"}}}`,
74
+ `{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"好"}}}`,
75
+ `{"type":"assistant","message":{"id":"msg_abc123","role":"assistant","content":[{"type":"text","text":"你好"}]}}`,
76
+ }
77
+ acc := &streamAccumulator{Engine: "claude"}
78
+ evs := feedNDJSON(t, acc, lines)
79
+
80
+ var ids []string
81
+ for _, ev := range evs {
82
+ if ev.Kind == KindText {
83
+ ids = append(ids, ev.ItemID)
84
+ }
85
+ }
86
+ if len(ids) != 2 {
87
+ t.Fatalf("text 事件数 = %d,want 2", len(ids))
88
+ }
89
+ if ids[0] != "claude_msg_msg_abc123" {
90
+ t.Fatalf("item id = %q,want claude_msg_msg_abc123", ids[0])
91
+ }
92
+ if ids[0] != ids[1] {
93
+ t.Fatalf("同一轮的 item id 必须稳定:%q vs %q", ids[0], ids[1])
94
+ }
95
+ }
96
+
97
+ // TestItemIDFallbackIsStableNoGuessing 拿不到原生 id 时降级到「本轮内稳定的
98
+ // 序号 id」—— 关键是**不能**每帧一个新 id(否则一条消息被拆成 N 个 item,
99
+ // 前端会画出 N 个气泡)。
100
+ func TestItemIDFallbackIsStableNoGuessing(t *testing.T) {
101
+ lines := []string{
102
+ `{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"甲"}}}`,
103
+ `{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"乙"}}}`,
104
+ `{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"丙"}}}`,
105
+ }
106
+ acc := &streamAccumulator{Engine: "trae"}
107
+ evs := feedNDJSON(t, acc, lines)
108
+
109
+ first := ""
110
+ for _, ev := range evs {
111
+ if ev.ItemID == "" {
112
+ t.Fatalf("降级也要给 id(否则消费方无法收敛):%+v", ev)
113
+ }
114
+ if first == "" {
115
+ first = ev.ItemID
116
+ }
117
+ if ev.ItemID != first {
118
+ t.Fatalf("降级 id 不稳定:%q vs %q", first, ev.ItemID)
119
+ }
120
+ }
121
+ if !strings.HasPrefix(first, "trae_msg_") {
122
+ t.Errorf("降级 id 应带引擎前缀:%q", first)
123
+ }
124
+ }
125
+
126
+ // TestRevisionMonotonic revision 在同一 item 内严格递增,且从 1 起。
127
+ // TestRevisionMonotonic revision 必须在**各自 lane 内**单调。
128
+ //
129
+ // 2026-10-02 修正:原先断言「一条消息内所有事件共用一个递增计数器」,
130
+ // 那是在 thinking/text 共享同一个 item 的前提下成立的。拆成两条 item
131
+ // (见 itemtracker.go itemLane)之后,两条 lane 各自从 1 开始,
132
+ // 跨 lane 比较 revision 是没有意义的 —— item_id 不同就不是同一条 item。
133
+ func TestRevisionMonotonic(t *testing.T) {
134
+ lines := []string{
135
+ claudeMsgStart,
136
+ `{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"想"}}}`,
137
+ `{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"甲"}}}`,
138
+ `{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"乙"}}}`,
139
+ }
140
+ acc := &streamAccumulator{Engine: "claude"}
141
+ evs := feedNDJSON(t, acc, lines)
142
+
143
+ // 按 item id 分组,每组内 revision 必须 1,2,3... 严格递增。
144
+ lastByItem := map[string]uint64{}
145
+ n := 0
146
+ for _, ev := range evs {
147
+ if ev.ItemRevision == 0 {
148
+ t.Fatalf("事件缺 revision:%+v", ev)
149
+ }
150
+ if ev.ItemID == "" {
151
+ t.Fatalf("事件缺 item id:%+v", ev)
152
+ }
153
+ if last := lastByItem[ev.ItemID]; ev.ItemRevision <= last {
154
+ t.Fatalf("item %q 的 revision 不单调:%d 之后是 %d", ev.ItemID, last, ev.ItemRevision)
155
+ }
156
+ lastByItem[ev.ItemID] = ev.ItemRevision
157
+ n++
158
+ }
159
+ if n != 3 {
160
+ t.Fatalf("事件数 = %d,want 3", n)
161
+ }
162
+ for item, last := range lastByItem {
163
+ if last != 1 && last != 2 {
164
+ t.Errorf("item %q 的末版 revision = %d,want 1(思考单帧)或 2(正文两帧)", item, last)
165
+ }
166
+ }
167
+ }
168
+
169
+ // TestSnapshotAccumulates snapshot 必须是**累积整段**(状态收敛),
170
+ // 而不是又一个增量片段 —— 这是消费方能只靠 upsert 就拼出全文的前提。
171
+ func TestSnapshotAccumulates(t *testing.T) {
172
+ lines := []string{
173
+ claudeMsgStart,
174
+ `{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"你"}}}`,
175
+ `{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"好"}}}`,
176
+ `{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"呀"}}}`,
177
+ }
178
+ acc := &streamAccumulator{Engine: "claude"}
179
+ evs := feedNDJSON(t, acc, lines)
180
+
181
+ var snaps []string
182
+ var deltas []string
183
+ for _, ev := range evs {
184
+ if ev.Kind != KindText {
185
+ continue
186
+ }
187
+ snaps = append(snaps, ev.Snapshot)
188
+ deltas = append(deltas, ev.Text)
189
+ }
190
+ want := []string{"你", "你好", "你好呀"}
191
+ for i, s := range snaps {
192
+ if s != want[i] {
193
+ t.Fatalf("snapshot[%d] = %q,want %q", i, s, want[i])
194
+ }
195
+ }
196
+ // Text 必须仍是增量(终端直打依赖它)。
197
+ wantDelta := []string{"你", "好", "呀"}
198
+ for i, d := range deltas {
199
+ if d != wantDelta[i] {
200
+ t.Fatalf("text[%d] = %q,want 增量 %q", i, d, wantDelta[i])
201
+ }
202
+ }
203
+ }
204
+
205
+ // TestSnapshotJoinsBlocksByIndex 一条消息里正文可能分散在多个 text 块,
206
+ // snapshot 必须按 content block index 升序拼接,而不是按到达顺序。
207
+ // 挡的是「先 append 到全局 buffer」这种实现:块乱序到达时会拼错。
208
+ func TestSnapshotJoinsBlocksByIndex(t *testing.T) {
209
+ // 故意让 index=1 的块**先**到:按到达顺序拼会得到「B然后A」。
210
+ lines := []string{
211
+ claudeMsgStart,
212
+ `{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"B"}}}`,
213
+ `{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"A"}}}`,
214
+ }
215
+ acc := &streamAccumulator{Engine: "claude"}
216
+ evs := feedNDJSON(t, acc, lines)
217
+
218
+ last := evs[len(evs)-1]
219
+ if !strings.HasSuffix(last.Snapshot, "AB") {
220
+ t.Fatalf("snapshot 应按 index 拼成 AB(index1 先到),实际 %q", last.Snapshot)
221
+ }
222
+ }
223
+
224
+ // TestPartialThenFinalConverge partial(running)之后 final 用**同一个 id**
225
+ // + 更高 revision + final 状态覆盖。消费方只做 upsert 即可,
226
+ // 不需要「先删 partial 再插 final」这种有顺序要求的逻辑。
227
+ func TestPartialThenFinalConverge(t *testing.T) {
228
+ acc := &streamAccumulator{Engine: "claude"}
229
+ c := newCollector(acc)
230
+ feedTo(t, acc, c, []string{
231
+ claudeMsgStart,
232
+ `{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"你好"}}}`,
233
+ })
234
+ acc.emitTurnEnd("你好(引擎权威值)", "s-1")
235
+
236
+ var texts, finals []StreamEvent
237
+ for _, ev := range c.evs {
238
+ if ev.Kind == KindText {
239
+ texts = append(texts, ev)
240
+ }
241
+ if ev.Kind == KindTurnEnd {
242
+ finals = append(finals, ev)
243
+ }
244
+ }
245
+ if len(texts) != 1 || len(finals) != 1 {
246
+ t.Fatalf("events: text=%d turn_end=%d,want 1/1", len(texts), len(finals))
247
+ }
248
+ p, f := texts[0], finals[0]
249
+ if p.ItemID != f.ItemID {
250
+ t.Fatalf("final 必须沿用 partial 的 id:%q vs %q", p.ItemID, f.ItemID)
251
+ }
252
+ if f.ItemRevision <= p.ItemRevision {
253
+ t.Fatalf("final revision 必须更高:partial=%d final=%d", p.ItemRevision, f.ItemRevision)
254
+ }
255
+ if p.Status != StatusRunning {
256
+ t.Errorf("partial 状态 = %q,want running", p.Status)
257
+ }
258
+ if f.Status != StatusFinal {
259
+ t.Errorf("final 状态 = %q,want final", f.Status)
260
+ }
261
+ // final 的 snapshot 用 result 行的权威正文。
262
+ if f.Snapshot != "你好(引擎权威值)" {
263
+ t.Errorf("final snapshot = %q,want result 行正文", f.Snapshot)
264
+ }
265
+ }
266
+
267
+ // TestSecondMessageStartsNewItem 一轮里的**多条** assistant message
268
+ // 必须是**不同的 item**。
269
+ //
270
+ // 为什么:claude 一次工具调用后继续回答,每个 tool_result 之后都是一次新的
271
+ // message_start。共用一个 item 的话后一条覆盖前一条 —— 症状是
272
+ // 「对话里只剩最后一段回复,前面的全没了」,而且没有任何报错。
273
+ //
274
+ // agents-anywhere 同样在 message_start 里清空块桶并把 revision 归零
275
+ // (runtimes/claude/timeline/stream.py:35-36),本测试钉住这个行为。
276
+ func TestSecondMessageStartsNewItem(t *testing.T) {
277
+ acc := &streamAccumulator{Engine: "claude"}
278
+ evs := feedNDJSON(t, acc, []string{
279
+ claudeMsgStart, // msg_abc123
280
+ `{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"第一段"}}}`,
281
+ `{"type":"stream_event","event":{"type":"message_stop"}}`,
282
+ `{"type":"stream_event","event":{"type":"message_start","message":{"id":"msg_xyz789"}}}`,
283
+ `{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"第二段"}}}`,
284
+ })
285
+ if len(evs) != 2 {
286
+ t.Fatalf("事件数 = %d,want 2", len(evs))
287
+ }
288
+ first, second := evs[0], evs[1]
289
+ if first.ItemID == second.ItemID {
290
+ t.Fatalf("两条 message 不该共用 item:%q", first.ItemID)
291
+ }
292
+ // revision 按 message 重置(与 agents-anywhere 归零同义),
293
+ // 所以每条 item 内部才是 1,2,3… 的干净序列。
294
+ if first.ItemRevision != 1 || second.ItemRevision != 1 {
295
+ t.Errorf("各 message 的首个 revision 应为 1:%d / %d", first.ItemRevision, second.ItemRevision)
296
+ }
297
+ // snapshot 不能串:第二条只含自己的正文。
298
+ if second.Snapshot != "第二段" {
299
+ t.Errorf("第二条 snapshot 串了前一条的内容:%q", second.Snapshot)
300
+ }
301
+ if !strings.HasSuffix(first.ItemID, "msg_abc123") || !strings.HasSuffix(second.ItemID, "msg_xyz789") {
302
+ t.Errorf("item id 未对应各自的原生 id:%q / %q", first.ItemID, second.ItemID)
303
+ }
304
+ }
305
+
306
+ // TestFinalDoesNotTouchThinkLane final 只收尾**正文** lane。
307
+ //
308
+ // result 行里是整条消息的正文,**不含思考链**。若也给思考 lane 发 final,
309
+ // 消费方会收到一条「快照从「我在想」回退成空」的 final ——
310
+ // 比不收尾更糟(内容先涨后掉)。思考 lane 的终止由轮级终态信号
311
+ // (turn_end / turn_failed)表达,见 stream.go emitTurnEnd 的注释。
312
+ func TestFinalDoesNotTouchThinkLane(t *testing.T) {
313
+ acc := &streamAccumulator{Engine: "claude"}
314
+ c := newCollector(acc)
315
+ feedTo(t, acc, c, []string{
316
+ claudeMsgStart,
317
+ `{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"我在想"}}}`,
318
+ `{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"答案"}}}`,
319
+ })
320
+ acc.emitTurnEnd("答案", "s-1")
321
+
322
+ thinkItem, textItem := "", ""
323
+ for _, ev := range c.evs {
324
+ switch ev.Kind {
325
+ case KindThinking:
326
+ thinkItem = ev.ItemID
327
+ if ev.Status != StatusRunning {
328
+ t.Errorf("思考事件状态 = %q,want running", ev.Status)
329
+ }
330
+ case KindTurnEnd:
331
+ textItem = ev.ItemID
332
+ }
333
+ }
334
+ if thinkItem == "" || textItem == "" {
335
+ t.Fatalf("没拿到两个 lane 的 item:think=%q text=%q", thinkItem, textItem)
336
+ }
337
+ if thinkItem == textItem {
338
+ t.Fatalf("final 不该落到思考 lane 上:%q", thinkItem)
339
+ }
340
+ // 只有一条 turn_end,且它挂在正文 lane 上。
341
+ var ends int
342
+ for _, ev := range c.evs {
343
+ if ev.Kind == KindTurnEnd {
344
+ ends++
345
+ }
346
+ }
347
+ if ends != 1 {
348
+ t.Errorf("turn_end 条数 = %d,want 1(不为思考 lane 额外发一条)", ends)
349
+ }
350
+ }
351
+
352
+ // TestUpsertIdempotent 同一 (id, revision) 重复投递不产生新版本 ——
353
+ // agents-anywhere 的幂等公式:revision = max(item, existing+1)。
354
+ // 我们在引擎侧就保证 revision 单调,消费方按 id 覆盖即可幂等;
355
+ // 这条测试钉住「重复消费同一条事件得到同样结果」这个可观察性质。
356
+ func TestUpsertIdempotent(t *testing.T) {
357
+ acc := &streamAccumulator{Engine: "claude"}
358
+ evs := feedNDJSON(t, acc, []string{
359
+ claudeMsgStart,
360
+ `{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"你好"}}}`,
361
+ })
362
+
363
+ // 模拟消费方的 upsert 表。
364
+ type ver struct {
365
+ rev uint64
366
+ body string
367
+ }
368
+ table := map[string]ver{}
369
+ apply := func(ev StreamEvent) {
370
+ old, ok := table[ev.ItemID]
371
+ if ok && old.rev >= ev.ItemRevision {
372
+ return // 重复或乱序的旧版本,直接丢弃
373
+ }
374
+ table[ev.ItemID] = ver{rev: ev.ItemRevision, body: ev.Snapshot}
375
+ }
376
+ for _, ev := range evs {
377
+ if ev.Kind == KindText {
378
+ apply(ev)
379
+ apply(ev) // 重放同一帧
380
+ apply(ev)
381
+ }
382
+ }
383
+ if len(table) != 1 {
384
+ t.Fatalf("upsert 表应只有 1 个 item,实际 %d", len(table))
385
+ }
386
+ for _, v := range table {
387
+ if v.body != "你好" {
388
+ t.Fatalf("重复 upsert 后正文 = %q,want 你好", v.body)
389
+ }
390
+ }
391
+ }
392
+
393
+ // TestTurnFailedTerminalFallback 终帧缺失必须补终态事件(对齐
394
+ // agents-anywhere 的 failed_terminal_event)。此前这条路径只有 return error,
395
+ // 于是已经流出去的半截正文在事件流上永远停在 running —— 一轮悬空。
396
+ func TestTurnFailedTerminalFallback(t *testing.T) {
397
+ acc := &streamAccumulator{Engine: "claude"}
398
+ c := newCollector(acc)
399
+ feedTo(t, acc, c, []string{
400
+ claudeMsgStart,
401
+ `{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"半截"}}}`,
402
+ })
403
+ acc.emitTurnFailed("s-1", errNoResultLine("claude"))
404
+
405
+ var fail *StreamEvent
406
+ for i := range c.evs {
407
+ if c.evs[i].Kind == KindTurnFailed {
408
+ fail = &c.evs[i]
409
+ }
410
+ }
411
+ if fail == nil {
412
+ t.Fatal("终帧缺失时必须发 turn_failed")
413
+ }
414
+ if fail.Status != StatusFailed {
415
+ t.Errorf("状态 = %q,want failed", fail.Status)
416
+ }
417
+ if fail.Error == "" || fail.Reason == "" {
418
+ t.Errorf("异常收尾必须带原因:%+v", fail)
419
+ }
420
+ // 已建立 item 时,失败也挂到同一个 item 上:内容还在,只是不再增长。
421
+ if fail.ItemID == "" {
422
+ t.Fatal("异常收尾应挂到已有 item 上")
423
+ }
424
+ if fail.ItemRevision <= 1 {
425
+ t.Errorf("异常收尾的 revision 必须高于既有 partial:%d", fail.ItemRevision)
426
+ }
427
+ if !strings.Contains(fail.Snapshot, "半截") {
428
+ t.Errorf("异常收尾的 snapshot 应保留已流出的内容:%q", fail.Snapshot)
429
+ }
430
+ }
431
+
432
+ // TestTurnFailedWithoutAnyText 引擎连一个字都没吐就断了:仍然必须有终态。
433
+ func TestTurnFailedWithoutAnyText(t *testing.T) {
434
+ acc := &streamAccumulator{Engine: "claude"}
435
+ c := newCollector(acc)
436
+ feedTo(t, acc, c, []string{
437
+ `{"type":"system","subtype":"init","session_id":"s-9"}`,
438
+ })
439
+ acc.emitTurnFailed("s-9", errNoResultLine("claude"))
440
+
441
+ if len(c.evs) != 1 || c.evs[0].Kind != KindTurnFailed {
442
+ t.Fatalf("events = %+v,want 恰好一条 turn_failed", c.evs)
443
+ }
444
+ if c.evs[0].SessionID != "s-9" {
445
+ t.Errorf("异常收尾要带 session_id:%q", c.evs[0].SessionID)
446
+ }
447
+ // 没建立过 item 时不硬造 id(宁缺勿猜:空 id 表示「本事件不参与状态收敛」)。
448
+ if c.evs[0].ItemID != "" {
449
+ t.Errorf("没有内容时不该硬造 item id:%q", c.evs[0].ItemID)
450
+ }
451
+ }
452
+
453
+ // TestAggregateOnlySnapshotReplacesNotAppends AggregateOnly 引擎(codebuddy)
454
+ // 只发聚合行,聚合行给的是**整条消息的全文**。snapshot 必须是整体替换,
455
+ // 不是追加 —— 追加会得到「你好你好」这种重复正文(同一份内容数两遍)。
456
+ func TestAggregateOnlySnapshotReplacesNotAppends(t *testing.T) {
457
+ lines := []string{
458
+ `{"type":"system","subtype":"init","session_id":"cb-1"}`,
459
+ `{"type":"assistant","message":{"id":"cb_msg_1","content":[{"type":"text","text":"第一段"}]}}`,
460
+ `{"type":"assistant","message":{"id":"cb_msg_1","content":[{"type":"text","text":"第二段"}]}}`,
461
+ }
462
+ acc := &streamAccumulator{Engine: "codebuddy", AggregateOnly: true}
463
+ evs := feedNDJSON(t, acc, lines)
464
+
465
+ if len(evs) != 2 {
466
+ t.Fatalf("事件数 = %d,want 2", len(evs))
467
+ }
468
+ for i, ev := range evs {
469
+ if ev.Snapshot == "" {
470
+ t.Fatalf("聚合行也要带 snapshot:%+v", ev)
471
+ }
472
+ if strings.Contains(ev.Snapshot, "第一段第一段") {
473
+ t.Fatalf("第 %d 帧 snapshot 重复累加了:%q", i, ev.Snapshot)
474
+ }
475
+ }
476
+ if evs[0].ItemID != evs[1].ItemID {
477
+ t.Fatalf("同一轮两条聚合行的 item id 应一致:%q vs %q", evs[0].ItemID, evs[1].ItemID)
478
+ }
479
+ if evs[1].ItemRevision <= evs[0].ItemRevision {
480
+ t.Fatalf("revision 必须递增:%d vs %d", evs[0].ItemRevision, evs[1].ItemRevision)
481
+ }
482
+ }
483
+
484
+ // TestThinkingAndTextShareItem 思考与正文属于**同一条消息 item**(共享 id、
485
+ // 各自递增 revision),但 snapshot 分开:thinking 的快照不含正文。
486
+ //
487
+ // 为什么分开:消费方要能分别渲染「思考气泡」和「正文气泡」,
488
+ // 混在一个 snapshot 里就没法分开画。
489
+ // TestThinkingAndTextAreSeparateItems 思考与正文是**两条独立 item**。
490
+ //
491
+ // 2026-10-02 修正:原先二者共享一个 item id,只靠 snapshot 内容区分。
492
+ // 那样在「按 (item_id, revision) 覆盖式 upsert」下会静默丢内容:
493
+ //
494
+ // thinking(snapshot=「我在想」, rev=1)
495
+ // text (snapshot=「答案」, rev=2) ← 覆盖前者,思考彻底消失
496
+ //
497
+ // Text 是增量所以终端直打看不出问题;出问题的是走 snapshot 的消费方
498
+ // (历史回放 / 多端同步)。agents-anywhere 同样把 reasoning 归为独立的
499
+ // system item(runtime_protocol/timeline.py),压根不与 message 共用 item。
500
+ //
501
+ // 断言两条 item id 不同,但**内嵌同一个原生 message id** —— 消费方要
502
+ // 合并展示时按原生 id 关联即可,不需要额外映射表。
503
+ func TestThinkingAndTextAreSeparateItems(t *testing.T) {
504
+ acc := &streamAccumulator{Engine: "claude"}
505
+ evs := feedNDJSON(t, acc, []string{
506
+ claudeMsgStart,
507
+ `{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"我在想"}}}`,
508
+ `{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"答案"}}}`,
509
+ })
510
+ if len(evs) != 2 {
511
+ t.Fatalf("事件数 = %d,want 2", len(evs))
512
+ }
513
+ think, text := evs[0], evs[1]
514
+ if think.Kind != KindThinking || text.Kind != KindText {
515
+ t.Fatalf("kind 顺序不对:%v / %v", think.Kind, text.Kind)
516
+ }
517
+ if think.ItemID == text.ItemID {
518
+ t.Fatalf("思考与正文不该共用 item id:%q(共用会让 upsert 丢掉先到的一半)", think.ItemID)
519
+ }
520
+ // 两条 item 都要能追回同一个原生 message id,否则消费方无法关联。
521
+ for _, ev := range []StreamEvent{think, text} {
522
+ if !strings.HasSuffix(ev.ItemID, "msg_abc123") {
523
+ t.Errorf("item id 未内嵌原生 message id:%q", ev.ItemID)
524
+ }
525
+ }
526
+ // 命名钉死:正文 msg_ / 思考 think_(与 wire 上 type=text / type=thinking 对齐)。
527
+ // 改名会让按 item_id 前缀分类的消费方("只看思考"之类)静默失效。
528
+ if !strings.HasPrefix(text.ItemID, "claude_msg_") {
529
+ t.Errorf("正文 item id 前缀 = %q,want claude_msg_", text.ItemID)
530
+ }
531
+ if !strings.HasPrefix(think.ItemID, "claude_think_") {
532
+ t.Errorf("思考 item id 前缀 = %q,want claude_think_", think.ItemID)
533
+ }
534
+ if strings.Contains(think.Snapshot, "答案") {
535
+ t.Errorf("思考的 snapshot 不该含正文:%q", think.Snapshot)
536
+ }
537
+ if strings.Contains(text.Snapshot, "我在想") {
538
+ t.Errorf("正文的 snapshot 不该含思考:%q", text.Snapshot)
539
+ }
540
+ }
541
+
542
+ // TestToolEventsKeepNoItemID 工具事件**不带** item id —— 它们有自己的
543
+ // tool_use.id 关联语义,塞进消息 item 会让工具卡和消息卡混成一个 item。
544
+ func TestToolEventsKeepNoItemID(t *testing.T) {
545
+ acc := &streamAccumulator{Engine: "claude"}
546
+ evs := feedNDJSON(t, acc, []string{
547
+ claudeMsgStart,
548
+ `{"type":"stream_event","event":{"type":"content_block_start","index":2,"content_block":{"type":"tool_use","name":"Bash","id":"toolu_1"}}}`,
549
+ `{"type":"stream_event","event":{"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"{\"command\":\"ls\"}"}}}`,
550
+ `{"type":"stream_event","event":{"type":"content_block_stop","index":2}}`,
551
+ })
552
+ for _, ev := range evs {
553
+ if ev.Kind == KindToolUse {
554
+ if ev.ItemID != "" {
555
+ t.Errorf("tool_use 不该带 item id:%q", ev.ItemID)
556
+ }
557
+ if ev.ID != "toolu_1" {
558
+ t.Errorf("tool_use.ID = %q,want toolu_01 系列的 toolu_1", ev.ID)
559
+ }
560
+ }
561
+ }
562
+ }
563
+
564
+ // errNoResultLine 复刻各引擎的「流结束但没有 result 行」错误文案。
565
+ func errNoResultLine(engine string) error {
566
+ return &noResultError{engine: engine}
567
+ }
568
+
569
+ type noResultError struct{ engine string }
570
+
571
+ func (e *noResultError) Error() string {
572
+ return e.engine + " CLI stream ended without result line"
573
+ }
574
+
575
+ // ── 端到端:走真实 Stream 路径(fake CLI 回放真实 NDJSON)──
576
+
577
+ // TestClaudeStreamEndToEndConverges 端到端:整条链(Stream → 累加器 → 事件)
578
+ // 必须给出「同 id、单调 revision、running…→ final」的完整收敛序列。
579
+ //
580
+ // 这是本文件最重要的一条:前面几条测的是累加器的单个方法,
581
+ // 挡不住「引擎 Stream 忘了把 item id 传下去」这类接线错误。
582
+ func TestClaudeStreamEndToEndConverges(t *testing.T) {
583
+ skipIfNoShCLI(t)
584
+ cli := writeFakeCLI(t, "claude", `#!/bin/sh
585
+ echo '{"type":"system","subtype":"init","session_id":"s-1","model":"MiniMax-M3"}'
586
+ echo '{"type":"stream_event","event":{"type":"message_start","message":{"id":"msg_e2e"}},"session_id":"s-1"}'
587
+ echo '{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"想一下"}}}'
588
+ echo '{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"你"}},"session_id":"s-1"}'
589
+ echo '{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"好"}},"session_id":"s-1"}'
590
+ echo '{"type":"result","subtype":"success","is_error":false,"result":"你好","session_id":"s-1"}'
591
+ `)
592
+ e := &ClaudeEngine{BinPath: cli}
593
+ events, onEvent := collectEvents()
594
+ if _, err := e.Stream(context.Background(), Request{
595
+ Messages: []Message{{Role: "user", Content: "hi"}},
596
+ }, onEvent); err != nil {
597
+ t.Fatalf("Stream: %v", err)
598
+ }
599
+
600
+ // 每条 item 各自 revision 单调(思考与正文是两条 item,各从 1 起)+ 至少一条 final。
601
+ var ids = map[string]bool{}
602
+ lastRev := map[string]uint64{}
603
+ sawFinal := false
604
+ for _, ev := range *events {
605
+ if ev.ItemID == "" {
606
+ continue
607
+ }
608
+ ids[ev.ItemID] = true
609
+ if ev.ItemRevision <= lastRev[ev.ItemID] {
610
+ t.Fatalf("item %s 的 revision 不单调:%d 之后 %d", ev.ItemID, lastRev[ev.ItemID], ev.ItemRevision)
611
+ }
612
+ lastRev[ev.ItemID] = ev.ItemRevision
613
+ if ev.Status == StatusFinal {
614
+ sawFinal = true
615
+ if ev.Snapshot != "你好" {
616
+ t.Errorf("final snapshot = %q,want result 行正文「你好」", ev.Snapshot)
617
+ }
618
+ }
619
+ }
620
+ // 思考与正文是两条 item(合并会让思考内容在 snapshot 路径静默消失)。
621
+ if len(ids) != 2 {
622
+ t.Fatalf("一轮应有正文 + 思考两条 item,实际 %v", ids)
623
+ }
624
+ if !sawFinal {
625
+ t.Fatal("整条流必须有 final 事件(turn_end 携带)")
626
+ }
627
+ }
628
+
629
+ // TestClaudeStreamMissingResultEmitsTurnFailed 端到端:流被截断时,
630
+ // 已经流出去的正文不会停在 running —— 必须补一条 turn_failed。
631
+ func TestClaudeStreamMissingResultEmitsTurnFailed(t *testing.T) {
632
+ skipIfNoShCLI(t)
633
+ cli := writeFakeCLI(t, "claude", `#!/bin/sh
634
+ echo '{"type":"stream_event","event":{"type":"message_start","message":{"id":"msg_cut"}}}'
635
+ echo '{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"半截"}}}'
636
+ `)
637
+ e := &ClaudeEngine{BinPath: cli}
638
+ events, onEvent := collectEvents()
639
+ if _, err := e.Stream(context.Background(), Request{
640
+ Messages: []Message{{Role: "user", Content: "hi"}},
641
+ }, onEvent); err == nil {
642
+ t.Fatal("缺 result 行必须报错")
643
+ }
644
+
645
+ var fail *StreamEvent
646
+ for i := range *events {
647
+ if (*events)[i].Kind == KindTurnFailed {
648
+ fail = &(*events)[i]
649
+ }
650
+ }
651
+ if fail == nil {
652
+ t.Fatalf("必须补 turn_failed 终态事件,实际事件:%+v", events)
653
+ }
654
+ if fail.Status != StatusFailed || fail.Reason == "" {
655
+ t.Errorf("turn_failed 缺状态或原因:%+v", fail)
656
+ }
657
+ // 半截正文仍在同一条 item 上,且不再增长。
658
+ if !strings.Contains(fail.Snapshot, "半截") {
659
+ t.Errorf("turn_failed 的 snapshot 应保留已流出的内容:%q", fail.Snapshot)
660
+ }
661
+ }
662
+
663
+ // TestCodeBuddyStreamEndToEndConverges codebuddy(AggregateOnly)端到端收敛。
664
+ // 它的正文只来自 assistant 聚合行,snapshot 必须是**替换**语义。
665
+ func TestCodeBuddyStreamEndToEndConverges(t *testing.T) {
666
+ skipIfNoShCLI(t)
667
+ cli := writeFakeCLI(t, "codebuddy", `#!/bin/sh
668
+ echo '{"type":"system","subtype":"init","session_id":"cb-1"}'
669
+ echo '{"type":"assistant","message":{"id":"cb_msg_e2e","content":[{"type":"text","text":"<think>思考</think>最终答案"}]}}'
670
+ echo '{"type":"result","subtype":"success","is_error":false,"result":"<think>思考</think>最终答案","session_id":"cb-1"}'
671
+ `)
672
+ e := &CodeBuddyEngine{BinPath: cli}
673
+ events, onEvent := collectEvents()
674
+ if _, err := e.Stream(context.Background(), Request{
675
+ Messages: []Message{{Role: "user", Content: "hi"}},
676
+ }, onEvent); err != nil {
677
+ t.Fatalf("Stream: %v", err)
678
+ }
679
+
680
+ var itemID string
681
+ sawFinal := false
682
+ for _, ev := range *events {
683
+ if ev.ItemID == "" {
684
+ continue
685
+ }
686
+ if itemID == "" {
687
+ itemID = ev.ItemID
688
+ }
689
+ if ev.ItemID != itemID {
690
+ t.Fatalf("item id 漂移:%q vs %q", itemID, ev.ItemID)
691
+ }
692
+ if strings.Contains(ev.Snapshot, "<think>") {
693
+ t.Fatalf("snapshot 混进了思维链:%q", ev.Snapshot)
694
+ }
695
+ if ev.Status == StatusFinal {
696
+ sawFinal = true
697
+ }
698
+ }
699
+ if itemID == "" {
700
+ t.Fatal("codebuddy 也应给出稳定 item id")
701
+ }
702
+ if !sawFinal {
703
+ t.Fatal("必须有 final 事件")
704
+ }
705
+ }
706
+
707
+ // TestTraeStreamStillDeltaOnly trae 协议没有 message 概念,
708
+ // 但降级 id 仍要**本轮内稳定**(否则一条消息被拆成多段)。
709
+ func TestTraeStreamStillDeltaOnly(t *testing.T) {
710
+ skipIfNoShCLI(t)
711
+ cli := writeFakeCLI(t, "trae-cli", `#!/bin/sh
712
+ echo '{"type":"stream_event","delta":{"role":"assistant","content":"你"}}'
713
+ echo '{"type":"stream_event","delta":{"role":"assistant","content":"好"}}'
714
+ echo '{"type":"result","subtype":"success","is_error":false,"result":"你好"}'
715
+ `)
716
+ e := &TraeEngine{BinPath: cli}
717
+ events, onEvent := collectEvents()
718
+ if _, err := e.Stream(context.Background(), Request{
719
+ Messages: []Message{{Role: "user", Content: "hi"}},
720
+ }, onEvent); err != nil {
721
+ t.Fatalf("Stream: %v", err)
722
+ }
723
+ var id string
724
+ var lastSnap string
725
+ for _, ev := range *events {
726
+ if ev.Kind != KindText {
727
+ continue
728
+ }
729
+ if ev.ItemID == "" {
730
+ t.Fatal("trae 的正文也应有 item id(降级序号)")
731
+ }
732
+ if id == "" {
733
+ id = ev.ItemID
734
+ }
735
+ if ev.ItemID != id {
736
+ t.Fatalf("trae 降级 id 不稳定:%q vs %q", id, ev.ItemID)
737
+ }
738
+ lastSnap = ev.Snapshot
739
+ }
740
+ if lastSnap != "你好" {
741
+ t.Errorf("最终 snapshot = %q,want 你好", lastSnap)
742
+ }
743
+ }
744
+
745
+ // ── arkclaw 专项(A2A message/stream)─────────────────────────────────
746
+ //
747
+ // 与 claude 的关键差异,决定了这里要钉的东西不一样:
748
+ // - **没有 message_start**,所以拿不到「一轮一条新消息」的事件;
749
+ // 一轮就是一个 task,原生 id 直接取 result.id(task id)。
750
+ // - **不能拿 contextId 当 item id**:它是跨轮不变的**会话**锚点,
751
+ // 拿它当 item id 的话同一会话每一轮都是同一个 item,后一轮覆盖前一轮
752
+ // (症状:多轮对话只剩最后一次回复)。
753
+ // - 网关**不是逐字增量**:实测 200 字生成只回 2 帧(working 空帧 +
754
+ // completed 整段)。所以内容累积由 arkclaw 自己做前缀去重,tracker
755
+ // 只管 id/revision/status(分工见 itemtracker.go 文件头)。
756
+
757
+ func TestArkClawItemIDFromTaskID(t *testing.T) {
758
+ body := arkClawSSEFrame(arkClawWorkingFrame("ctx-s1")) +
759
+ arkClawSSEFrame(arkClawTextEnvelope("正文", "ctx-s1"))
760
+ srv, _ := arkClawSSEServer(t, body)
761
+
762
+ events, onEvent := collectEvents()
763
+ if _, err := arkClawServerEngine(srv).Stream(context.Background(), Request{
764
+ Messages: []Message{{Role: "user", Content: "hi"}},
765
+ }, onEvent); err != nil {
766
+ t.Fatalf("Stream: %v", err)
767
+ }
768
+ texts, _ := arkClawSplit(events)
769
+ if len(texts) == 0 {
770
+ t.Fatal("缺少正文事件")
771
+ }
772
+ // result.id = "task-1"(见 arkClawTextEnvelope 的帧构造)。
773
+ if want := "arkclaw_msg_task-1"; texts[0].ItemID != want {
774
+ t.Errorf("item_id = %q want %q(必须派生自 task id)", texts[0].ItemID, want)
775
+ }
776
+ // contextId 是会话锚点,只能进 SessionID,**绝不能**成为 item id。
777
+ for _, ev := range *events {
778
+ if strings.Contains(ev.ItemID, "ctx-s1") {
779
+ t.Errorf("item_id 混入了 contextId: %q", ev.ItemID)
780
+ }
781
+ }
782
+ if (*events)[len(*events)-1].SessionID != "ctx-s1" {
783
+ t.Errorf("turn_end.SessionID = %q want ctx-s1", (*events)[len(*events)-1].SessionID)
784
+ }
785
+ }
786
+
787
+ func TestArkClawRevisionMonotonicAndSnapshotCumulative(t *testing.T) {
788
+ // 三帧增量(网关将来的形态)→ 逐帧校验:text 是增量,snapshot 是累积。
789
+ body := arkClawSSEFrame(arkClawArtifactFrame("working", "c1", "第一段")) +
790
+ arkClawSSEFrame(arkClawArtifactFrame("working", "c1", "第一段第二段")) +
791
+ arkClawSSEFrame(arkClawTextEnvelope("第一段第二段第三段", "c1"))
792
+ srv, _ := arkClawSSEServer(t, body)
793
+
794
+ events, onEvent := collectEvents()
795
+ if _, err := arkClawServerEngine(srv).Stream(context.Background(), Request{
796
+ Messages: []Message{{Role: "user", Content: "hi"}},
797
+ }, onEvent); err != nil {
798
+ t.Fatalf("Stream: %v", err)
799
+ }
800
+ texts, ends := arkClawSplit(events)
801
+ wantDelta := []string{"第一段", "第二段", "第三段"}
802
+ wantSnap := []string{"第一段", "第一段第二段", "第一段第二段第三段"}
803
+ if len(texts) != len(wantDelta) {
804
+ t.Fatalf("text 事件数 = %d,want %d:%+v", len(texts), len(wantDelta), texts)
805
+ }
806
+ for i, ev := range texts {
807
+ if ev.Text != wantDelta[i] {
808
+ t.Errorf("[%d] text = %q,want 增量 %q", i, ev.Text, wantDelta[i])
809
+ }
810
+ if ev.Snapshot != wantSnap[i] {
811
+ t.Errorf("[%d] snapshot = %q,want 累积 %q", i, ev.Snapshot, wantSnap[i])
812
+ }
813
+ if ev.Status != StatusRunning {
814
+ t.Errorf("[%d] status = %q want running(partial 不是终态)", i, ev.Status)
815
+ }
816
+ if ev.ItemRevision != uint64(i+1) {
817
+ t.Errorf("[%d] revision = %d,want %d(严格递增)", i, ev.ItemRevision, i+1)
818
+ }
819
+ }
820
+ assertArkClawFinal(t, texts, ends, "第一段第二段第三段")
821
+ }
822
+
823
+ func TestArkClawMissingTaskIDDegradesToStableLocal(t *testing.T) {
824
+ // 网关不给 result.id 时降级到**本轮内稳定**的序号 id(不是每帧新造)。
825
+ frame := `{"jsonrpc":"2.0","id":"req-1","result":{"kind":"task","contextId":"c1",` +
826
+ `"status":{"state":"completed","message":{"parts":[{"kind":"text","text":"无 task id"}]}}}}`
827
+ srv, _ := arkClawSSEServer(t, arkClawSSEFrame(frame))
828
+
829
+ events, onEvent := collectEvents()
830
+ if _, err := arkClawServerEngine(srv).Stream(context.Background(), Request{
831
+ Messages: []Message{{Role: "user", Content: "hi"}},
832
+ }, onEvent); err != nil {
833
+ t.Fatalf("Stream: %v", err)
834
+ }
835
+ texts, ends := arkClawSplit(events)
836
+ // 降级序号从 1 起(ensure 用 localSeq 递增,不取 revision —— 后者会被
837
+ // Begin 归零,让同一跟踪器里的两条无 id 消息撞成同一个 local_1)。
838
+ if want := "arkclaw_msg_local_1"; texts[0].ItemID != want {
839
+ t.Errorf("降级 item_id = %q want %q", texts[0].ItemID, want)
840
+ }
841
+ assertArkClawFinal(t, texts, ends, "无 task id")
842
+ }
843
+
844
+ func TestArkClawTurnFailedOnTruncatedStream(t *testing.T) {
845
+ // 已发出增量但流被截断(没有 completed 帧)→ 必须补 turn_failed,
846
+ // 否则那条 item 永远停在 running,消费方只能靠超时猜。
847
+ // 对照:TestArkClawStreamMissingCompletedFrame 用 func(StreamEvent){}
848
+ // 收事件,从没建立过 item,所以**不该**凭空造出 failed 帧。
849
+ body := arkClawSSEFrame(arkClawWorkingFrame("c1")) +
850
+ arkClawSSEFrame(arkClawArtifactFrame("working", "c1", "半句正文"))
851
+ srv, _ := arkClawSSEServer(t, body)
852
+
853
+ events, onEvent := collectEvents()
854
+ _, err := arkClawServerEngine(srv).Stream(context.Background(), Request{
855
+ Messages: []Message{{Role: "user", Content: "hi"}},
856
+ }, onEvent)
857
+ if err == nil {
858
+ t.Fatal("截断流应报错")
859
+ }
860
+ texts, failed := arkClawSplitFailed(events)
861
+ if len(texts) != 1 || len(failed) != 1 {
862
+ t.Fatalf("事件形状 = text %d / turn_failed %d,want 1/1:%+v", len(texts), len(failed), *events)
863
+ }
864
+ // failed 挂在**同一个** item 上:内容还在,只是这条 item 不再增长。
865
+ if failed[0].ItemID != texts[0].ItemID {
866
+ t.Errorf("turn_failed 应挂在正文 item 上:%q vs %q", failed[0].ItemID, texts[0].ItemID)
867
+ }
868
+ if failed[0].Status != StatusFailed {
869
+ t.Errorf("status = %q want %q", failed[0].Status, StatusFailed)
870
+ }
871
+ if failed[0].ItemRevision <= texts[0].ItemRevision {
872
+ t.Errorf("turn_failed revision 必须更高:%d vs %d", failed[0].ItemRevision, texts[0].ItemRevision)
873
+ }
874
+ if failed[0].Error == "" || failed[0].Reason == "" {
875
+ t.Errorf("turn_failed 必须带 error / reason:%+v", failed[0])
876
+ }
877
+ // snapshot 保留已发出去的 partial(不回退成空)。
878
+ if failed[0].Snapshot != "半句正文" {
879
+ t.Errorf("snapshot = %q,want 保留 partial「半句正文」", failed[0].Snapshot)
880
+ }
881
+ }
882
+
883
+ func TestArkClawTurnFailedOnTaskFailedState(t *testing.T) {
884
+ // status.state=failed 同样要收成 failed 终态。
885
+ frame := `{"jsonrpc":"2.0","id":"req-1","result":{"kind":"task","id":"t","contextId":"ctx-9",` +
886
+ `"status":{"state":"failed","message":{"parts":[{"kind":"text","text":"上游超时"}]}}}}`
887
+ srv, _ := arkClawSSEServer(t, arkClawSSEFrame(arkClawWorkingFrame("ctx-9"))+
888
+ arkClawSSEFrame(arkClawArtifactFrame("working", "ctx-9", "生成了一部分"))+
889
+ arkClawSSEFrame(frame))
890
+
891
+ events, onEvent := collectEvents()
892
+ _, err := arkClawServerEngine(srv).Stream(context.Background(), Request{
893
+ Messages: []Message{{Role: "user", Content: "hi"}},
894
+ }, onEvent)
895
+ if err == nil {
896
+ t.Fatal("failed 状态应报错")
897
+ }
898
+ _, failed := arkClawSplitFailed(events)
899
+ if len(failed) != 1 {
900
+ t.Fatalf("turn_failed 事件数 = %d want 1:%+v", len(failed), *events)
901
+ }
902
+ if failed[0].Status != StatusFailed || failed[0].Snapshot != "生成了一部分" {
903
+ t.Errorf("turn_failed = %+v", failed[0])
904
+ }
905
+ }