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.
- 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 +109 -3
- package/internal/agent/custom_models_test.go +74 -0
- 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 +69 -1
- 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/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 +260 -33
- 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/permission_test.go +5 -1
- package/internal/cli/root.go +174 -32
- package/internal/cli/session.go +175 -28
- 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 +101 -10
- package/npm/bin/magic-client.js +118 -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
|
@@ -43,6 +43,68 @@ func arkClawWorkingFrame(contextID string) string {
|
|
|
43
43
|
`"history":[]}}`, contextID)
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
// arkClawSplit 把事件按 kind 拆成「正文增量」与「轮次边界」两组。
|
|
47
|
+
//
|
|
48
|
+
// 为什么测试不能直接 len(events):completed 帧收尾会多发一条 KindTurnEnd
|
|
49
|
+
// (带权威全文,status=final),它**不是**又一段正文。收齐所有 ev.Text 再拼会
|
|
50
|
+
// 把 final 全文重复算进增量(与 codebuddy-gateway 改测试时踩的同一个坑)。
|
|
51
|
+
// 同理,turn_end 不能触发 AppendSupportOf 的 keep-alive 收工 —— arkclaw 不在名单里。
|
|
52
|
+
func arkClawSplit(events *[]StreamEvent) (texts []StreamEvent, ends []StreamEvent) {
|
|
53
|
+
for _, ev := range *events {
|
|
54
|
+
switch ev.Kind {
|
|
55
|
+
case KindText:
|
|
56
|
+
texts = append(texts, ev)
|
|
57
|
+
case KindTurnEnd:
|
|
58
|
+
ends = append(ends, ev)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return texts, ends
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// arkClawSplitFailed 与 arkClawSplit 同构,但收的是**异常终态** turn_failed。
|
|
65
|
+
// 单独一个入口是为了让「截断/失败」用例不必记住第三种 kind 的存在 ——
|
|
66
|
+
// 终态只有两种(turn_end 成功 / turn_failed 异常),正文永远只是 turn。
|
|
67
|
+
func arkClawSplitFailed(events *[]StreamEvent) (texts []StreamEvent, failed []StreamEvent) {
|
|
68
|
+
for _, ev := range *events {
|
|
69
|
+
switch ev.Kind {
|
|
70
|
+
case KindText:
|
|
71
|
+
texts = append(texts, ev)
|
|
72
|
+
case KindTurnFailed:
|
|
73
|
+
failed = append(failed, ev)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return texts, failed
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// assertArkClawFinal 断言 turn_end 承担了 final:挂在正文 item 上、revision 更高、
|
|
80
|
+
// 正文是权威全文。与 gateway / claude 同一约定。
|
|
81
|
+
func assertArkClawFinal(t *testing.T, texts, ends []StreamEvent, wantFinal string) {
|
|
82
|
+
t.Helper()
|
|
83
|
+
if len(texts) == 0 {
|
|
84
|
+
t.Fatal("缺少正文事件")
|
|
85
|
+
}
|
|
86
|
+
if len(ends) != 1 {
|
|
87
|
+
t.Fatalf("turn_end 事件数 = %d,want 1", len(ends))
|
|
88
|
+
}
|
|
89
|
+
end := ends[0]
|
|
90
|
+
if end.Status != StatusFinal {
|
|
91
|
+
t.Errorf("turn_end.Status = %q want %q", end.Status, StatusFinal)
|
|
92
|
+
}
|
|
93
|
+
if end.Text != wantFinal {
|
|
94
|
+
t.Errorf("turn_end.Text = %q want 权威全文 %q", end.Text, wantFinal)
|
|
95
|
+
}
|
|
96
|
+
if end.ItemID != texts[0].ItemID {
|
|
97
|
+
t.Errorf("turn_end 应挂在正文 item 上:%q vs %q", end.ItemID, texts[0].ItemID)
|
|
98
|
+
}
|
|
99
|
+
if end.ItemRevision <= texts[len(texts)-1].ItemRevision {
|
|
100
|
+
t.Errorf("turn_end revision 必须更高:正文末帧=%d turn_end=%d",
|
|
101
|
+
texts[len(texts)-1].ItemRevision, end.ItemRevision)
|
|
102
|
+
}
|
|
103
|
+
if end.ItemID == "" {
|
|
104
|
+
t.Error("turn_end 缺 item_id(状态收敛字段为空)")
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
46
108
|
// arkClawArtifactFrame 组一个把正文挂在 artifacts 上的帧(网关将来吐增量时的形态)。
|
|
47
109
|
func arkClawArtifactFrame(state, contextID, text string) string {
|
|
48
110
|
return fmt.Sprintf(`{"jsonrpc":"2.0","id":"req-1","result":{"kind":"task","id":"task-1",`+
|
|
@@ -102,13 +164,16 @@ func TestArkClawStreamSSECompleted(t *testing.T) {
|
|
|
102
164
|
t.Errorf("method = %q want message/stream", sent.Method)
|
|
103
165
|
}
|
|
104
166
|
|
|
105
|
-
// 事件:working 帧**不发事件**(网关没有增量可发),completed
|
|
106
|
-
|
|
107
|
-
|
|
167
|
+
// 事件:working 帧**不发事件**(网关没有增量可发),completed 帧发一条正文增量,
|
|
168
|
+
// 收尾多发一条 turn_end(final,不是又一段正文 —— 见 arkClawSplit)。
|
|
169
|
+
texts, ends := arkClawSplit(events)
|
|
170
|
+
if len(texts) != 1 {
|
|
171
|
+
t.Fatalf("正文增量数 = %d want 1: %+v", len(texts), *events)
|
|
108
172
|
}
|
|
109
|
-
if ev :=
|
|
173
|
+
if ev := texts[0]; ev.Kind != KindText || ev.Text != text {
|
|
110
174
|
t.Errorf("事件 = %+v want KindText/%q", ev, text)
|
|
111
175
|
}
|
|
176
|
+
assertArkClawFinal(t, texts, ends, text)
|
|
112
177
|
|
|
113
178
|
if res.Text != text {
|
|
114
179
|
t.Errorf("Text = %q", res.Text)
|
|
@@ -136,8 +201,8 @@ func TestArkClawStreamSkipsHeartbeatAndNonDataLines(t *testing.T) {
|
|
|
136
201
|
if err != nil {
|
|
137
202
|
t.Fatalf("Stream: %v", err)
|
|
138
203
|
}
|
|
139
|
-
if
|
|
140
|
-
t.Errorf("
|
|
204
|
+
if texts, _ := arkClawSplit(events); len(texts) != 1 || texts[0].Text != "正文" {
|
|
205
|
+
t.Errorf("正文增量 = %+v want 单条正文", texts)
|
|
141
206
|
}
|
|
142
207
|
if res.Text != "正文" {
|
|
143
208
|
t.Errorf("Text = %q", res.Text)
|
|
@@ -160,8 +225,10 @@ func TestArkClawStreamDedupWhenArtifactsRepeat(t *testing.T) {
|
|
|
160
225
|
if err != nil {
|
|
161
226
|
t.Fatalf("Stream: %v", err)
|
|
162
227
|
}
|
|
163
|
-
|
|
164
|
-
|
|
228
|
+
// 正文只发一次(status.message 与 artifacts 同文,去重后差量为空);
|
|
229
|
+
// 收尾的 turn_end 不算正文,见 arkClawSplit。
|
|
230
|
+
if texts, _ := arkClawSplit(events); len(texts) != 1 {
|
|
231
|
+
t.Fatalf("正文增量数 = %d want 1(同一份正文不得发两次): %+v", len(texts), *events)
|
|
165
232
|
}
|
|
166
233
|
if res.Text != "同一份正文" {
|
|
167
234
|
t.Errorf("Text = %q", res.Text)
|
|
@@ -182,12 +249,16 @@ func TestArkClawStreamForwardsArtifactIncrements(t *testing.T) {
|
|
|
182
249
|
if err != nil {
|
|
183
250
|
t.Fatalf("Stream: %v", err)
|
|
184
251
|
}
|
|
185
|
-
|
|
186
|
-
|
|
252
|
+
// 正文两条增量(第一段 / 第二段),completed 帧的整段前缀去重后无新增。
|
|
253
|
+
// turn_end 单独承担轮次边界,不计入增量。
|
|
254
|
+
texts, ends := arkClawSplit(events)
|
|
255
|
+
if len(texts) != 2 {
|
|
256
|
+
t.Fatalf("正文增量数 = %d want 2: %+v", len(texts), *events)
|
|
187
257
|
}
|
|
188
|
-
if
|
|
189
|
-
t.Errorf("增量 = %q / %q",
|
|
258
|
+
if texts[0].Text != "第一段" || texts[1].Text != "第二段" {
|
|
259
|
+
t.Errorf("增量 = %q / %q", texts[0].Text, texts[1].Text)
|
|
190
260
|
}
|
|
261
|
+
assertArkClawFinal(t, texts, ends, "第一段第二段")
|
|
191
262
|
if res.Text != "第一段第二段" {
|
|
192
263
|
t.Errorf("Text = %q", res.Text)
|
|
193
264
|
}
|
|
@@ -256,9 +327,13 @@ func TestArkClawStreamJSONSchemaPostProcess(t *testing.T) {
|
|
|
256
327
|
if err := json.Unmarshal([]byte(res.Text), &out); err != nil {
|
|
257
328
|
t.Fatalf("Text 不是纯 JSON: %v (%q)", err, res.Text)
|
|
258
329
|
}
|
|
259
|
-
|
|
330
|
+
// 增量正文(已 JSONSchema 抽取)必须与收尾正文逐字节相同。
|
|
331
|
+
// 注意别把 turn_end 的权威全文当成第二段增量算进来。
|
|
332
|
+
texts, ends := arkClawSplit(events)
|
|
333
|
+
if len(texts) != 1 || texts[0].Text != res.Text {
|
|
260
334
|
t.Errorf("增量应与收尾正文一致: events=%+v text=%q", *events, res.Text)
|
|
261
335
|
}
|
|
336
|
+
assertArkClawFinal(t, texts, ends, res.Text)
|
|
262
337
|
}
|
|
263
338
|
|
|
264
339
|
// ---------- 错误路径 ----------
|
|
@@ -395,8 +470,15 @@ func TestArkClawStreamNonSSEFallback(t *testing.T) {
|
|
|
395
470
|
if res.Text != "一次性正文" || res.SessionID != "ctx-f1" {
|
|
396
471
|
t.Errorf("res = %+v", res.Response)
|
|
397
472
|
}
|
|
398
|
-
|
|
399
|
-
|
|
473
|
+
// 回退路径同样有状态收敛:整段正文一条 text + 一条 turn_end(final),
|
|
474
|
+
// 且 item id 取 task id(与 SSE 路径同源,见 arkClawTaskID 的注释)。
|
|
475
|
+
texts, ends := arkClawSplit(events)
|
|
476
|
+
if len(texts) != 1 || texts[0].Text != "一次性正文" {
|
|
477
|
+
t.Errorf("正文增量 = %+v want 单条正文", texts)
|
|
478
|
+
}
|
|
479
|
+
assertArkClawFinal(t, texts, ends, "一次性正文")
|
|
480
|
+
if !strings.HasPrefix(texts[0].ItemID, "arkclaw_msg_") || strings.Contains(texts[0].ItemID, "local_") {
|
|
481
|
+
t.Errorf("回退路径应沿用原生 task id 而非降级序号: %q", texts[0].ItemID)
|
|
400
482
|
}
|
|
401
483
|
if !strings.Contains(buf.String(), "SSE") {
|
|
402
484
|
t.Errorf("回退应在 stderr 说明原因: %q", buf.String())
|
|
@@ -659,10 +659,10 @@ func TestArkClawNoModelNoWarning(t *testing.T) {
|
|
|
659
659
|
|
|
660
660
|
func TestDefaultArkClawTimeoutCoversObservedLatency(t *testing.T) {
|
|
661
661
|
// 实测短问答 8~23s,但**带工具循环的长任务**要 5 分钟(2026-09-24:「生成周报」304s 才回,
|
|
662
|
-
// 期间每 15s 一个 working 心跳)。默认值必须留足余量,且**不得短于 CLI 的 -t 默认(
|
|
662
|
+
// 期间每 15s 一个 working 心跳)。默认值必须留足余量,且**不得短于 CLI 的 -t 默认(1 小时)**
|
|
663
663
|
// —— 短了就会出现「什么都没显示 · 调用失败 context deadline exceeded」。
|
|
664
|
-
if DefaultArkClawTimeout <
|
|
665
|
-
t.Errorf("DefaultArkClawTimeout = %v, 实测长任务 304s + CLI -t 默认
|
|
664
|
+
if DefaultArkClawTimeout < time.Hour {
|
|
665
|
+
t.Errorf("DefaultArkClawTimeout = %v, 实测长任务 304s + CLI -t 默认 1h,应 ≥ 1 小时", DefaultArkClawTimeout)
|
|
666
666
|
}
|
|
667
667
|
}
|
|
668
668
|
|
|
@@ -16,8 +16,11 @@ package agent
|
|
|
16
16
|
// stdin:stream-json claude/codebuddy 原生 stream-json input(图片走 content block)
|
|
17
17
|
// flag:-a llm CLI 原生 --attachment
|
|
18
18
|
// part:file arkclaw 走 A2A 原生 file part(base64 inline)
|
|
19
|
+
// sdk:content-blocks dsh 的 SDK 通道(默认)把图片作为原生 image content block
|
|
20
|
+
// 发出去(base64 inline,真进模型上下文);显式切 headless 后
|
|
21
|
+
// 没有图片通道 → 退化为下面的 prompt 档
|
|
19
22
|
// prompt 无原生输入通道:把绝对路径写进提示词,靠引擎的读文件工具看
|
|
20
|
-
// (trae / openclaw
|
|
23
|
+
// (trae / openclaw —— 需要工具可用,CLI 层会提示)
|
|
21
24
|
// codebuddy-gateway 也归这一档:它走 payload.attachments 原生字段,
|
|
22
25
|
// 但网关把它渲染成路径清单塞进 prompt(图不进模型上下文)
|
|
23
26
|
|
|
@@ -111,7 +114,16 @@ func AttachmentSupportOf(engine string) string {
|
|
|
111
114
|
return "flag:-a"
|
|
112
115
|
case "arkclaw":
|
|
113
116
|
return "part:file"
|
|
114
|
-
case "
|
|
117
|
+
case "dsh":
|
|
118
|
+
// SDK 通道(默认)把图片作为原生 image content block 发出去(真进模型上下文);
|
|
119
|
+
// 显式把通道切成 headless 后没有图片通道,只能退化为提示词里的路径。
|
|
120
|
+
// dshProfileName() 与引擎的 effectiveProfile 同规则(看 MAGIC_AGENT_DSH_PROFILE),
|
|
121
|
+
// 这里让 --engines 的能力字段与实际通道一致,不虚报原生。
|
|
122
|
+
if dshProfileName() == dshSDKProfile {
|
|
123
|
+
return "sdk:content-blocks"
|
|
124
|
+
}
|
|
125
|
+
return "prompt"
|
|
126
|
+
case "trae", "openclaw", "codebuddy-gateway":
|
|
115
127
|
// codebuddy-gateway 走 payload.attachments 原生字段(urlType=local-path),
|
|
116
128
|
// 但网关把它渲染成「附件清单 + 路径」塞进 prompt —— 图**不会**以图片形式
|
|
117
129
|
// 进模型上下文,故如实报 prompt 而不是 part:file。
|
|
@@ -158,9 +170,10 @@ func AppendDefaultOn(engine string) bool {
|
|
|
158
170
|
|
|
159
171
|
// AttachmentPromptSection 把附件以「路径清单」的形式拼进提示词正文。
|
|
160
172
|
//
|
|
161
|
-
// 这是**没有原生附件输入通道**的引擎(trae / openclaw
|
|
162
|
-
//
|
|
163
|
-
// --tools off 时给出明确提示(不静默丢附件)。
|
|
173
|
+
// 这是**没有原生附件输入通道**的引擎(trae / openclaw)以及 dsh 的 headless 回退路
|
|
174
|
+
// 的兜底:把绝对路径说给模型,由它自己的读文件工具去看。因此只有在工具可用时才有
|
|
175
|
+
// 意义 —— CLI 层会在 --tools off 时给出明确提示(不静默丢附件)。dsh 的 SDK 通道不走
|
|
176
|
+
// 这里:图片作为原生 image 块直接发(见 dshPromptBlocks)。
|
|
164
177
|
func AttachmentPromptSection(atts []Attachment) string {
|
|
165
178
|
if len(atts) == 0 {
|
|
166
179
|
return ""
|
|
@@ -107,6 +107,7 @@ func TestNewAttachmentFallsBackToExtension(t *testing.T) {
|
|
|
107
107
|
}
|
|
108
108
|
|
|
109
109
|
func TestAttachmentSupportOf(t *testing.T) {
|
|
110
|
+
t.Setenv(DshProfileEnv, "") // dsh 默认走 SDK 通道 → 原生收图
|
|
110
111
|
want := map[string]string{
|
|
111
112
|
"codex": "flag:-i",
|
|
112
113
|
"claude": "stdin:stream-json",
|
|
@@ -116,7 +117,7 @@ func TestAttachmentSupportOf(t *testing.T) {
|
|
|
116
117
|
"arkclaw": "part:file",
|
|
117
118
|
"trae": "prompt",
|
|
118
119
|
"openclaw": "prompt",
|
|
119
|
-
"dsh": "
|
|
120
|
+
"dsh": "sdk:content-blocks",
|
|
120
121
|
}
|
|
121
122
|
for engine, w := range want {
|
|
122
123
|
if got := AttachmentSupportOf(engine); got != w {
|
|
@@ -127,6 +128,11 @@ func TestAttachmentSupportOf(t *testing.T) {
|
|
|
127
128
|
if got := AttachmentSupportOf("nope"); got != "prompt" {
|
|
128
129
|
t.Errorf("未知引擎应为 prompt, got %q", got)
|
|
129
130
|
}
|
|
131
|
+
// dsh 显式切 headless → 没有图片通道,退化为提示词路径
|
|
132
|
+
t.Setenv(DshProfileEnv, dshDefaultProfile)
|
|
133
|
+
if got := AttachmentSupportOf("dsh"); got != "prompt" {
|
|
134
|
+
t.Errorf("headless 下 AttachmentSupportOf(dsh) = %q want prompt", got)
|
|
135
|
+
}
|
|
130
136
|
}
|
|
131
137
|
|
|
132
138
|
func TestAppendAttachmentSectionFallback(t *testing.T) {
|
|
@@ -127,7 +127,6 @@ func TestCapabilitiesDocumentedEngines(t *testing.T) {
|
|
|
127
127
|
}{
|
|
128
128
|
{"trae", &TraeEngine{}},
|
|
129
129
|
{"openclaw", &OpenClawEngine{}},
|
|
130
|
-
{"dsh", &DshEngine{}},
|
|
131
130
|
} {
|
|
132
131
|
caps := capabilitiesOfEngine(tc.e)
|
|
133
132
|
if !hasCap(caps, CapAttachmentPrompt) {
|
|
@@ -138,6 +137,17 @@ func TestCapabilitiesDocumentedEngines(t *testing.T) {
|
|
|
138
137
|
}
|
|
139
138
|
}
|
|
140
139
|
|
|
140
|
+
// dsh:SDK 通道(默认)把图片作为原生 image block 发出 → attachment.native。
|
|
141
|
+
// 显式切 headless 后没有图片通道 → 退化为 attachment.prompt(能力随实际通道走)。
|
|
142
|
+
t.Setenv(DshProfileEnv, "")
|
|
143
|
+
if caps := capabilitiesOfEngine(&DshEngine{}); !hasCap(caps, CapAttachmentNative) || hasCap(caps, CapAttachmentPrompt) {
|
|
144
|
+
t.Errorf("dsh(SDK 通道) 应有 %s 而非 %s,实际 %v", CapAttachmentNative, CapAttachmentPrompt, caps)
|
|
145
|
+
}
|
|
146
|
+
t.Setenv(DshProfileEnv, dshDefaultProfile)
|
|
147
|
+
if caps := capabilitiesOfEngine(&DshEngine{}); !hasCap(caps, CapAttachmentPrompt) || hasCap(caps, CapAttachmentNative) {
|
|
148
|
+
t.Errorf("dsh(headless) 应有 %s 而非 %s,实际 %v", CapAttachmentPrompt, CapAttachmentNative, caps)
|
|
149
|
+
}
|
|
150
|
+
|
|
141
151
|
// arkclaw 的附件走 A2A 原生 file part → 应给出 attachment.native。
|
|
142
152
|
// 这条直接查表:ArkClawEngine 的名字来自配置(不是常量),不适合直接构造。
|
|
143
153
|
if got := AttachmentSupportOf("arkclaw"); got != "part:file" {
|
package/internal/agent/claude.go
CHANGED
|
@@ -3,15 +3,27 @@ package agent
|
|
|
3
3
|
// claude.go - Claude Code CLI 引擎。
|
|
4
4
|
//
|
|
5
5
|
// 后端:claude(Claude Code,本机 /opt/homebrew/bin/claude)。
|
|
6
|
-
// 非交互模式:-p(--print)+ --output-format json,拿到稳定 envelope:
|
|
7
6
|
//
|
|
8
|
-
//
|
|
9
|
-
// "session_id":"...","is_error":false, "total_cost_usd":...}
|
|
7
|
+
// ## 提示词一律走 stdin(2026-10-01 改,Windows 实测)
|
|
10
8
|
//
|
|
11
|
-
//
|
|
9
|
+
// 以前用 `-p <prompt>` 把提示词当**位置参数**传。实测在 Windows 上这是
|
|
10
|
+
// 结构性不可靠的:npm 装的 claude 是 `claude.cmd` 批处理,含换行的位置参数
|
|
11
|
+
// 会被 cmd.exe 拆坏,子进程拿不到完整 prompt 就**静默退 0、零输出**
|
|
12
|
+
//(对照实验:同一命令单行 20 行 / 含换行 0 行;非流式同样整段丢内容)。
|
|
13
|
+
//
|
|
14
|
+
// 现在 Complete 与 Stream **都**走 `--input-format stream-json` 的 stdin
|
|
15
|
+
// 通道(streamjson.go),提示词永不作为 argv。unix 上协议完全相同,
|
|
16
|
+
// 故**不加平台分支** —— 一条路径两平台通吃。
|
|
17
|
+
//
|
|
18
|
+
// 收尾正文从 NDJSON 的 result 行取(这条协议要求 --output-format 也是
|
|
19
|
+
// stream-json),故连非流式的 Complete 也要归约一遍事件。
|
|
20
|
+
//
|
|
21
|
+
// ## 关键 flags:
|
|
12
22
|
//
|
|
13
23
|
// --model <m> 传裸模型名(sonnet / opus / claude-sonnet-4-6...)
|
|
14
24
|
// --tools "" 禁用全部内置工具,纯 chat 一次成型
|
|
25
|
+
// (Windows 上经 launch.go 改写成 --tools=,
|
|
26
|
+
// 空串作独立参数会被 cmd.exe 丢弃)
|
|
15
27
|
// --permission-mode <mode> 四档权限档位(见 permission.go):
|
|
16
28
|
// manual → default;accept-edits → acceptEdits;
|
|
17
29
|
// auto → auto;full → bypassPermissions(默认档)
|
|
@@ -39,7 +51,6 @@ package agent
|
|
|
39
51
|
|
|
40
52
|
import (
|
|
41
53
|
"context"
|
|
42
|
-
"encoding/json"
|
|
43
54
|
"fmt"
|
|
44
55
|
"io"
|
|
45
56
|
"os"
|
|
@@ -57,8 +68,8 @@ type ClaudeEngine struct {
|
|
|
57
68
|
// Name 实现 Engine。
|
|
58
69
|
func (e *ClaudeEngine) Name() string { return "claude" }
|
|
59
70
|
|
|
60
|
-
// DefaultClaudeTimeout
|
|
61
|
-
const DefaultClaudeTimeout =
|
|
71
|
+
// DefaultClaudeTimeout 单次尝试默认超时(2026-10-10 由 5 分钟统一上调到 1 小时)。
|
|
72
|
+
const DefaultClaudeTimeout = time.Hour
|
|
62
73
|
|
|
63
74
|
// DefaultClaudeModel claude 引擎的默认模型(Claude Code CLI 默认即主模型,
|
|
64
75
|
// 显式传空串别名让 CLI 自己决定,不在代理层硬编码具体版本号)。
|
|
@@ -121,17 +132,6 @@ func (e *ClaudeEngine) ListModels(_ context.Context) ([]string, error) {
|
|
|
121
132
|
return models, nil
|
|
122
133
|
}
|
|
123
134
|
|
|
124
|
-
// claudeResult 对应 --output-format json 的 envelope。
|
|
125
|
-
type claudeResult struct {
|
|
126
|
-
Type string `json:"type"`
|
|
127
|
-
Subtype string `json:"subtype"`
|
|
128
|
-
IsError bool `json:"is_error"`
|
|
129
|
-
Result string `json:"result"`
|
|
130
|
-
SessionID string `json:"session_id"`
|
|
131
|
-
Model string `json:"model"`
|
|
132
|
-
TotalCostUSD float64 `json:"total_cost_usd"`
|
|
133
|
-
}
|
|
134
|
-
|
|
135
135
|
// Complete 实现 Engine:单次调用 claude CLI。
|
|
136
136
|
func (e *ClaudeEngine) Complete(ctx context.Context, req Request) (Response, error) {
|
|
137
137
|
start := time.Now()
|
|
@@ -145,54 +145,14 @@ func (e *ClaudeEngine) Complete(ctx context.Context, req Request) (Response, err
|
|
|
145
145
|
return Response{}, fmt.Errorf("claude: empty prompt")
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
args := e.buildArgs(req, prompt)
|
|
157
|
-
|
|
158
|
-
// workspace:claude 没有工作目录 flag,子进程 cwd 就是原生方式(见 Request.Workspace)
|
|
159
|
-
stdout, stderr, err := runCLIIn(ctx, req.Workspace, bin, args...)
|
|
160
|
-
if err != nil {
|
|
161
|
-
return Response{}, wrapCliError("claude", stdout, stderr, err)
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
raw := strings.TrimSpace(stdout)
|
|
165
|
-
if raw == "" {
|
|
166
|
-
return Response{}, fmt.Errorf("claude CLI returned empty output")
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
var env claudeResult
|
|
170
|
-
if err := json.Unmarshal([]byte(raw), &env); err != nil {
|
|
171
|
-
// 个别版本对 --print + json 仍吐纯文本:原样返回。
|
|
172
|
-
return Response{
|
|
173
|
-
Text: raw,
|
|
174
|
-
Model: req.Model,
|
|
175
|
-
Latency: time.Since(start),
|
|
176
|
-
}, nil
|
|
177
|
-
}
|
|
178
|
-
if env.IsError {
|
|
179
|
-
return Response{}, fmt.Errorf("claude CLI error (subtype=%s): %s", env.Subtype, truncateStr(env.Result, 500))
|
|
180
|
-
}
|
|
181
|
-
if env.Result == "" {
|
|
182
|
-
return Response{}, fmt.Errorf("claude CLI returned empty result")
|
|
183
|
-
}
|
|
184
|
-
return Response{
|
|
185
|
-
Text: env.Result,
|
|
186
|
-
Model: env.Model,
|
|
187
|
-
SessionID: env.SessionID,
|
|
188
|
-
Latency: time.Since(start),
|
|
189
|
-
}, nil
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
// completeWithAttachments 走 stream-json 输入通道跑一次带附件的调用,
|
|
193
|
-
// 把事件归约成单次 Response(不向调用方发增量事件)。
|
|
194
|
-
func (e *ClaudeEngine) completeWithAttachments(ctx context.Context, req Request, prompt string, start time.Time) (Response, error) {
|
|
195
|
-
bin := e.bin()
|
|
148
|
+
// 提示词走 stdin 的 stream-json 通道(**所有**调用,含无附件的纯文本)。
|
|
149
|
+
//
|
|
150
|
+
// 为什么不再用 `-p <prompt>` 位置参数(2026-10-01 改,Windows 实测):
|
|
151
|
+
// 位置参数在 Windows 上会被 .cmd 批处理拆坏 —— 含换行时整段内容丢失,
|
|
152
|
+
// claude 收到空 prompt 后回一句 "How can I help you today?"(实测)。
|
|
153
|
+
// unix 上位置参数本来能用,但统一走 stdin 可让两平台行为完全一致,
|
|
154
|
+
// 省掉一类「只在某个平台复现」的问题。代价是要多解析一层 NDJSON,
|
|
155
|
+
// 而 streamJSONArgs/runStreamJSONIn 已是附件路径的现成实现。
|
|
196
156
|
args := streamJSONArgs(e.buildArgsBase(req), prompt, false)
|
|
197
157
|
stdin, err := streamJSONUserLine(prompt, req.Attachments)
|
|
198
158
|
if err != nil {
|
|
@@ -200,12 +160,19 @@ func (e *ClaudeEngine) completeWithAttachments(ctx context.Context, req Request,
|
|
|
200
160
|
}
|
|
201
161
|
acc := &streamAccumulator{Engine: e.Name(), OnSessionID: req.OnSessionID}
|
|
202
162
|
var fin streamJSONResult
|
|
203
|
-
|
|
163
|
+
// stderr 尾巴留一份:非零退出与「没有 result 行」都要拿它拼可读原因
|
|
164
|
+
//(CLI 崩溃 / 鉴权与额度失败的原因只出现在它的 stderr 里)。
|
|
165
|
+
var cliStderr streamStderrBuf
|
|
166
|
+
seen, err := runStreamJSONIn(ctx, req.Workspace, nil, bin, args, strings.NewReader(stdin), acc, &fin, &cliStderr)
|
|
204
167
|
if err != nil {
|
|
205
|
-
return Response{}, wrapCliError("claude", "",
|
|
168
|
+
return Response{}, wrapCliError("claude", "", cliStderr.String(), err)
|
|
206
169
|
}
|
|
207
170
|
if !seen {
|
|
208
|
-
|
|
171
|
+
// 补终态事件(2026-10-02):非流式路径通常没有 onEvent,nil 时静默跳过。
|
|
172
|
+
// 有 onEvent 的场景(观察者)才能看到「这一轮异常结束」而不是凭空消失。
|
|
173
|
+
err := streamNoResultErr("claude", cliStderr.String())
|
|
174
|
+
acc.emitTurnFailed(acc.SessionID, err)
|
|
175
|
+
return Response{}, err
|
|
209
176
|
}
|
|
210
177
|
if fin.IsError {
|
|
211
178
|
return Response{}, fmt.Errorf("claude CLI error (subtype=%s): %s", fin.Subtype, truncateStr(fin.Result, 500))
|
|
@@ -222,14 +189,16 @@ func (e *ClaudeEngine) completeWithAttachments(ctx context.Context, req Request,
|
|
|
222
189
|
}, nil
|
|
223
190
|
}
|
|
224
191
|
|
|
225
|
-
// buildArgs 构造 claude CLI
|
|
226
|
-
//
|
|
192
|
+
// buildArgs 构造 claude CLI 参数(含末尾的位置参数 prompt)。
|
|
193
|
+
//
|
|
194
|
+
// ⚠️ 2026-10-01 起**生产路径已不再用它**:Complete 与 Stream 都把提示词
|
|
195
|
+
// 走 stdin(见各自注释里的理由),只有测试与「显式要位置参数」的场合还可能用。
|
|
196
|
+
// 保留是因为 buildArgsBase 的语义("不含位置参数")仍被 streamJSONArgs 依赖。
|
|
227
197
|
func (e *ClaudeEngine) buildArgs(req Request, prompt string) []string {
|
|
228
198
|
return append(e.buildArgsBase(req), prompt)
|
|
229
199
|
}
|
|
230
200
|
|
|
231
|
-
// buildArgsBase 构造参数(不含末尾的位置参数 prompt
|
|
232
|
-
// 那条路提示词走 stdin,不能再作为命令行参数传(streamJSONArgs 负责剔除)。
|
|
201
|
+
// buildArgsBase 构造参数(不含末尾的位置参数 prompt)——提示词走 stdin 的路径复用它。
|
|
233
202
|
func (e *ClaudeEngine) buildArgsBase(req Request) []string {
|
|
234
203
|
// 工具模式 × 权限档位(四档模型见 permission.go):
|
|
235
204
|
// off --tools ""(无工具调用,权限档位无意义,不传)
|
|
@@ -252,7 +221,13 @@ func (e *ClaudeEngine) buildArgsBase(req Request) []string {
|
|
|
252
221
|
case tools.IsOff():
|
|
253
222
|
args = append(args, "--tools", "")
|
|
254
223
|
case tools.IsOn():
|
|
255
|
-
|
|
224
|
+
// ⚠️ 2026-10-01:必须显式 --tools default —— 只传 --permission-mode
|
|
225
|
+
// 不传 --tools 时,claude CLI 在 stream-json 模式下不发 result 行
|
|
226
|
+
// (实测):表现为「stream ended without result line」,魔法端把整轮
|
|
227
|
+
// 标 turn_failed、UI 一句话都渲染不出来。--tools default 才是「开工具」
|
|
228
|
+
// 的正路(与 ""=关、name1,name2=白名单 同列;"default" 是「全部内置」)。
|
|
229
|
+
args = append(args, "--tools", "default",
|
|
230
|
+
"--permission-mode", claudePermissionMode(req.Permission))
|
|
256
231
|
default: // allowlist
|
|
257
232
|
args = append(args, "--tools", strings.Join(tools.Allowlist(), ","),
|
|
258
233
|
"--permission-mode", claudePermissionMode(req.Permission))
|
|
@@ -262,6 +237,14 @@ func (e *ClaudeEngine) buildArgsBase(req Request) []string {
|
|
|
262
237
|
} else {
|
|
263
238
|
args = append(args, "--model", DefaultClaudeModel)
|
|
264
239
|
}
|
|
240
|
+
// 推理档位:claude 的 --effort 只认 low/medium/high/xhigh/max(无 minimal),
|
|
241
|
+
// 传 minimal 时降级为 low,避免 clap 直接拒绝整轮调用。
|
|
242
|
+
if lv := ReasoningLevel(req.Reasoning); lv != "" {
|
|
243
|
+
if lv == "minimal" {
|
|
244
|
+
lv = "low"
|
|
245
|
+
}
|
|
246
|
+
args = append(args, "--effort", lv)
|
|
247
|
+
}
|
|
265
248
|
// 统一参数矩阵:`--settings` 只接受**一份**载荷(文件路径或内联 JSON),
|
|
266
249
|
// 所以 MaxTokens 的 env 注入、四档模型的 sandbox / autoMode / permissions
|
|
267
250
|
// 必须合并进同一个 JSON —— 见 permission.go 的 agentSettingsPayload。
|
|
@@ -287,102 +270,57 @@ func (e *ClaudeEngine) Stream(ctx context.Context, req Request, onEvent func(Str
|
|
|
287
270
|
return StreamResult{}, fmt.Errorf("claude: empty prompt")
|
|
288
271
|
}
|
|
289
272
|
|
|
290
|
-
//
|
|
291
|
-
//
|
|
273
|
+
// 提示词一律走 stdin 的 stream-json 输入通道(不再当位置参数)。
|
|
274
|
+
//
|
|
275
|
+
// 为什么这是**默认**而不是「附件时才走」(2026-10-01 改,Windows 实测):
|
|
276
|
+
// 位置参数在 Windows 上是结构性不可靠的 —— npm 装的 claude 是 .cmd 批处理,
|
|
277
|
+
// 含换行的位置参数会被 cmd.exe 拆坏,子进程拿不到完整 prompt 就
|
|
278
|
+
// **静默退 0、零输出**(对照实验:同一命令单行 20 行 / 含换行 0 行)。
|
|
279
|
+
// 不只是流式受损:非流式的 -p 同样整段丢内容。
|
|
280
|
+
// agents-anywhere 对此的处理是提示词全走 SDK stdin(connector/runtimes/claude/
|
|
281
|
+
// sdk/client.py:160 的 query(content)),从架构上就没有这条破路。
|
|
282
|
+
//
|
|
283
|
+
// unix 上走 stdin 同样正确(协议一致),故**不加平台分支** ——
|
|
284
|
+
// 一条路径两平台通吃,避免两套行为漂移。
|
|
285
|
+
//
|
|
286
|
+
// 常驻会话(Request.Append 非 nil)的区别只在 stdin 是个**流**:
|
|
292
287
|
// 追加消息源源不断写进去,通道关闭才 EOF(子进程随之收尾)。
|
|
293
|
-
|
|
294
|
-
if req.Append != nil
|
|
295
|
-
|
|
296
|
-
if req.Append != nil {
|
|
297
|
-
pipe, perr := streamJSONInputPipe(prompt, req.Attachments, req.Append)
|
|
298
|
-
if perr != nil {
|
|
299
|
-
return StreamResult{}, perr
|
|
300
|
-
}
|
|
301
|
-
stdin = pipe
|
|
302
|
-
} else {
|
|
303
|
-
line, serr := streamJSONUserLine(prompt, req.Attachments)
|
|
304
|
-
if serr != nil {
|
|
305
|
-
return StreamResult{}, serr
|
|
306
|
-
}
|
|
307
|
-
stdin = strings.NewReader(line)
|
|
308
|
-
}
|
|
309
|
-
args = streamJSONArgs(e.buildArgsBase(req), prompt, true)
|
|
310
|
-
acc := &streamAccumulator{Engine: e.Name(), OnEvent: onEvent, OnSessionID: req.OnSessionID}
|
|
311
|
-
var fin streamJSONResult
|
|
312
|
-
seen, err := runStreamJSONIn(ctx, req.Workspace, nil, bin, args, stdin, acc, &fin)
|
|
313
|
-
if err != nil {
|
|
314
|
-
return StreamResult{}, err
|
|
315
|
-
}
|
|
316
|
-
if !seen {
|
|
317
|
-
return StreamResult{}, fmt.Errorf("claude CLI stream ended without result line")
|
|
318
|
-
}
|
|
319
|
-
if fin.IsError {
|
|
320
|
-
return StreamResult{}, fmt.Errorf("claude CLI error (subtype=%s): %s", fin.Subtype, truncateStr(fin.Result, 500))
|
|
321
|
-
}
|
|
322
|
-
text := finalizeStreamText(fin, acc)
|
|
323
|
-
if text == "" {
|
|
324
|
-
return StreamResult{}, errEmptyStreamContent("claude")
|
|
325
|
-
}
|
|
326
|
-
return StreamResult{
|
|
327
|
-
Response: Response{
|
|
328
|
-
Engine: e.Name(),
|
|
329
|
-
Text: text,
|
|
330
|
-
Model: fin.Model,
|
|
331
|
-
SessionID: fin.SessionID,
|
|
332
|
-
Latency: time.Since(start),
|
|
333
|
-
},
|
|
334
|
-
Thinking: acc.Thinking.String(),
|
|
335
|
-
Tools: acc.Tools,
|
|
336
|
-
}, nil
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
// json → stream-json:替换 --output-format 值并追加流式 flags。
|
|
340
|
-
for i := range args {
|
|
341
|
-
if args[i] == "--output-format" {
|
|
342
|
-
args[i+1] = "stream-json"
|
|
343
|
-
break
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
args = append(args, "--include-partial-messages", "--verbose")
|
|
347
|
-
|
|
348
|
-
acc := &streamAccumulator{Engine: e.Name(), OnEvent: onEvent}
|
|
349
|
-
var fin struct {
|
|
350
|
-
Type string `json:"type"`
|
|
351
|
-
Subtype string `json:"subtype"`
|
|
352
|
-
IsError bool `json:"is_error"`
|
|
353
|
-
Result string `json:"result"`
|
|
354
|
-
SessionID string `json:"session_id"`
|
|
355
|
-
Model string `json:"model"`
|
|
356
|
-
}
|
|
357
|
-
seenResult := false
|
|
358
|
-
|
|
359
|
-
err := runStreamCLIIn(ctx, req.Workspace, bin, args, func(line string) error {
|
|
360
|
-
isResult, perr := acc.handleNDJSONLine(line)
|
|
288
|
+
var stdin io.Reader
|
|
289
|
+
if req.Append != nil {
|
|
290
|
+
pipe, perr := streamJSONInputPipe(prompt, req.Attachments, req.Append)
|
|
361
291
|
if perr != nil {
|
|
362
|
-
return perr
|
|
292
|
+
return StreamResult{}, perr
|
|
363
293
|
}
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
294
|
+
stdin = pipe
|
|
295
|
+
} else {
|
|
296
|
+
line, serr := streamJSONUserLine(prompt, req.Attachments)
|
|
297
|
+
if serr != nil {
|
|
298
|
+
return StreamResult{}, serr
|
|
367
299
|
}
|
|
368
|
-
|
|
369
|
-
}
|
|
300
|
+
stdin = strings.NewReader(line)
|
|
301
|
+
}
|
|
302
|
+
args := streamJSONArgs(e.buildArgsBase(req), prompt, true)
|
|
303
|
+
acc := &streamAccumulator{Engine: e.Name(), OnEvent: onEvent, OnSessionID: req.OnSessionID}
|
|
304
|
+
var fin streamJSONResult
|
|
305
|
+
var cliStderr streamStderrBuf
|
|
306
|
+
seen, err := runStreamJSONIn(ctx, req.Workspace, nil, bin, args, stdin, acc, &fin, &cliStderr)
|
|
370
307
|
if err != nil {
|
|
371
308
|
return StreamResult{}, err
|
|
372
309
|
}
|
|
373
|
-
if !
|
|
374
|
-
|
|
310
|
+
if !seen {
|
|
311
|
+
// 补终态事件:流被截断(引擎崩 / 被杀 / 没吐 result)时,已流出去的半截正文
|
|
312
|
+
// 在事件流上永远停在 running —— 消费方看到的是一条悬空 item。
|
|
313
|
+
// 对齐 agents-anywhere 的 failed_terminal_event(见 emitTurnFailed)。
|
|
314
|
+
err := streamNoResultErr("claude", cliStderr.String())
|
|
315
|
+
acc.emitTurnFailed(acc.SessionID, err)
|
|
316
|
+
return StreamResult{}, err
|
|
375
317
|
}
|
|
376
318
|
if fin.IsError {
|
|
377
319
|
return StreamResult{}, fmt.Errorf("claude CLI error (subtype=%s): %s", fin.Subtype, truncateStr(fin.Result, 500))
|
|
378
320
|
}
|
|
379
|
-
|
|
380
|
-
text := fin.Result
|
|
381
|
-
if text == "" {
|
|
382
|
-
text = strings.TrimSpace(acc.Text.String())
|
|
383
|
-
}
|
|
321
|
+
text := finalizeStreamText(fin, acc)
|
|
384
322
|
if text == "" {
|
|
385
|
-
return StreamResult{},
|
|
323
|
+
return StreamResult{}, errEmptyStreamContent("claude")
|
|
386
324
|
}
|
|
387
325
|
return StreamResult{
|
|
388
326
|
Response: Response{
|