magic-agent 1.0.2 → 1.0.46
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLIENT-CONTRACT.md +254 -8
- package/README.md +48 -5
- package/client-ui/index.html +32 -0
- package/client-ui/pc/app.css +89 -1
- package/client-ui/pc/index.html +18 -8
- package/client-ui/pc/js/00-base.js +8 -30
- package/client-ui/pc/js/05-task-list-render.js +4 -1
- package/client-ui/pc/js/07-chat-stream-render.js +2 -85
- package/client-ui/pc/js/10-markdown.js +3 -171
- package/client-ui/pc/js/18-askbox.js +2 -176
- package/client-ui/pc/js/90-glue.js +1 -1
- package/client-ui/pc/js/91-workbench-stubs.js +4 -10
- package/client-ui/pc/js/93-rail.js +10 -3
- package/client-ui/shared/conv/01-dom.js +52 -0
- package/client-ui/shared/conv/02-media-kinds.js +34 -0
- package/client-ui/shared/conv/03-chat-message.js +1561 -0
- package/client-ui/shared/conv/06-ask-answer.js +377 -0
- package/client-ui/shared/conv/07-stream-delegation.js +158 -0
- package/client-ui/{pc/js → shared/conv}/09-stream-apply.js +121 -6
- package/client-ui/shared/conv/10-markdown-render.js +252 -0
- package/client-ui/shared/conv/18-askbox-markup.js +185 -0
- package/client-ui/{pc/js → shared/conv}/19-attachments.js +365 -21
- package/client-ui/shared/conv/27-conv-ledger.js +381 -0
- package/client-ui/{pc/js → shared/conv}/29-msg-variants.js +112 -3
- package/client-ui/shared/conv/README.md +257 -0
- package/client-ui/shared/conv/conv.css +1638 -0
- package/client-ui/shared/conv/dom-map.md +170 -0
- package/client-ui/shared/conv/manifest.json +44 -0
- package/client-ui/shared/conversation.css +368 -190
- package/client-ui/shared/conversation.js +1722 -788
- package/client-ui/shared/host-glue.js +143 -0
- package/client-ui/shared/transport-http.js +130 -6
- package/client-ui-mobile/index.html +2417 -103
- package/cmd/magic-client/clientctl.go +273 -0
- package/cmd/magic-client/main.go +71 -5
- package/go.mod +6 -3
- package/go.sum +4 -1
- package/internal/agent/arkclaw.go +3 -2
- package/internal/agent/arkclaw_stream.go +126 -8
- package/internal/agent/arkclaw_stream_test.go +97 -15
- package/internal/agent/arkclaw_test.go +3 -3
- package/internal/agent/attachments.go +18 -5
- package/internal/agent/attachments_test.go +7 -1
- package/internal/agent/capability_test.go +11 -1
- package/internal/agent/claude.go +96 -158
- package/internal/agent/codebuddy.go +108 -133
- package/internal/agent/codebuddy_gateway.go +8 -4
- package/internal/agent/codebuddy_gateway_stream.go +82 -2
- package/internal/agent/codebuddy_gateway_test.go +236 -1
- package/internal/agent/codebuddy_stream_test.go +262 -0
- package/internal/agent/codex.go +50 -9
- package/internal/agent/codex_stream_test.go +30 -0
- package/internal/agent/codex_test.go +24 -0
- package/internal/agent/custom_models.go +260 -8
- package/internal/agent/custom_models_test.go +204 -7
- package/internal/agent/dsh.go +464 -130
- package/internal/agent/dsh_resume.go +258 -0
- package/internal/agent/dsh_sdk.go +68 -17
- package/internal/agent/dsh_sdk_test.go +102 -2
- package/internal/agent/dsh_test.go +409 -36
- package/internal/agent/engine.go +26 -6
- package/internal/agent/engine_base.go +46 -2
- package/internal/agent/engine_base_test.go +29 -0
- package/internal/agent/engine_test.go +101 -0
- package/internal/agent/env.go +78 -2
- package/internal/agent/env_test.go +37 -0
- package/internal/agent/itemtracker.go +271 -0
- package/internal/agent/launch.go +192 -0
- package/internal/agent/launch_test.go +206 -0
- package/internal/agent/launch_unix.go +15 -0
- package/internal/agent/launch_windows.go +24 -0
- package/internal/agent/limitnotice.go +103 -0
- package/internal/agent/limitnotice_test.go +211 -0
- package/internal/agent/llmengine.go +2 -2
- package/internal/agent/models.go +3 -2
- package/internal/agent/openclaw.go +4 -3
- package/internal/agent/openclaw_acp.go +7 -0
- package/internal/agent/openclaw_test.go +2 -2
- package/internal/agent/permission.go +100 -11
- package/internal/agent/permission_test.go +100 -36
- package/internal/agent/repair_models_test.go +194 -0
- package/internal/agent/runcmd.go +13 -3
- package/internal/agent/runner.go +7 -1
- package/internal/agent/runner_test.go +1 -1
- package/internal/agent/stream.go +777 -43
- package/internal/agent/stream_test.go +3 -2
- package/internal/agent/stream_timeline_test.go +905 -0
- package/internal/agent/streamjson.go +36 -2
- package/internal/agent/toolkind.go +20 -1
- package/internal/agent/toolkind_test.go +7 -1
- package/internal/agent/tools_suffix_test.go +10 -3
- package/internal/agent/trae.go +5 -3
- package/internal/agent/workspace_test.go +52 -0
- package/internal/cli/ask.go +282 -51
- package/internal/cli/ask_test.go +15 -11
- package/internal/cli/attach_test.go +32 -20
- package/internal/cli/clientcmd.go +156 -0
- package/internal/cli/events.go +53 -8
- package/internal/cli/events_test.go +230 -0
- package/internal/cli/keepalive_test.go +101 -6
- package/internal/cli/login.go +61 -4
- package/internal/cli/login_test.go +99 -0
- package/internal/cli/permission_test.go +5 -1
- package/internal/cli/repair.go +60 -0
- package/internal/cli/repair_test.go +51 -0
- package/internal/cli/root.go +195 -32
- package/internal/cli/session.go +183 -29
- package/internal/cli/session_test.go +144 -5
- package/internal/cli/sessionlog.go +385 -38
- package/internal/cli/sessionlog_test.go +708 -0
- package/internal/cli/sessionlog_user_test.go +142 -1
- package/internal/cli/sessiontitle.go +141 -0
- package/internal/client/browse.go +456 -0
- package/internal/client/browse_test.go +199 -0
- package/internal/client/cors_test.go +35 -0
- package/internal/client/daemon.go +525 -0
- package/internal/client/daemon_test.go +212 -0
- package/internal/client/detach_unix.go +19 -0
- package/internal/client/detach_windows.go +27 -0
- package/internal/client/pair.go +3 -3
- package/internal/client/plugins.go +376 -0
- package/internal/client/plugins_test.go +223 -0
- package/internal/client/proxy.go +374 -0
- package/internal/client/proxy_test.go +212 -0
- package/internal/client/service.go +1464 -48
- package/internal/client/service_test.go +649 -206
- package/internal/client/session_events_test.go +130 -0
- package/internal/client/wsproxy.go +113 -0
- package/internal/client/wsproxy_test.go +102 -0
- package/internal/session/delete.go +151 -0
- package/internal/session/delete_test.go +103 -0
- package/internal/session/session.go +155 -20
- package/internal/session/session_append.go +79 -21
- package/internal/session/session_test.go +124 -10
- package/internal/session/sessionlog.go +572 -6
- package/internal/session/sessionlog_read_test.go +410 -0
- package/internal/session/title.go +486 -0
- package/internal/session/title_test.go +298 -0
- package/npm/bin/magic-agent.js +78 -10
- package/npm/bin/magic-client.js +100 -0
- package/npm/dist/darwin-arm64/magic-agent +0 -0
- package/npm/dist/darwin-arm64/magic-client +0 -0
- package/npm/dist/darwin-x64/magic-agent +0 -0
- package/npm/dist/darwin-x64/magic-client +0 -0
- package/npm/dist/linux-arm64/magic-agent +0 -0
- package/npm/dist/linux-arm64/magic-client +0 -0
- package/npm/dist/linux-x64/magic-agent +0 -0
- package/npm/dist/linux-x64/magic-client +0 -0
- package/npm/dist/win32-x64/magic-agent.exe +0 -0
- package/npm/dist/win32-x64/magic-client.exe +0 -0
- package/npm/install.js +36 -13
- package/npm/lib/platform.js +27 -5
- package/package.json +8 -3
- package/client-ui/pc/js/03-chat-message.js +0 -880
- package/client-ui/pc/js/06-ask-answer.js +0 -204
- /package/client-ui/{pc/js → shared/conv}/08-ask-event-map.js +0 -0
- /package/client-ui/{pc → shared}/vendor/highlight.min.js +0 -0
- /package/client-ui/{pc → shared}/vendor/marked.min.js +0 -0
- /package/client-ui/{pc → shared}/vendor/purify.min.js +0 -0
package/internal/agent/stream.go
CHANGED
|
@@ -41,6 +41,8 @@ import (
|
|
|
41
41
|
"fmt"
|
|
42
42
|
"io"
|
|
43
43
|
"os"
|
|
44
|
+
"os/exec"
|
|
45
|
+
"sort"
|
|
44
46
|
"strings"
|
|
45
47
|
"time"
|
|
46
48
|
)
|
|
@@ -83,6 +85,75 @@ const (
|
|
|
83
85
|
// 要真正作答,得把答案作为**后续 user 消息**补进去(见 EncodeAskFollowUp),
|
|
84
86
|
// 正好复用常驻会话的 --append 通道。
|
|
85
87
|
KindAsk StreamEventKind = "ask"
|
|
88
|
+
// KindTurnFailed 一轮**没有走到终帧**(引擎流被截断、没吐 result 行)。
|
|
89
|
+
//
|
|
90
|
+
// 为什么需要它(2026-10-02,对齐 agents-anywhere 的 failed_terminal_event):
|
|
91
|
+
// 此前这条路径只有 `return fmt.Errorf("... without result line")`,
|
|
92
|
+
// 于是「已经流出去的半截正文」在事件流上永远停在 running —— 消费方
|
|
93
|
+
// (多端同步 / 历史回放)看到的是一条**悬空**的 item:既不知道它完了没有,
|
|
94
|
+
// 也不知道该显示什么。补一条终态事件,一轮就绝不会悬空。
|
|
95
|
+
//
|
|
96
|
+
// 与 KindTurnEnd 的分工:turn_end = 引擎给出了 result 行(成功收尾);
|
|
97
|
+
// turn_failed = 引擎没给(异常收尾,带 Error / Reason)。
|
|
98
|
+
// 两者都终结「这一轮」,消费方可以统一当边界用。
|
|
99
|
+
KindTurnFailed StreamEventKind = "turn_failed"
|
|
100
|
+
// KindImage 助手产出的**完整图片块**(2026-10-10,跨引擎渲染覆盖 P0-1)。
|
|
101
|
+
//
|
|
102
|
+
// Text 字段即图片本体:`data:<media_type>;base64,<data>` 形状的 data URI,
|
|
103
|
+
// 消费方(PC 渲染层 / 历史回放)拿它直接 <img src>。
|
|
104
|
+
//
|
|
105
|
+
// 为什么独立成一种 kind:claude 的图片是 base64 内嵌的
|
|
106
|
+
//(content_block.source{media_type,data}),不进正文流 —— 以前直接丢掉,
|
|
107
|
+
// 用户看到的是「模型说画好了,界面什么都没有」。走事件落 sessionlog 后,
|
|
108
|
+
// 历史回放(按 kind 同名词表回灌渲染层)免费还原。
|
|
109
|
+
//
|
|
110
|
+
// 去重:claude 下同一张图会走两条路各出现一次 —— stream_event 的
|
|
111
|
+
// content_block_start(--include-partial-messages 下)与随后的 assistant
|
|
112
|
+
// 聚合行。emitImage 按指纹去重,只报一次(与 askSeen 同一手法)。
|
|
113
|
+
KindImage StreamEventKind = "image"
|
|
114
|
+
// KindNotice 过程提示(2026-10-10,跨引擎渲染覆盖 P0-4):引擎侧产生的、
|
|
115
|
+
// 不属于正文但值得让用户看见的提示,目前只有一种 —— max_tokens 截断。
|
|
116
|
+
//
|
|
117
|
+
// 为什么要有它:正文在长度上限处被掐断时,用户看到的是「说到一半没了」,
|
|
118
|
+
// 没有任何解释。各引擎的截断信号可得性(有多少算多少,不硬造):
|
|
119
|
+
// - claude / codebuddy:流尾 message_delta.delta.stop_reason == "max_tokens"
|
|
120
|
+
// (handleNDJSONLine 的 message_delta 分支);
|
|
121
|
+
// - openclaw(ACP):收尾时的 stopReason(openclaw_acp.go 检查点);
|
|
122
|
+
// - llm:仅非流式 Complete() 有 finish_reason,流式是纯文本管道,无信号;
|
|
123
|
+
// - codex:无信号。
|
|
124
|
+
//
|
|
125
|
+
// 走事件落 sessionlog 后,历史回放免费还原;前端 09 的 case "notice" 直接渲染,
|
|
126
|
+
// 不需要新分支。emit 不进 Thinking/Text 累积(见 streamAccumulator.emit:
|
|
127
|
+
// 只有 thinking/text 写累积器),单发即走。
|
|
128
|
+
KindNotice StreamEventKind = "notice"
|
|
129
|
+
// KindCommand 斜杠命令节点(2026-10-10,跨引擎渲染覆盖 P0-3):用户输入
|
|
130
|
+
// /xxx 形态的命令时,core 合成的一对生命周期帧 —— 开跑一条(Status="running")、
|
|
131
|
+
// 收尾一条(Status="completed"/"cancelled"/"failed")。引擎都不上报这个生命周期,
|
|
132
|
+
// 事件由 cli/ask.go runStreamAsk 依输入形态自己补,与 user / error 同族
|
|
133
|
+
//(core 自己补的,不走引擎流)。
|
|
134
|
+
//
|
|
135
|
+
// Status 取值对齐前端 qaStatus 渲染词表(running→进行中 / completed→已完成 /
|
|
136
|
+
// cancelled·failed→失败),不复用 StreamStatus 的三值枚举 —— 那个枚举对齐的
|
|
137
|
+
// 是 item 生命周期,这里对齐的是渲染词表。前端 09 的 case "command" 用终端
|
|
138
|
+
// 图标(SquareTerminal)渲染标记行。
|
|
139
|
+
KindCommand StreamEventKind = "command"
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
// StreamStatus 事件所描述的 item 状态 —— 对齐 agents-anywhere 的
|
|
143
|
+
// timeline.item_status(2026-10-02)。
|
|
144
|
+
//
|
|
145
|
+
// 为什么要它:delta 广播只能告诉消费方「又来了几个字」,没法告诉它
|
|
146
|
+
// 「这条消息已经完整了」。多端同步时前者要客户端自己判断何时停止拼接
|
|
147
|
+
// (易错:漏拼、重复拼、乱序),后者只需「覆盖到 final 为止」。
|
|
148
|
+
type StreamStatus string
|
|
149
|
+
|
|
150
|
+
const (
|
|
151
|
+
// StatusRunning 还在增长中(partial)。消费方应持续覆盖同一 item。
|
|
152
|
+
StatusRunning StreamStatus = "running"
|
|
153
|
+
// StatusFinal 终态:这条 item 的内容已经定型,不会再变。
|
|
154
|
+
StatusFinal StreamStatus = "final"
|
|
155
|
+
// StatusFailed 异常终态:引擎没走完就被掐断(见 KindTurnFailed)。
|
|
156
|
+
StatusFailed StreamStatus = "failed"
|
|
86
157
|
)
|
|
87
158
|
|
|
88
159
|
// StreamEvent 一条流式增量。
|
|
@@ -106,6 +177,58 @@ type StreamEvent struct {
|
|
|
106
177
|
// 有了它,调用方不必再分辨这次是 claude 的 tool_use 形态还是 control_request 形态
|
|
107
178
|
//(两种形态在 wire 上字段完全不同,见 ask.go 的 ParseAskLine)。
|
|
108
179
|
Ask *AskRequest
|
|
180
|
+
|
|
181
|
+
/* ── 状态收敛字段(2026-10-02,对齐 agents-anywhere 的 timeline item)──
|
|
182
|
+
*
|
|
183
|
+
* 此前的事件模型是**纯 delta 广播**:只说「又来了这几个字」,
|
|
184
|
+
* 消费方要自己拼、自己判断何时完整。对「终端直打」够用,但撑不起
|
|
185
|
+
* 多端同步 / 历史回放 / 断线重连 —— 那些场景里同一条消息会被
|
|
186
|
+
* 反复投递、乱序到达,前端不得不自己去重、排序、猜终态。
|
|
187
|
+
*
|
|
188
|
+
* agents-anywhere 的做法(claude/timeline/stream.py + core/timeline.py):
|
|
189
|
+
* 事件语义是**状态收敛**而非增量广播:
|
|
190
|
+
* · 每个可更新 item 有**稳定 id**(引擎原生 message id 派生)
|
|
191
|
+
* · 每次更新带**单调递增的 revision**
|
|
192
|
+
* · partial 与 final 用**同一个 id**,final 只是 revision 更高的覆盖
|
|
193
|
+
* · 幂等公式 revision = max(item.revision, existing.revision+1)
|
|
194
|
+
* ⇒ 重复投递不产生新版本
|
|
195
|
+
* 消费方于是只需要「按 (id, revision) 覆盖式 upsert」,天然幂等。
|
|
196
|
+
*
|
|
197
|
+
* ⚠️ **Text 的语义保持不变(仍是增量片段)** —— 这是本设计最重要的一条
|
|
198
|
+
* 取舍。`--stream -o text` 的正文直打、sessionlog 落盘、老客户端的
|
|
199
|
+
* 逐行拼接全都依赖它,改成整段快照会静默打断所有既有消费方。
|
|
200
|
+
* 想做 upsert 的消费方读 Snapshot;想增量拼接的继续读 Text。
|
|
201
|
+
* 两个字段并存,引擎层一次投影同时喂饱两类消费方。 */
|
|
202
|
+
|
|
203
|
+
// ItemID 稳定的 item 标识(timeline item id)。空串 = 本事件不参与状态收敛
|
|
204
|
+
//(老引擎 / 无法归一化身份时)。取值形如 "msg_<native message id>"。
|
|
205
|
+
//
|
|
206
|
+
// 同一轮里的 thinking / text / 工具事件共享同一个 message item id,
|
|
207
|
+
// revision 各自递增 —— 消费方按 (ItemID, Revision) upsert 即得最终全文。
|
|
208
|
+
ItemID string
|
|
209
|
+
|
|
210
|
+
// Revision 该 item 的第几个版本,从 1 起单调递增。
|
|
211
|
+
// 同一个 ItemID 下:partial 是 1..N,final 是 N+1(覆盖,不是追加)。
|
|
212
|
+
ItemRevision uint64
|
|
213
|
+
|
|
214
|
+
// Status 本事件描述的 item 状态(见 StreamStatus)。空串 = 老语义
|
|
215
|
+
//(纯增量,既无终态也无状态机),消费方可按「非空才启用收敛」处理。
|
|
216
|
+
Status StreamStatus
|
|
217
|
+
|
|
218
|
+
// Snapshot 该 item 的**当前完整内容**(累积到此刻为止),与 Text(增量)并存。
|
|
219
|
+
//
|
|
220
|
+
// 为什么已经累积了还要发增量:两类消费方要的东西不同 ——
|
|
221
|
+
// 终端直打要增量(否则重打一遍全文);多端同步要快照(否则要自己拼,
|
|
222
|
+
// 且拼不回去的历史/乱序场景无解)。发了快照,消费方可以完全忽略 Text。
|
|
223
|
+
//
|
|
224
|
+
// 仅在 ItemID 非空时携带;Snapshot 不可靠的引擎留空(消费方回退到 delta 拼接)。
|
|
225
|
+
Snapshot string
|
|
226
|
+
|
|
227
|
+
// Error 本轮异常收尾的原因(仅 KindTurnFailed 携带)。
|
|
228
|
+
Error string
|
|
229
|
+
// Reason 根因摘要(仅 KindTurnFailed 携带),与 -o json 的失败 envelope 同源
|
|
230
|
+
//(同走 agent.ReasonOf),同一次失败在事件流与 stderr 两处的说法必然一致。
|
|
231
|
+
Reason string
|
|
109
232
|
}
|
|
110
233
|
|
|
111
234
|
// ToolCall 一次完整的工具调用记录(用于收尾汇总)。
|
|
@@ -218,9 +341,9 @@ func runStreamCLIEnvIn(ctx context.Context, dir string, extraEnv []string, bin s
|
|
|
218
341
|
// runStreamCLIStdinIn = runStreamCLIEnvIn + 可给子进程喂 stdin 内容(一次性字符串)。
|
|
219
342
|
func runStreamCLIStdinIn(ctx context.Context, dir string, extraEnv []string, bin string, args []string, stdin string, lineHandler func(line string) error) error {
|
|
220
343
|
if stdin == "" {
|
|
221
|
-
return runStreamCLIStdinReaderIn(ctx, dir, extraEnv, bin, args, nil, lineHandler)
|
|
344
|
+
return runStreamCLIStdinReaderIn(ctx, dir, extraEnv, bin, args, nil, nil, lineHandler)
|
|
222
345
|
}
|
|
223
|
-
return runStreamCLIStdinReaderIn(ctx, dir, extraEnv, bin, args, strings.NewReader(stdin), lineHandler)
|
|
346
|
+
return runStreamCLIStdinReaderIn(ctx, dir, extraEnv, bin, args, strings.NewReader(stdin), nil, lineHandler)
|
|
224
347
|
}
|
|
225
348
|
|
|
226
349
|
// runStreamCLIStdinReaderIn = 上面那个 + stdin 可以是**流**(常驻会话靠它持续喂消息)。
|
|
@@ -228,7 +351,7 @@ func runStreamCLIStdinIn(ctx context.Context, dir string, extraEnv []string, bin
|
|
|
228
351
|
// 为什么需要流:claude/codebuddy 的 `--input-format stream-json` 允许在一个进程里
|
|
229
352
|
// 持续接收 user 消息(「会话中追加需求」)。调用方给的 reader 不 EOF,子进程就一直活着;
|
|
230
353
|
// reader EOF(或关闭)→ 子进程收尾退出。
|
|
231
|
-
func runStreamCLIStdinReaderIn(ctx context.Context, dir string, extraEnv []string, bin string, args []string, stdin io.Reader, lineHandler func(line string) error) error {
|
|
354
|
+
func runStreamCLIStdinReaderIn(ctx context.Context, dir string, extraEnv []string, bin string, args []string, stdin io.Reader, stderrOut *streamStderrBuf, lineHandler func(line string) error) error {
|
|
232
355
|
cmd := newStreamCmdIn(dir, bin, args)
|
|
233
356
|
if len(extraEnv) > 0 {
|
|
234
357
|
// Go exec 语义:重复 key 取最后一个 → extraEnv 覆盖继承值
|
|
@@ -254,7 +377,24 @@ func runStreamCLIStdinReaderIn(ctx context.Context, dir string, extraEnv []strin
|
|
|
254
377
|
stdoutW.Close()
|
|
255
378
|
return fmt.Errorf("stderr pipe: %w", err)
|
|
256
379
|
}
|
|
257
|
-
|
|
380
|
+
// stderrOut 非空时由调用方持有同一份缓冲(它要拿 stderr 尾巴拼错误摘要,
|
|
381
|
+
// 见 streamNoResultErr);为空就只在这里兜底收着。
|
|
382
|
+
stderrBuf := stderrOut
|
|
383
|
+
if stderrBuf == nil {
|
|
384
|
+
stderrBuf = &streamStderrBuf{}
|
|
385
|
+
}
|
|
386
|
+
/* 返回前给 stderr 的 drain 一点时间把**已到达**的内容抄进缓冲 —— 调用方
|
|
387
|
+
(或本函数返回后的上层)立刻就要读它拼错误摘要,而 drain 是独立 goroutine,
|
|
388
|
+
进程刚退出时它可能还没被调度。
|
|
389
|
+
⚠️ **有界**等待:后代进程可能还握着 stderr 写端(见下面的管道注释),
|
|
390
|
+
无限等会把「引擎已死」变成「magic-agent 挂着不退」。 */
|
|
391
|
+
stderrDrained := make(chan struct{})
|
|
392
|
+
defer func() {
|
|
393
|
+
select {
|
|
394
|
+
case <-stderrDrained:
|
|
395
|
+
case <-time.After(300 * time.Millisecond):
|
|
396
|
+
}
|
|
397
|
+
}()
|
|
258
398
|
cmd.Stderr = stderrW
|
|
259
399
|
if err := cmd.Start(); err != nil {
|
|
260
400
|
stdoutR.Close()
|
|
@@ -266,7 +406,7 @@ func runStreamCLIStdinReaderIn(ctx context.Context, dir string, extraEnv []strin
|
|
|
266
406
|
notifySpawn(ctx, cmd.Process.Pid) // 会话登记表据此记住引擎子进程 pid
|
|
267
407
|
stdoutW.Close() // 父进程不再持有写端;EOF 只取决于子进程一侧
|
|
268
408
|
stderrW.Close()
|
|
269
|
-
go func() { defer stderrR.Close(); _, _ = io.Copy(stderrBuf, stderrR) }()
|
|
409
|
+
go func() { defer close(stderrDrained); defer stderrR.Close(); _, _ = io.Copy(stderrBuf, stderrR) }()
|
|
270
410
|
|
|
271
411
|
// 看门狗:ctx 结束 → 杀整组;否则等进程自然退出。
|
|
272
412
|
done := make(chan error, 1)
|
|
@@ -338,16 +478,25 @@ func runStreamCLIStdinReaderIn(ctx context.Context, dir string, extraEnv []strin
|
|
|
338
478
|
}
|
|
339
479
|
}
|
|
340
480
|
|
|
341
|
-
//
|
|
481
|
+
// runStreamLinesIn 启动 CLI,把**选定的一路**(stdout 或 stderr)逐行回调,
|
|
482
|
+
// 收尾返回 stdout 全文与 stderr 全文。
|
|
342
483
|
//
|
|
343
|
-
// 为什么需要它(与上面几个 helper 正好相反):其余引擎的增量走 stdout
|
|
344
|
-
// 而 dsh
|
|
345
|
-
//
|
|
484
|
+
// 为什么需要它(与上面几个 helper 正好相反):其余引擎的增量走 stdout 且直接透传;
|
|
485
|
+
// 而 dsh 有两种形态要扫:
|
|
486
|
+
// - `--json`:事件流在 **stdout**(streamStdout = true);
|
|
487
|
+
// - 旧版 headless:推理增量在 **stderr**("dsh: reasoning:" 起头),最终正文才到 stdout
|
|
488
|
+
// (streamStdout = false)。
|
|
346
489
|
//
|
|
347
490
|
// 管道与 Wait 的处理沿用上面同一条经验(自建管道 + 自己 drain,不让 cmd.Wait 等
|
|
348
491
|
// Go 的内部拷贝 goroutine,否则后代进程握着写端时 Wait 永不返回)。
|
|
349
|
-
|
|
492
|
+
//
|
|
493
|
+
// extraEnv 追加到继承环境之后(重复 key 取后者)—— dsh 的权限档位就是这么下发的
|
|
494
|
+
// (它没有逐调用的权限 flag,只有 DSH_PERMISSION_MODE,见 permission.go)。
|
|
495
|
+
func runStreamLinesIn(ctx context.Context, dir string, extraEnv []string, bin string, args []string, streamStdout bool, onLine func(line string) error) (string, string, error) {
|
|
350
496
|
cmd := newStreamCmdIn(dir, bin, args)
|
|
497
|
+
if len(extraEnv) > 0 {
|
|
498
|
+
cmd.Env = append(cmd.Env, extraEnv...)
|
|
499
|
+
}
|
|
351
500
|
|
|
352
501
|
stdoutR, stdoutW, err := os.Pipe()
|
|
353
502
|
if err != nil {
|
|
@@ -375,34 +524,28 @@ func runStreamStderrIn(ctx context.Context, dir string, bin string, args []strin
|
|
|
375
524
|
stdoutW.Close() // 父进程不再持有写端;EOF 只取决于子进程一侧
|
|
376
525
|
stderrW.Close()
|
|
377
526
|
|
|
378
|
-
//
|
|
527
|
+
// 选定的一路逐行扫 + 回调;另一路只做收集(最终正文 / 错误摘要)。
|
|
379
528
|
stdoutDone := make(chan error, 1)
|
|
380
529
|
go func() {
|
|
381
530
|
defer stdoutR.Close()
|
|
382
|
-
|
|
383
|
-
|
|
531
|
+
if !streamStdout {
|
|
532
|
+
_, cerr := io.Copy(&stdoutBuf, stdoutR)
|
|
533
|
+
stdoutDone <- cerr
|
|
534
|
+
return
|
|
535
|
+
}
|
|
536
|
+
stdoutDone <- scanCallbackLines(stdoutR, &stdoutBuf, onLine, cmd)
|
|
384
537
|
}()
|
|
385
538
|
|
|
386
|
-
// stderr
|
|
539
|
+
// stderr:streamStdout 时只收集;否则逐行扫 + 回调。
|
|
387
540
|
stderrDone := make(chan error, 1)
|
|
388
541
|
go func() {
|
|
389
542
|
defer stderrR.Close()
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
stderrBuf.WriteString(line)
|
|
395
|
-
stderrBuf.WriteByte('\n')
|
|
396
|
-
if onStderrLine == nil {
|
|
397
|
-
continue
|
|
398
|
-
}
|
|
399
|
-
if herr := onStderrLine(line); herr != nil {
|
|
400
|
-
killProcessGroup(cmd)
|
|
401
|
-
stderrDone <- herr
|
|
402
|
-
return
|
|
403
|
-
}
|
|
543
|
+
if streamStdout {
|
|
544
|
+
_, cerr := io.Copy(&stderrBuf, stderrR)
|
|
545
|
+
stderrDone <- cerr
|
|
546
|
+
return
|
|
404
547
|
}
|
|
405
|
-
stderrDone <-
|
|
548
|
+
stderrDone <- scanCallbackLines(stderrR, &stderrBuf, onLine, cmd)
|
|
406
549
|
}()
|
|
407
550
|
|
|
408
551
|
waitErr := make(chan error, 1)
|
|
@@ -455,6 +598,26 @@ func waitDrainedBounded(ch <-chan error, d time.Duration) {
|
|
|
455
598
|
}
|
|
456
599
|
}
|
|
457
600
|
|
|
601
|
+
// scanCallbackLines 逐行扫 r(同时抄进 buf),每行回调一次;
|
|
602
|
+
// 回调返回错误即杀掉整个进程组并返回该错误(与 runStreamLinesIn 的收尾约定一致)。
|
|
603
|
+
func scanCallbackLines(r io.Reader, buf *bytes.Buffer, onLine func(line string) error, cmd *exec.Cmd) error {
|
|
604
|
+
sc := bufio.NewScanner(r)
|
|
605
|
+
sc.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
|
|
606
|
+
for sc.Scan() {
|
|
607
|
+
line := sc.Text()
|
|
608
|
+
buf.WriteString(line)
|
|
609
|
+
buf.WriteByte('\n')
|
|
610
|
+
if onLine == nil {
|
|
611
|
+
continue
|
|
612
|
+
}
|
|
613
|
+
if herr := onLine(line); herr != nil {
|
|
614
|
+
killProcessGroup(cmd)
|
|
615
|
+
return herr
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
return sc.Err()
|
|
619
|
+
}
|
|
620
|
+
|
|
458
621
|
// streamAccumulator 聚合增量与收尾 result 行。
|
|
459
622
|
type streamAccumulator struct {
|
|
460
623
|
Text strings.Builder
|
|
@@ -488,6 +651,38 @@ type streamAccumulator struct {
|
|
|
488
651
|
// 留空也能跑:解析本身与引擎无关,只有 header 上限之类的归一化需要它。
|
|
489
652
|
Engine string
|
|
490
653
|
|
|
654
|
+
// AggregateOnly 标记本引擎的正文走 assistant 聚合行兜底通道。
|
|
655
|
+
//
|
|
656
|
+
// 语义演变(两次实测):
|
|
657
|
+
// - 2026-10-01:codebuddy **只**发聚合行、不发 content_block_delta,
|
|
658
|
+
// 不开这个开关 `-o text` 实时输出全程空白。
|
|
659
|
+
// - 2026-10-11:新版 CLI **增量与聚合行并存** —— delta 正常逐字发,
|
|
660
|
+
// 流尾再补一份 assistant 聚合行。所以这里不再是「非此即彼」,
|
|
661
|
+
// emitAggregateText 必须与已发正文做前缀比对只发差量
|
|
662
|
+
// (无条件重发全文 = snapshot 全文×2 = 移动端「消息显示重复」)。
|
|
663
|
+
//
|
|
664
|
+
// 由引擎显式声明,而不是「猜」(猜错就是重复输出,两种错法都很难查)。
|
|
665
|
+
AggregateOnly bool
|
|
666
|
+
|
|
667
|
+
// SanitizeTurnText 该引擎的「轮次正文清洗器」:把 result 行的**原始**
|
|
668
|
+
// result 字段整理成可以直接当正文交出去的样子。
|
|
669
|
+
//
|
|
670
|
+
// 为什么需要它(2026-10-01,codebuddy 实测):result 行里的 result 字段是
|
|
671
|
+
// 未经处理的引擎原始输出 —— codebuddy 把思维链以 <think>…</think>
|
|
672
|
+
// 塞在同一个字符串里。于是同一轮里三个来源的"干净程度"并不一致:
|
|
673
|
+
//
|
|
674
|
+
// acc.Text 已清洗(emit 时过 stripThinkBlock)
|
|
675
|
+
// fin.Result 未清洗(← turn_end 直接拿它发,于是漏了)
|
|
676
|
+
// 最终正文 由调用方 stripThinkBlock(finalizeStreamText(...)) 得到,已清洗
|
|
677
|
+
//
|
|
678
|
+
// 于是 `-o json` 的 NDJSON 里会出现「text 干净、turn_end 脏」的自相矛盾。
|
|
679
|
+
// 症状极隐蔽:内容本身是对的,只是 turn_end 那一条夹着一段英文推理,
|
|
680
|
+
// 消费方(观物台把 turn_end 当轮次边界)就会把推理当成正文收进去。
|
|
681
|
+
//
|
|
682
|
+
// 由引擎声明(claude 留 nil = 原样不动),而不是在 emitTurnEnd 里对所有引擎
|
|
683
|
+
// 无脑套 think 剥离 —— 那会把合法输出里恰好出现的 <think> 字面量也切掉。
|
|
684
|
+
SanitizeTurnText func(string) string
|
|
685
|
+
|
|
491
686
|
// SessionID 流里最近见到的 session_id(system/init、assistant、result 行都带)。
|
|
492
687
|
//
|
|
493
688
|
// 为什么要它:KindAsk 事件必须能**直接接住** —— 宿主看到提问后要把用户的答案
|
|
@@ -509,6 +704,263 @@ type streamAccumulator struct {
|
|
|
509
704
|
// 被识别到 —— 一次是 content_block_start/stop 累积出的 tool_use(含完整 questions),
|
|
510
705
|
// 一次是随后那份聚合 assistant 消息(也带完整 input)。只该报一次。
|
|
511
706
|
askSeen map[string]bool
|
|
707
|
+
|
|
708
|
+
// imgSeen 已发过的图片指纹(imageFingerprint:长度 + 首尾采样)。
|
|
709
|
+
// 与 askSeen 同一个由来:claude 的同一张图走两条路各出现一次
|
|
710
|
+
//(stream_event 的 content_block_start 与 assistant 聚合行),只该报一次。
|
|
711
|
+
imgSeen map[string]bool
|
|
712
|
+
|
|
713
|
+
// maxTokensNoticed 本轮是否已发过 max_tokens 截断提示(2026-10-10,P0-4)。
|
|
714
|
+
// message_delta 有很多条、stop_reason 只有最后一条带值 —— 这里是防御性去重:
|
|
715
|
+
// 万一引擎推了多条 max_tokens 也只报一次(与 askSeen / imgSeen 同一手法)。
|
|
716
|
+
maxTokensNoticed bool
|
|
717
|
+
|
|
718
|
+
/* ── 状态收敛状态机(2026-10-02,见 StreamEvent 的状态收敛字段注释)──
|
|
719
|
+
*
|
|
720
|
+
* agents-anywhere 的 ClaudeStreamAccumulator(runtimes/claude/timeline/stream.py)
|
|
721
|
+
* 维护三样东西,本文件照抄同一套:
|
|
722
|
+
* partial_message_id 稳定 item id(message_start 记原生 id 派生)
|
|
723
|
+
* partial_*_blocks 按 content block index 的累积文本
|
|
724
|
+
* partial_revision 当前 revision 计数
|
|
725
|
+
* 终帧沿用同 id + revision+1 覆盖。 */
|
|
726
|
+
|
|
727
|
+
/* 正文与思考各一条 item(2026-10-02 修正,理由见 itemtracker.go
|
|
728
|
+
itemLane 的注释):共享一个 item id 时消费方覆盖式 upsert 会丢掉
|
|
729
|
+
先到的那一半。两条 item 内嵌同一个原生 message id,合并展示时
|
|
730
|
+
按原生 id 关联即可。 */
|
|
731
|
+
textTrack *itemTracker
|
|
732
|
+
thinkTrack *itemTracker
|
|
733
|
+
|
|
734
|
+
// textBlocks 按 content block index 累积的正文片段。
|
|
735
|
+
textBlocks map[int]string
|
|
736
|
+
// thinkBlocks 按 content block index 累积的思考片段。
|
|
737
|
+
thinkBlocks map[int]string
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// trackers 惰性建立两条 lane 的跟踪器(prefix 依赖 Engine,
|
|
741
|
+
// 累加器是零值构造的,不能在字段声明处建)。
|
|
742
|
+
func (a *streamAccumulator) trackers() trackerPair {
|
|
743
|
+
if a.textTrack == nil {
|
|
744
|
+
a.textTrack = newItemTracker(a.Engine, laneText)
|
|
745
|
+
}
|
|
746
|
+
if a.thinkTrack == nil {
|
|
747
|
+
a.thinkTrack = newItemTracker(a.Engine, laneThink)
|
|
748
|
+
}
|
|
749
|
+
return trackerPair{text: a.textTrack, think: a.thinkTrack}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/* ── 稳定 item id 的建立与降级链 ──────────────────────────────────
|
|
753
|
+
*
|
|
754
|
+
* agents-anywhere 的规则(claude/timeline/messages.py stable_message_item_id):
|
|
755
|
+
* 从 message_start 记下的原生 id 派生 `claude_msg_<hash>`,**跨事件稳定**。
|
|
756
|
+
* 拿不到 message_start.id 时它「丢弃并 warning,不猜」。
|
|
757
|
+
*
|
|
758
|
+
* 我们同理,但降级链多两级(引擎协议不保证每次都有原生 id):
|
|
759
|
+
*
|
|
760
|
+
* 1. 原生 message id(claude/codebuddy 实测必有,贯穿整条消息)
|
|
761
|
+
* 2. 本轮内稳定的**序号**(首个 text/thinking 块出现时定下,
|
|
762
|
+
* 之后本轮不再变)—— 同一轮内稳定,重放时靠 seq 也能推回同一个
|
|
763
|
+
*
|
|
764
|
+
* ⚠️ 降级到 2 时**绝不能**每帧新造一个 id(否则一条消息被拆成 N 个 item),
|
|
765
|
+
* 所以序号在首次建立后写进 tracker.itemID,之后只读不重算。 */
|
|
766
|
+
|
|
767
|
+
// beginMessage 声明一条新 assistant message 开始,清空块桶。
|
|
768
|
+
//
|
|
769
|
+
// 为什么必须切:一轮里可能有**多条** assistant message(工具调用后
|
|
770
|
+
// 继续对话,每次 tool_result 之后都是新的 message_start)。不切的话它们
|
|
771
|
+
// 共用一个 item id,前一条被后一条覆盖 —— 症状是「只看到最后一段回复」。
|
|
772
|
+
//
|
|
773
|
+
// 与 agents-anywhere 同动作(runtimes/claude/timeline/stream.py:35-36 在
|
|
774
|
+
// message_start 里 clear() 块桶并把 revision 归零),这里是封装。
|
|
775
|
+
func (a *streamAccumulator) beginMessage(nativeID string) {
|
|
776
|
+
p := a.trackers()
|
|
777
|
+
p.text.Begin(nativeID)
|
|
778
|
+
p.think.Begin(nativeID)
|
|
779
|
+
a.textBlocks = nil
|
|
780
|
+
a.thinkBlocks = nil
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// observeNativeMessageID 从任意 NDJSON 行里捞出原生 assistant message id。
|
|
784
|
+
//
|
|
785
|
+
// 2026-10-02 实测(claude stream-json 抓包):message_start 事件里
|
|
786
|
+
// event.message.id 与后续 assistant 聚合行 message.id **完全一致**,
|
|
787
|
+
// 且整条消息期间不变 —— 所以两条路任一先到都能定下同一个 id。
|
|
788
|
+
func (a *streamAccumulator) observeNativeMessageID(line string) {
|
|
789
|
+
var probe struct {
|
|
790
|
+
Type string `json:"type"`
|
|
791
|
+
Message struct {
|
|
792
|
+
ID string `json:"id"`
|
|
793
|
+
} `json:"message"`
|
|
794
|
+
Event struct {
|
|
795
|
+
Type string `json:"type"`
|
|
796
|
+
Message struct {
|
|
797
|
+
ID string `json:"id"`
|
|
798
|
+
} `json:"message"`
|
|
799
|
+
} `json:"event"`
|
|
800
|
+
}
|
|
801
|
+
if json.Unmarshal([]byte(line), &probe) != nil {
|
|
802
|
+
return
|
|
803
|
+
}
|
|
804
|
+
// message_start = 一条新 assistant message 开始:切 item、记原生 id。
|
|
805
|
+
// 必须在事件路由**之前**做(message_start 早于所有 content_block_delta),
|
|
806
|
+
// 否则新消息的第一个 delta 会挂到上一条消息的 item 上。
|
|
807
|
+
if probe.Event.Type == "message_start" {
|
|
808
|
+
nativeID := probe.Event.Message.ID
|
|
809
|
+
if nativeID == "" && probe.Type == "assistant" {
|
|
810
|
+
nativeID = probe.Message.ID
|
|
811
|
+
}
|
|
812
|
+
a.beginMessage(nativeID)
|
|
813
|
+
return
|
|
814
|
+
}
|
|
815
|
+
// 聚合 assistant 行也带 message.id(实测与 message_start 的 id 一致),
|
|
816
|
+
// 作为 message_start 缺失时定下 id 的第二条路。
|
|
817
|
+
if probe.Type == "assistant" && probe.Message.ID != "" {
|
|
818
|
+
p := a.trackers()
|
|
819
|
+
p.text.Note(probe.Message.ID)
|
|
820
|
+
p.think.Note(probe.Message.ID)
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// appendBlock 往某个 index 累积文本块。
|
|
825
|
+
func (a *streamAccumulator) appendBlock(store map[int]string, index int, text string) {
|
|
826
|
+
if text == "" || store == nil {
|
|
827
|
+
return
|
|
828
|
+
}
|
|
829
|
+
store[index] += text
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// joinBlocks 按 index 升序拼接所有块(与到达顺序无关)。
|
|
833
|
+
func (a *streamAccumulator) joinBlocks(store map[int]string) string {
|
|
834
|
+
if len(store) == 0 {
|
|
835
|
+
return ""
|
|
836
|
+
}
|
|
837
|
+
idx := make([]int, 0, len(store))
|
|
838
|
+
for i := range store {
|
|
839
|
+
idx = append(idx, i)
|
|
840
|
+
}
|
|
841
|
+
sort.Ints(idx)
|
|
842
|
+
var sb strings.Builder
|
|
843
|
+
for _, i := range idx {
|
|
844
|
+
sb.WriteString(store[i])
|
|
845
|
+
}
|
|
846
|
+
return sb.String()
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// emit 聚合并转发一条增量(基础版本:thinking / text)。
|
|
850
|
+
//
|
|
851
|
+
// 两种模式并存(2026-10-02):
|
|
852
|
+
// - OnEvent == nil:不发事件,只累积到 Text / Thinking(老行为)。
|
|
853
|
+
// - 已建立 item:Text 仍带增量(**不破坏**终端直打与老客户端),
|
|
854
|
+
// 同时带 ItemID / ItemRevision / Status / Snapshot 四元组,
|
|
855
|
+
// 消费方按 (ItemID, Revision) upsert 即得状态收敛。
|
|
856
|
+
func (a *streamAccumulator) emit(kind StreamEventKind, text string) {
|
|
857
|
+
switch kind {
|
|
858
|
+
case KindThinking:
|
|
859
|
+
a.Thinking.WriteString(text)
|
|
860
|
+
case KindText:
|
|
861
|
+
a.Text.WriteString(text)
|
|
862
|
+
}
|
|
863
|
+
if a.OnEvent == nil {
|
|
864
|
+
return
|
|
865
|
+
}
|
|
866
|
+
ev := StreamEvent{Kind: kind, Text: text}
|
|
867
|
+
if kind == KindThinking || kind == KindText {
|
|
868
|
+
a.decorateTextEvent(&ev, kind)
|
|
869
|
+
}
|
|
870
|
+
a.OnEvent(ev)
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// decorateTextEvent 给 thinking/text 事件补上状态收敛四元组。
|
|
874
|
+
//
|
|
875
|
+
// 累积策略:**整段 snapshot**(不是增量片段)—— 与 agents-anywhere 一致。
|
|
876
|
+
// 消费方覆盖式 upsert,天然去重,不需要「先删 partial 再插 final」的顺序逻辑。
|
|
877
|
+
func (a *streamAccumulator) decorateTextEvent(ev *StreamEvent, kind StreamEventKind) {
|
|
878
|
+
/* snapshot 从「按 content block index 升序拼接的块」取,而不是从
|
|
879
|
+
到达顺序累积的 builder 取。区别是实质性的:一条消息里正文可能分散
|
|
880
|
+
在多个 text 块,而块的**到达顺序**不保证等于 index 顺序
|
|
881
|
+
(实测:index=1 的块先到、index=0 的块后到是可能的)。
|
|
882
|
+
按到达顺序拼会给消费方一份顺序错乱的正文,而且错得很隐蔽 ——
|
|
883
|
+
内容都在,只是读起来不对。 */
|
|
884
|
+
if kind == KindThinking {
|
|
885
|
+
a.trackers().think.nextWith(StatusRunning, a.joinBlocks(a.thinkBlocks)).apply(ev)
|
|
886
|
+
return
|
|
887
|
+
}
|
|
888
|
+
a.trackers().text.nextWith(StatusRunning, a.joinBlocks(a.textBlocks)).apply(ev)
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// emitTextDelta 累积一个内容块的增量并发出带状态收敛元数据的事件。
|
|
892
|
+
//
|
|
893
|
+
// index 来自 claude 的 content_block index(-1 = 协议没给,如 trae /
|
|
894
|
+
// codebuddy 聚合行)。按 index 分桶累积而不是一路 append,是因为
|
|
895
|
+
// 一条 assistant 消息里正文可能分散在多个 text 块,而块的**到达顺序**
|
|
896
|
+
// 不保证等于 index 顺序(实测 thinking=0 先到、text=1 后到,但反过来也可能)。
|
|
897
|
+
func (a *streamAccumulator) emitTextDelta(index int, kind StreamEventKind, text string) {
|
|
898
|
+
if text == "" {
|
|
899
|
+
return
|
|
900
|
+
}
|
|
901
|
+
if a.textBlocks == nil {
|
|
902
|
+
a.textBlocks = make(map[int]string)
|
|
903
|
+
}
|
|
904
|
+
if a.thinkBlocks == nil {
|
|
905
|
+
a.thinkBlocks = make(map[int]string)
|
|
906
|
+
}
|
|
907
|
+
store := a.textBlocks
|
|
908
|
+
if kind == KindThinking {
|
|
909
|
+
store = a.thinkBlocks
|
|
910
|
+
}
|
|
911
|
+
a.appendBlock(store, index, text)
|
|
912
|
+
a.emit(kind, text)
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// emitAggregateText 从 assistant 聚合行发正文(AggregateOnly 引擎专用)。
|
|
916
|
+
//
|
|
917
|
+
// ⚠️ 与 emitTextDelta 的**关键区别**:聚合行给的是**整条消息的全文**,
|
|
918
|
+
// 不是增量。所以 snapshot 是「整体替换」而不是「追加」——
|
|
919
|
+
// 追加会得到 "你好你好"(同一份内容被数了两遍),这正是 codebuddy 路径
|
|
920
|
+
// 最容易踩的坑。
|
|
921
|
+
//
|
|
922
|
+
// 2026-10-11 实测(新版 CLI):codebuddy 的增量与聚合行**并存**了——
|
|
923
|
+
// stream_event 的 content_block_delta 正常逐字发,流尾再补一份 assistant
|
|
924
|
+
// 聚合行(2026-10-01「只发聚合行」的前提已失效)。若仍无条件把聚合行
|
|
925
|
+
// 全文当增量 emit,两处各重复一份:
|
|
926
|
+
// - a.Text 多追一份全文(终端直打重复);
|
|
927
|
+
// - textBlocks 里「增量桶 + 0 号聚合桶」并存,joinBlocks 拼出全文×2
|
|
928
|
+
// 进 snapshot —— 消费方按 (item_id, revision) upsert 后整条消息
|
|
929
|
+
// 显示两遍(移动端「消息显示重复」的根因)。
|
|
930
|
+
//
|
|
931
|
+
// 所以先与已累积正文做前缀比对:完全一致静默收敛、多尾巴只补差量。
|
|
932
|
+
// 旧版 CLI(聚合行是正文唯一来源)行为不变:accumulated 为空时原样发。
|
|
933
|
+
func (a *streamAccumulator) emitAggregateText(full string) {
|
|
934
|
+
if full == "" {
|
|
935
|
+
return
|
|
936
|
+
}
|
|
937
|
+
if a.textBlocks == nil {
|
|
938
|
+
a.textBlocks = make(map[int]string)
|
|
939
|
+
}
|
|
940
|
+
accumulated := a.joinBlocks(a.textBlocks)
|
|
941
|
+
var delta string
|
|
942
|
+
switch {
|
|
943
|
+
case accumulated == "":
|
|
944
|
+
// 真 AggregateOnly(旧版 CLI / 首条聚合行):聚合行是正文唯一
|
|
945
|
+
// 来源,原样发 —— 否则 `-o text` 实时输出全程空白。
|
|
946
|
+
delta = full
|
|
947
|
+
case full == accumulated:
|
|
948
|
+
// 增量已发过同一份全文(新版 CLI 的流尾聚合行):静默收敛,
|
|
949
|
+
// 不补发 —— 补发就是「消息显示重复」。
|
|
950
|
+
case strings.HasPrefix(full, accumulated):
|
|
951
|
+
// 聚合行比已发增量多尾巴(增量被截断等):只补差量。
|
|
952
|
+
delta = strings.TrimPrefix(full, accumulated)
|
|
953
|
+
default:
|
|
954
|
+
// 与已发内容对不上:按「下一条独立聚合行」处理(多条聚合行
|
|
955
|
+
// 依次到达的旧形态),整段替换并照发 —— 覆盖语义不回归。
|
|
956
|
+
delta = full
|
|
957
|
+
}
|
|
958
|
+
// 无论哪条路,桶都收敛为单份全文:增量桶(可能散在多个 index)与
|
|
959
|
+
// 0 号聚合桶不能并存,否则 joinBlocks 拼出全文×2。
|
|
960
|
+
a.textBlocks = map[int]string{0: full}
|
|
961
|
+
if delta != "" {
|
|
962
|
+
a.emit(KindText, delta)
|
|
963
|
+
}
|
|
512
964
|
}
|
|
513
965
|
|
|
514
966
|
// toolUseOrphanGuard 安全释放:若 content_block_stop 到来但 openToolName 为空,
|
|
@@ -628,6 +1080,12 @@ func (a *streamAccumulator) handleNDJSONLine(line string) (isResult bool, err er
|
|
|
628
1080
|
a.OnSessionID(probe.SessionID)
|
|
629
1081
|
}
|
|
630
1082
|
}
|
|
1083
|
+
/* 原生 assistant message id(状态收敛的锚点,2026-10-02)。
|
|
1084
|
+
必须**在事件路由之前**观察:message_start 早于所有 content_block_delta,
|
|
1085
|
+
在它之后才建立 item id 的话,前几个 delta 会落到「序号降级」id 上。
|
|
1086
|
+
实测(claude 抓包):message_start.event.message.id 与 assistant 聚合行的
|
|
1087
|
+
message.id 一致,所以两条路都能定下同一个 id。 */
|
|
1088
|
+
a.observeNativeMessageID(line)
|
|
631
1089
|
|
|
632
1090
|
switch probe.Type {
|
|
633
1091
|
case "result":
|
|
@@ -642,12 +1100,22 @@ func (a *streamAccumulator) handleNDJSONLine(line string) (isResult bool, err er
|
|
|
642
1100
|
// trae: delta.content 直出增量
|
|
643
1101
|
var se struct {
|
|
644
1102
|
Event struct {
|
|
645
|
-
Type
|
|
1103
|
+
Type string `json:"type"`
|
|
1104
|
+
Message struct {
|
|
1105
|
+
ID string `json:"id"`
|
|
1106
|
+
} `json:"message"`
|
|
646
1107
|
ContentBlock struct {
|
|
647
1108
|
Type string `json:"type"`
|
|
648
1109
|
Name string `json:"name"`
|
|
649
1110
|
ID string `json:"id"`
|
|
650
1111
|
ToolUseID string `json:"tool_use_id"`
|
|
1112
|
+
// Source 图片块的载体(type=image 时必有):base64 内嵌,
|
|
1113
|
+
// start 一次到位、没有 delta(与 tool_use 的 input_json_delta 不同)。
|
|
1114
|
+
Source struct {
|
|
1115
|
+
Type string `json:"type"`
|
|
1116
|
+
MediaType string `json:"media_type"`
|
|
1117
|
+
Data string `json:"data"`
|
|
1118
|
+
} `json:"source"`
|
|
651
1119
|
} `json:"content_block"`
|
|
652
1120
|
Index int `json:"index"`
|
|
653
1121
|
Delta struct {
|
|
@@ -689,6 +1157,13 @@ func (a *streamAccumulator) handleNDJSONLine(line string) (isResult bool, err er
|
|
|
689
1157
|
a.openResultID = se.Event.ContentBlock.ID
|
|
690
1158
|
}
|
|
691
1159
|
a.expectingResultStart = true
|
|
1160
|
+
case "image":
|
|
1161
|
+
// 图片块(P0-1):content_block_start 一次到位(source 已含完整
|
|
1162
|
+
// base64,不会有 delta)。发 KindImage(text = data URI);
|
|
1163
|
+
// 与随后聚合行里的同一张图靠 emitImage 的指纹去重。
|
|
1164
|
+
if uri := imageDataURI(se.Event.ContentBlock.Source.MediaType, se.Event.ContentBlock.Source.Data); uri != "" {
|
|
1165
|
+
a.emitImage(uri)
|
|
1166
|
+
}
|
|
692
1167
|
}
|
|
693
1168
|
|
|
694
1169
|
case "content_block_delta":
|
|
@@ -714,12 +1189,14 @@ func (a *streamAccumulator) handleNDJSONLine(line string) (isResult bool, err er
|
|
|
714
1189
|
a.flushToolResult(d.Text)
|
|
715
1190
|
case d.Type == "thinking_delta" || d.Thinking != "":
|
|
716
1191
|
if d.Thinking != "" {
|
|
717
|
-
|
|
1192
|
+
// 按 content block index 累积:思考与正文可能分处不同块,
|
|
1193
|
+
// snapshot 必须按 index 拼,不能按到达顺序 append。
|
|
1194
|
+
a.emitTextDelta(se.Event.Index, KindThinking, d.Thinking)
|
|
718
1195
|
}
|
|
719
1196
|
case d.Type == "text_delta" || d.Text != "":
|
|
720
1197
|
if d.Text != "" {
|
|
721
1198
|
if cbType == "" || cbType == "text" {
|
|
722
|
-
a.
|
|
1199
|
+
a.emitTextDelta(se.Event.Index, KindText, d.Text)
|
|
723
1200
|
}
|
|
724
1201
|
}
|
|
725
1202
|
}
|
|
@@ -735,8 +1212,9 @@ func (a *streamAccumulator) handleNDJSONLine(line string) (isResult bool, err er
|
|
|
735
1212
|
}
|
|
736
1213
|
|
|
737
1214
|
// trae delta.content 直出增量(无 thinking / tool 通道)。
|
|
1215
|
+
// index 恒为 -1(协议不给),单桶累积 = 整段拼接,与到达顺序一致。
|
|
738
1216
|
if c := se.Delta2.Content; c != "" && se.Delta2.Role == "assistant" {
|
|
739
|
-
a.
|
|
1217
|
+
a.emitTextDelta(-1, KindText, c)
|
|
740
1218
|
}
|
|
741
1219
|
|
|
742
1220
|
case "assistant":
|
|
@@ -755,6 +1233,23 @@ func (a *streamAccumulator) handleNDJSONLine(line string) (isResult bool, err er
|
|
|
755
1233
|
if req, ok := ParseAskLine(a.Engine, line); ok {
|
|
756
1234
|
a.emitAsk(req)
|
|
757
1235
|
}
|
|
1236
|
+
/* AggregateOnly 引擎(2026-10-01,codebuddy 实测):它**不发**
|
|
1237
|
+
content_block_delta,正文只出现在这份聚合行里。若不在这里 emit,
|
|
1238
|
+
`-o text` 的实时 stdout 会全程空(用户看到「转圈然后什么都没有」),
|
|
1239
|
+
只有 json 模式收尾的 result 行才有正文。
|
|
1240
|
+
claude/trae 走 stream_event 增量,这里再 emit 会**重复输出**,
|
|
1241
|
+
所以由引擎显式声明(见 streamAccumulator.AggregateOnly)。 */
|
|
1242
|
+
if a.AggregateOnly {
|
|
1243
|
+
if txt := assistantLineText(line); txt != "" {
|
|
1244
|
+
// 与收尾路径同一个过滤器:codebuddy 会把推理以 <think>…</think>
|
|
1245
|
+
// 的形式塞进正文,不剥的话实时输出里会混进英文推理。
|
|
1246
|
+
a.emitAggregateText(stripThinkBlock(txt))
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
// 聚合行里的 image 块(P0-1):claude 在 content_block_start 与聚合行各发
|
|
1250
|
+
// 一次(emitImage 的指纹去重兜住),没开 partial 的路径则只在聚合行出现
|
|
1251
|
+
// —— 所以这里**无条件扫**,不能挂在 AggregateOnly 下面。
|
|
1252
|
+
a.emitImagesFromAssistantLine(line)
|
|
758
1253
|
|
|
759
1254
|
case "control_request":
|
|
760
1255
|
// SDK 层请求(宿主自己实现了 canUseTool 回调时才会出现):
|
|
@@ -776,6 +1271,27 @@ func (a *streamAccumulator) handleNDJSONLine(line string) (isResult bool, err er
|
|
|
776
1271
|
// 这里把 tool_result 文本回填到对应的 ToolCall.Result,并 emit 一条
|
|
777
1272
|
// KindToolResult 事件(id 与之关联的 tool_use 相同)。
|
|
778
1273
|
a.handleUserToolResult(line)
|
|
1274
|
+
|
|
1275
|
+
case "message_delta":
|
|
1276
|
+
/* 流式收尾信号(2026-10-10,跨引擎渲染覆盖 P0-4):claude / codebuddy 在
|
|
1277
|
+
--include-partial-messages 下,流尾会推
|
|
1278
|
+
{"type":"message_delta","delta":{"stop_reason":"max_tokens",...}}
|
|
1279
|
+
生成中 stop_reason 为空,只有最后一条带值。"max_tokens" = 本轮输出在
|
|
1280
|
+
长度上限处被掐断 —— 正文戛然而止,用户看到的是「说到一半没了」,却没有任何
|
|
1281
|
+
解释。转成一条 KindNotice(前端 09 的 case "notice" 渲染成时钟标记行),
|
|
1282
|
+
落盘后历史回放同样可见。
|
|
1283
|
+
其他引擎的截断信号:openclaw(ACP) 在收尾检查 stopReason(openclaw_acp.go);
|
|
1284
|
+
llm 仅非流式有 finish_reason;codex 无信号 —— 没信号的引擎就是「截断了
|
|
1285
|
+
也不知道」,这是协议边界,不硬造。 */
|
|
1286
|
+
var md struct {
|
|
1287
|
+
Delta struct {
|
|
1288
|
+
StopReason string `json:"stop_reason"`
|
|
1289
|
+
} `json:"delta"`
|
|
1290
|
+
}
|
|
1291
|
+
if json.Unmarshal([]byte(line), &md) == nil && md.Delta.StopReason == "max_tokens" && !a.maxTokensNoticed {
|
|
1292
|
+
a.maxTokensNoticed = true
|
|
1293
|
+
a.emit(KindNotice, "本轮输出在 max_tokens 上限处被截断")
|
|
1294
|
+
}
|
|
779
1295
|
}
|
|
780
1296
|
return false, nil
|
|
781
1297
|
}
|
|
@@ -859,24 +1375,242 @@ func extractToolResultText(raw json.RawMessage) string {
|
|
|
859
1375
|
return ""
|
|
860
1376
|
}
|
|
861
1377
|
|
|
862
|
-
//
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
1378
|
+
// assistantLineText 从 assistant 聚合行里取出纯文本正文。
|
|
1379
|
+
//
|
|
1380
|
+
// 行形状(claude / codebuddy 同族):
|
|
1381
|
+
//
|
|
1382
|
+
// {"type":"assistant","message":{"content":[{"type":"text","text":"…"}]}}
|
|
1383
|
+
//
|
|
1384
|
+
// content 可能是字符串(少见)或块数组。tool_use / tool_result 块跳过 ——
|
|
1385
|
+
// 它们由 content_block_* 与 user 消息两条路负责,不在这里重复发。
|
|
1386
|
+
// 嵌套的 thinking 块也跳过(codebuddy 会把推理塞在 result 字符串里,
|
|
1387
|
+
// 由 codebuddy.go 的 stripThinkBlock 在收尾时整段剥离)。
|
|
1388
|
+
func assistantLineText(line string) string {
|
|
1389
|
+
var msg struct {
|
|
1390
|
+
Message struct {
|
|
1391
|
+
Content json.RawMessage `json:"content"`
|
|
1392
|
+
} `json:"message"`
|
|
869
1393
|
}
|
|
870
|
-
if
|
|
871
|
-
|
|
1394
|
+
if err := json.Unmarshal([]byte(line), &msg); err != nil {
|
|
1395
|
+
return ""
|
|
1396
|
+
}
|
|
1397
|
+
raw := msg.Message.Content
|
|
1398
|
+
if len(raw) == 0 {
|
|
1399
|
+
return ""
|
|
1400
|
+
}
|
|
1401
|
+
// 形态 1:content 直接是字符串。
|
|
1402
|
+
var s string
|
|
1403
|
+
if err := json.Unmarshal(raw, &s); err == nil {
|
|
1404
|
+
return s
|
|
1405
|
+
}
|
|
1406
|
+
// 形态 2:块数组,只取 type=="text" 的 text 拼接。
|
|
1407
|
+
var blocks []struct {
|
|
1408
|
+
Type string `json:"type"`
|
|
1409
|
+
Text string `json:"text"`
|
|
1410
|
+
}
|
|
1411
|
+
if err := json.Unmarshal(raw, &blocks); err != nil {
|
|
1412
|
+
return ""
|
|
1413
|
+
}
|
|
1414
|
+
var sb strings.Builder
|
|
1415
|
+
for _, b := range blocks {
|
|
1416
|
+
if b.Type == "text" && b.Text != "" {
|
|
1417
|
+
if sb.Len() > 0 {
|
|
1418
|
+
sb.WriteString("\n")
|
|
1419
|
+
}
|
|
1420
|
+
sb.WriteString(b.Text)
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
return sb.String()
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
// imageDataURI 把图片块的 source 字段拼成 data URI。
|
|
1427
|
+
// media_type 缺省按 PNG 兜底;data 为空返回空串(调用方跳过)。
|
|
1428
|
+
func imageDataURI(mediaType, data string) string {
|
|
1429
|
+
if data == "" {
|
|
1430
|
+
return ""
|
|
1431
|
+
}
|
|
1432
|
+
if mediaType == "" {
|
|
1433
|
+
mediaType = "image/png"
|
|
1434
|
+
}
|
|
1435
|
+
return "data:" + mediaType + ";base64," + data
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
// imageFingerprint 图片去重指纹:长度 + 首尾 24 字节采样。
|
|
1439
|
+
// 完整哈希当然更稳,但这里的输入是**同一张图走了两条路** —— 两条路给的
|
|
1440
|
+
// 字节完全一致,这个采样已足够判同,还省去对几百 KB base64 做两次哈希。
|
|
1441
|
+
func imageFingerprint(uri string) string {
|
|
1442
|
+
n := len(uri)
|
|
1443
|
+
head := uri
|
|
1444
|
+
if len(head) > 24 {
|
|
1445
|
+
head = head[:24]
|
|
1446
|
+
}
|
|
1447
|
+
tail := ""
|
|
1448
|
+
if n > 48 {
|
|
1449
|
+
tail = uri[n-24:]
|
|
1450
|
+
}
|
|
1451
|
+
return fmt.Sprintf("%d|%s|%s", n, head, tail)
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
// emitImage 发一张助手图片(KindImage,text = data URI),按指纹去重。
|
|
1455
|
+
//
|
|
1456
|
+
// 为什么必须去重:claude 的同一张图会**走两条路**各出现一次 ——
|
|
1457
|
+
// stream_event 的 content_block_start 与随后的 assistant 聚合行
|
|
1458
|
+
// (与 askSeen 的双路去重同一个由来)。不去重的话 PC 端同一张图画两张。
|
|
1459
|
+
func (a *streamAccumulator) emitImage(uri string) {
|
|
1460
|
+
if uri == "" {
|
|
1461
|
+
return
|
|
1462
|
+
}
|
|
1463
|
+
key := imageFingerprint(uri)
|
|
1464
|
+
if a.imgSeen == nil {
|
|
1465
|
+
a.imgSeen = make(map[string]bool)
|
|
1466
|
+
}
|
|
1467
|
+
if a.imgSeen[key] {
|
|
1468
|
+
return
|
|
1469
|
+
}
|
|
1470
|
+
a.imgSeen[key] = true
|
|
1471
|
+
a.emit(KindImage, uri)
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
// emitImagesFromAssistantLine 从 assistant 聚合行里抽出 image 块并 emit。
|
|
1475
|
+
//
|
|
1476
|
+
// 块形状(claude / codebuddy 同族):
|
|
1477
|
+
//
|
|
1478
|
+
// {"type":"assistant","message":{"content":[
|
|
1479
|
+
// {"type":"image","source":{"type":"base64","media_type":"image/png","data":"…"}}]}}
|
|
1480
|
+
//
|
|
1481
|
+
// claude 开了 --include-partial-messages 时这张图已在 content_block_start
|
|
1482
|
+
// 发过一次(emitImage 去重兜住);没开(带附件的非流式路径)时这里是唯一入口,
|
|
1483
|
+
// 所以这条路径必须无条件走,不能挂在 AggregateOnly 下面。
|
|
1484
|
+
func (a *streamAccumulator) emitImagesFromAssistantLine(line string) {
|
|
1485
|
+
var msg struct {
|
|
1486
|
+
Type string `json:"type"`
|
|
1487
|
+
Message struct {
|
|
1488
|
+
Content []struct {
|
|
1489
|
+
Type string `json:"type"`
|
|
1490
|
+
Source struct {
|
|
1491
|
+
Type string `json:"type"`
|
|
1492
|
+
MediaType string `json:"media_type"`
|
|
1493
|
+
Data string `json:"data"`
|
|
1494
|
+
} `json:"source"`
|
|
1495
|
+
} `json:"content"`
|
|
1496
|
+
} `json:"message"`
|
|
1497
|
+
}
|
|
1498
|
+
if jerr := json.Unmarshal([]byte(line), &msg); jerr != nil || msg.Type != "assistant" {
|
|
1499
|
+
return
|
|
1500
|
+
}
|
|
1501
|
+
for _, b := range msg.Message.Content {
|
|
1502
|
+
if b.Type != "image" {
|
|
1503
|
+
continue
|
|
1504
|
+
}
|
|
1505
|
+
if uri := imageDataURI(b.Source.MediaType, b.Source.Data); uri != "" {
|
|
1506
|
+
a.emitImage(uri)
|
|
1507
|
+
}
|
|
872
1508
|
}
|
|
873
1509
|
}
|
|
874
1510
|
|
|
875
1511
|
// emitTurnEnd 发一轮结束事件:text = 该轮正文(引擎 result 行的 result),
|
|
876
1512
|
// sessionID = 该轮的会话 id(见 StreamEvent.SessionID 的说明)。
|
|
1513
|
+
//
|
|
1514
|
+
// text 会先过一遍 SanitizeTurnText(由引擎声明的清洗器)——
|
|
1515
|
+
// 不然这里发出去的是引擎**原始** result,与同一轮的 text / result 事件不一致
|
|
1516
|
+
// (codebuddy 的 <think> 泄漏正是从这条路出去的,见 SanitizeTurnText 的注释)。
|
|
877
1517
|
func (a *streamAccumulator) emitTurnEnd(text, sessionID string) {
|
|
878
1518
|
if a.OnEvent == nil {
|
|
879
1519
|
return
|
|
880
1520
|
}
|
|
881
|
-
a.
|
|
1521
|
+
if a.SanitizeTurnText != nil {
|
|
1522
|
+
text = a.SanitizeTurnText(text)
|
|
1523
|
+
}
|
|
1524
|
+
ev := StreamEvent{Kind: KindTurnEnd, Text: text, SessionID: sessionID}
|
|
1525
|
+
/* 终帧 = 状态收敛的**最后一次 upsert**:沿用与 partial 同一个 ItemID、
|
|
1526
|
+
revision 再 +1、Status 标 final。消费方按 (ItemID, Revision) 覆盖即可,
|
|
1527
|
+
不需要「先删 partial 再插 final」这种有顺序要求的逻辑(agents-anywhere
|
|
1528
|
+
的做法,见 claude/timeline/stream.py next_final_revision)。
|
|
1529
|
+
Snapshot 用 result 行的正文而非累积分片 —— 引擎给出的 result 是权威值,
|
|
1530
|
+
与增量拼接有出入时以它为准(claude 的 result 行 = 完整正文;codebuddy 的
|
|
1531
|
+
result 行还要过 stripThinkBlock,上面已过)。
|
|
1532
|
+
|
|
1533
|
+
⚠️ 只收尾**正文** lane:result 行是整条消息的正文,不含思考链。
|
|
1534
|
+
思考 lane 已经在最后一个 thinking_delta 上收到过内容,此后不会再增长;
|
|
1535
|
+
若也给它发 final,消费方会看到一条「内容中途消失」的 final(快照回退成空),
|
|
1536
|
+
反而制造出比不收尾更糟的状态。想标记思考已结束,靠消费方见到 result
|
|
1537
|
+
事件后停止等待即可 —— 它拿得到 turn 级的终态信号。 */
|
|
1538
|
+
if attrs, ok := a.trackers().finalize(text); ok {
|
|
1539
|
+
attrs.apply(&ev)
|
|
1540
|
+
}
|
|
1541
|
+
a.OnEvent(ev)
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
// emitTurnFailed 补一条「本轮异常收尾」事件(引擎没给 result 行)。
|
|
1545
|
+
//
|
|
1546
|
+
// 对齐 agents-anywhere 的 failed_terminal_event(claude/turns/lifecycle.py):
|
|
1547
|
+
// 流被截断也**必须**发终态事件,绝不让一轮悬空。
|
|
1548
|
+
//
|
|
1549
|
+
// 为什么这条不能省(2026-10-02):此前这条路径只有 return error,
|
|
1550
|
+
// 于是「已经流出去、正打到一半」的正文在事件流上永远停在 running ——
|
|
1551
|
+
// 多端同步的客户端看到的是一个既不增长也不结束的 item,
|
|
1552
|
+
// 只能靠超时猜;历史回放更糟:那一条永远显示「生成中」。
|
|
1553
|
+
//
|
|
1554
|
+
// 语义与 KindTurnEnd 的分工:
|
|
1555
|
+
//
|
|
1556
|
+
// turn_end = 引擎给出了 result 行(成功收尾)
|
|
1557
|
+
// turn_failed = 引擎没给(异常收尾),带 Error / Reason
|
|
1558
|
+
func (a *streamAccumulator) emitTurnFailed(sessionID string, err error) {
|
|
1559
|
+
if a.OnEvent == nil || err == nil {
|
|
1560
|
+
return
|
|
1561
|
+
}
|
|
1562
|
+
ev := StreamEvent{
|
|
1563
|
+
Kind: KindTurnFailed,
|
|
1564
|
+
SessionID: sessionID,
|
|
1565
|
+
Error: err.Error(),
|
|
1566
|
+
Reason: ReasonOf(err),
|
|
1567
|
+
Status: StatusFailed,
|
|
1568
|
+
}
|
|
1569
|
+
// 已建立 item 时把失败也挂到同一个 item 上:partial 的内容仍然存在,
|
|
1570
|
+
// 只是这条 item 不会再更新了(Status=failed 明确终止增长)。
|
|
1571
|
+
if attrs, ok := a.trackers().failText(a.joinBlocks(a.textBlocks)); ok {
|
|
1572
|
+
attrs.apply(&ev)
|
|
1573
|
+
}
|
|
1574
|
+
a.OnEvent(ev)
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
// emitLimitNoticeFailed 一轮的正文其实是供应商的「限流 / 额度通告」时的终态(见 limitnotice.go)。
|
|
1578
|
+
//
|
|
1579
|
+
// 与 emitTurnFailed 的分工:那条是「引擎**没给** result 行」(协议没跑完);这条是
|
|
1580
|
+
// 「**给了** result 行,但正文是一条通告」(引擎拿 200 + 一段正文回来的,本质仍是失败)。
|
|
1581
|
+
// 两者都发 KindTurnFailed —— 消费方只需认一种失败终态,不必知道上游是从哪一步看出来的。
|
|
1582
|
+
// 差别在三个字段:
|
|
1583
|
+
//
|
|
1584
|
+
// · Error / Reason = 通告原文(它就是用户此刻唯一有用的信息:谁限流了、什么时候恢复);
|
|
1585
|
+
// · Text = **剥掉通告之后的正文** —— 消费方拿它覆盖气泡,流式时已经拼进去的
|
|
1586
|
+
// 那半句通告也随之被擦掉(这正是用户报障要的效果)。
|
|
1587
|
+
//
|
|
1588
|
+
// ⚠️ 必须在 emitTurnEnd **之前**判(调用方在 streamjson.go 的 result 分支里就是这么做的):
|
|
1589
|
+
// turn_end 的语义是「这一轮成功收尾」,先发出去就成了假成功。
|
|
1590
|
+
func (a *streamAccumulator) emitLimitNoticeFailed(sessionID, notice, body string) {
|
|
1591
|
+
if a.OnEvent == nil {
|
|
1592
|
+
return
|
|
1593
|
+
}
|
|
1594
|
+
ev := StreamEvent{
|
|
1595
|
+
Kind: KindTurnFailed,
|
|
1596
|
+
Text: body,
|
|
1597
|
+
SessionID: sessionID,
|
|
1598
|
+
Error: notice,
|
|
1599
|
+
Reason: notice,
|
|
1600
|
+
Status: StatusFailed,
|
|
1601
|
+
}
|
|
1602
|
+
/* 快照 = 剥掉通告的正文;正文为空(这一轮引擎只回了通告)时退回**累积正文** ——
|
|
1603
|
+
引擎把通告混在最后一条 assistant 消息里时,那里面也夹着它,再判一次剥掉。
|
|
1604
|
+
(累积正文里判不出来就原样留着:宁可多留一句话,也不把用户真看过的正文抹成空。) */
|
|
1605
|
+
snap := strings.TrimSpace(body)
|
|
1606
|
+
if snap == "" {
|
|
1607
|
+
snap = a.joinBlocks(a.textBlocks)
|
|
1608
|
+
if _, b, hit := splitLimitNotice(snap); hit {
|
|
1609
|
+
snap = b
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
if attrs, ok2 := a.trackers().failText(snap); ok2 {
|
|
1613
|
+
attrs.apply(&ev)
|
|
1614
|
+
}
|
|
1615
|
+
a.OnEvent(ev)
|
|
882
1616
|
}
|