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,258 @@
1
+ package agent
2
+
3
+ // dsh_resume.go - dsh 的会话续接:**真续接**(headless `--session-id`)+ `--json` 事件解析。
4
+ //
5
+ // 结论先行(2026-10-09 实测,dsh 0.1.7-rc.2 与 0.2.0-rc.2 都如此):
6
+ //
7
+ // dsh 的会话由 **dsh 自己**持久化在 $DSH_HOME/sessions/<cwd-key>/<sessionId>/;
8
+ // headless profile 提供 `--session-id <id>`(官方 help 原文:
9
+ // "adopt the persisted Session with this id; an unknown id is an error")
10
+ // → **跨进程续接是支持的**,未知 id 会明确报错、exit 1,绝不静默开新会话。
11
+ //
12
+ // 但只有 headless 通道支持它:SDK 通道(initialize / session/prompt / shutdown)
13
+ // 没有 resume 方法。所以本引擎的分工是:
14
+ //
15
+ // 续接轮(-s <id> / -c) → headless + --session-id(上下文由 dsh 自己接回来,真续接)
16
+ // 普通轮 → 仍走 SDK 通道(默认:逐 step 流式 + 工具事件)
17
+ //
18
+ // 实测验收:拿 magic-agent 之前**经 SDK 通道**建出的会话 id 直接跑
19
+ //
20
+ // dsh --profile headless --session-id magic-agent-… "我让你记的数是多少?"
21
+ //
22
+ // 答出了上一轮让它记住的数字,且 `--json` 里的 turn 计数接着往下走
23
+ // (同一条会话,不是副本)—— 说明两条通道共用同一份持久化会话。
24
+ //
25
+ // ⚠️ 会话按 **cwd 分桶**:$DSH_HOME/sessions 下的目录名就是工作区路径的转义。
26
+ // 换工作区就找不到那条会话,所以续接**必须**与上一轮在同一个 workspace 发起 —— 与
27
+ // magic-agent 的 -w 语义一致。找不到时 dsh 会报 `session "…" does not exist`,照实透出。
28
+ //
29
+ // ⚠️ 曾经的「回放式续接」(把 magic-agent 的会话日志前置进 prompt)**已删除**:
30
+ // 那是「以为 dsh 不能按 id 续接」时的兜底,既然真续接可用,就不该再用假续接。
31
+
32
+ import (
33
+ "context"
34
+ "encoding/json"
35
+ "fmt"
36
+ "os"
37
+ "path/filepath"
38
+ "strings"
39
+ "sync"
40
+ "time"
41
+ )
42
+
43
+ // ── 续接目标:-s / -c → 会话 id ────────────────────────────────
44
+
45
+ // dshCanonicalWorkspace 把工作区路径规范化成 dsh 自己会看到的那一份。
46
+ //
47
+ // 为什么必须做:dsh 记会话时用的是**我们交给它的 cwd 字符串**(SDK 通道的
48
+ // initialize.cwd),而续接时 headless 拿自己的 getcwd 去**逐字比对**,只要字符串不同就
49
+ // 拒接 —— macOS 实测:`-w /tmp` 时会话被记成 "/tmp",续接进程的 getcwd 是
50
+ // "/private/tmp",于是报
51
+ //
52
+ // dsh: session "…" was recorded in "/tmp", not "/private/tmp"
53
+ //
54
+ // 两条通道都先用**解析掉符号链接**的路径,记录值与被比对值才是同一份。
55
+ // 解析失败(路径不存在等)就用原值 —— 不做额外猜测。
56
+ func dshCanonicalWorkspace(dir string) string {
57
+ dir = strings.TrimSpace(dir)
58
+ if dir == "" {
59
+ return ""
60
+ }
61
+ if resolved, err := filepath.EvalSymlinks(dir); err == nil && resolved != "" {
62
+ return resolved
63
+ }
64
+ return dir
65
+ }
66
+
67
+ // dshResumeSessionID 本轮要续接的会话 id(空 = 新会话)。
68
+ //
69
+ // -s/--session <id> 原样交给 dsh(存在性由 dsh 自己校验,未知 id 明确报错)
70
+ // -c/--continue 从 magic-agent 的会话登记表里挑**同一 workspace** 下最近一条
71
+ // dsh 会话。为什么不读 dsh 自己的会话目录:那个目录名是 cwd 的
72
+ // 内部转义(`/private/tmp` → `--private-tmp--`),解析它是猜实现;
73
+ // 登记表里 engine / session_id / workspace / 时间都是明文字段。
74
+ func dshResumeSessionID(req Request) string {
75
+ if id := strings.TrimSpace(req.SessionID); id != "" {
76
+ return id
77
+ }
78
+ if !req.Continue {
79
+ return ""
80
+ }
81
+ return dshLatestSessionID(strings.TrimSpace(req.Workspace))
82
+ }
83
+
84
+ // dshLatestSessionID 最近一条 dsh 会话的 session_id(可限定 workspace)。
85
+ func dshLatestSessionID(workspace string) string {
86
+ dir := dshSessionDir()
87
+ if dir == "" {
88
+ return ""
89
+ }
90
+ entries, err := os.ReadDir(dir)
91
+ if err != nil {
92
+ return ""
93
+ }
94
+ var (
95
+ bestID string
96
+ bestAt time.Time
97
+ fallback string // 没有同 workspace 记录时的兜底(同为 dsh 的最近一条)
98
+ fallbackAt time.Time
99
+ )
100
+ for _, e := range entries {
101
+ name := e.Name()
102
+ if e.IsDir() || !strings.HasPrefix(name, "run-") || !strings.HasSuffix(name, ".json") {
103
+ continue
104
+ }
105
+ data, err := os.ReadFile(filepath.Join(dir, name))
106
+ if err != nil {
107
+ continue
108
+ }
109
+ var rec struct {
110
+ SessionID string `json:"session_id"`
111
+ Engine string `json:"engine"`
112
+ Workspace string `json:"workspace"`
113
+ UpdatedAt time.Time `json:"updated_at"`
114
+ }
115
+ if json.Unmarshal(data, &rec) != nil {
116
+ continue
117
+ }
118
+ if rec.Engine != "dsh" || strings.TrimSpace(rec.SessionID) == "" {
119
+ continue
120
+ }
121
+ if fallback == "" || rec.UpdatedAt.After(fallbackAt) {
122
+ fallback, fallbackAt = rec.SessionID, rec.UpdatedAt
123
+ }
124
+ if workspace != "" && strings.TrimSpace(rec.Workspace) != workspace {
125
+ continue
126
+ }
127
+ if bestID == "" || rec.UpdatedAt.After(bestAt) {
128
+ bestID, bestAt = rec.SessionID, rec.UpdatedAt
129
+ }
130
+ }
131
+ if bestID != "" {
132
+ return bestID
133
+ }
134
+ return fallback
135
+ }
136
+
137
+ // dshSessionsEnv 覆盖会话目录的环境变量(与 session.EnvDir 同名同义)。
138
+ const dshSessionsEnv = "MAGIC_AGENT_SESSIONS"
139
+
140
+ // dshSessionDir 会话日志 / 登记表目录(口径同 session.Dir())。
141
+ //
142
+ // ⚠️ 不直接调 internal/session:那个包**反向依赖** internal/agent
143
+ // (session.go 用了 agent 的类型),agent 再 import 它就成了环。
144
+ // 路径口径必须与 session.Dir() 保持一致,改一处要改另一处。
145
+ func dshSessionDir() string {
146
+ if p := strings.TrimSpace(os.Getenv(dshSessionsEnv)); p != "" {
147
+ return p
148
+ }
149
+ home, err := os.UserHomeDir()
150
+ if err != nil || home == "" {
151
+ return ""
152
+ }
153
+ return filepath.Join(home, ".magic-agent", "sessions")
154
+ }
155
+
156
+ // ── 能力探测:这台 dsh 的 headless 认不认 --session-id / --json ──
157
+
158
+ // dshHeadlessCaps headless 支持的两个 flag。
159
+ type dshHeadlessCaps struct {
160
+ SessionID bool // --session-id(0.1.7+ 才有;没有就当不了真续接)
161
+ JSON bool // --json(事件流;没有就走旧版 stderr 推理增量)
162
+ }
163
+
164
+ var dshCapsCache sync.Map // bin → dshHeadlessCaps
165
+
166
+ // dshHeadlessCapabilities 探测这台 dsh 支持哪些 headless flag(按 bin 路径缓存)。
167
+ //
168
+ // 为什么必须探测:dsh 启动器在**第一个它不认识的 token 处结束**解析,剩下的都当
169
+ // app 参数交给 headless —— 老版本不认这俩 flag 时,它们会被拼进**任务文本**
170
+ // (静默改写提示词),比直接报错难查得多。探测代价约一次 `--help`,一进程一次。
171
+ //
172
+ // ⚠️ 只有在输出**确实是 headless 的 help**(同时含 "Usage:" 与 "--profile")时才据它判断;
173
+ // 否则(--help 跑不起来、被包装脚本吞掉、输出被截断)一律按「都支持」处理 ——
174
+ // 「探测不到」不等于「不支持」,宁可让现代 dsh 正常工作,也不因一次探测抖动把续接判死。
175
+ func dshHeadlessCapabilities(ctx context.Context, bin string) dshHeadlessCaps {
176
+ if v, ok := dshCapsCache.Load(bin); ok {
177
+ return v.(dshHeadlessCaps)
178
+ }
179
+ caps := dshHeadlessCaps{SessionID: true, JSON: true}
180
+ if stdout, stderr, err := runCLI(ctx, bin, "--profile", "headless", "--help"); err == nil {
181
+ help := stdout + stderr
182
+ if strings.Contains(help, "Usage:") && strings.Contains(help, "--profile") {
183
+ caps.SessionID = strings.Contains(help, "--session-id")
184
+ caps.JSON = strings.Contains(help, "--json")
185
+ }
186
+ }
187
+ dshCapsCache.Store(bin, caps)
188
+ return caps
189
+ }
190
+
191
+ // ── `--json` 事件流(dsh-headless 的 run 投影)────────────────────
192
+
193
+ // dshJSONEvent `--json` 事件流的一行。
194
+ //
195
+ // 字段与 @deepseek-ai/dsh-headless 的 json-stream 投影逐一对齐(2026-10-09 读源码 +
196
+ // 实测样本核对),只声明本实现用到的部分:
197
+ //
198
+ // {"type":"session","sessionId":…,"cwd":…} 会话抬头(id 就在这)
199
+ // {"type":"status","phase":"turn_start|step_start|step_end|turn_end","turn":N,…}
200
+ // {"type":"thinking","text":…} 推理块(已提交)
201
+ // {"type":"text","text":…} 正文块(已提交,按 step)
202
+ // {"type":"tool_call","callId":…,"tool":…,"input":…} 工具调用(input 是**解析后**的 JSON)
203
+ // {"type":"tool_result","callId":…,"status":…,"result":…} 工具结果
204
+ // {"type":"final","text":…} 收尾正文(整轮汇总)
205
+ //
206
+ // 文本与推理都来自**已提交的 assistant/message**,不会是随后被重试/丢弃的尝试内容。
207
+ type dshJSONEvent struct {
208
+ Type string `json:"type"`
209
+ Text string `json:"text"`
210
+ Phase string `json:"phase"`
211
+ Turn int `json:"turn"`
212
+ Step int `json:"step"`
213
+ SessionID string `json:"sessionId"`
214
+ Cwd string `json:"cwd"`
215
+ CallID string `json:"callId"`
216
+ Tool string `json:"tool"`
217
+ Input json.RawMessage `json:"input"`
218
+ Status string `json:"status"`
219
+ Result string `json:"result"`
220
+ }
221
+
222
+ // parseDshJSONEvent 解析一行事件;空行 / 非 JSON / 认不出(无 type)都不算事件。
223
+ func parseDshJSONEvent(line string) (dshJSONEvent, bool) {
224
+ line = strings.TrimSpace(line)
225
+ if line == "" || line[0] != '{' {
226
+ return dshJSONEvent{}, false
227
+ }
228
+ var ev dshJSONEvent
229
+ if json.Unmarshal([]byte(line), &ev) != nil || ev.Type == "" {
230
+ return dshJSONEvent{}, false
231
+ }
232
+ return ev, true
233
+ }
234
+
235
+ // dshJSONArgs 把 tool_call 的 `input` 变成可放进 StreamEvent.Text 的字符串。
236
+ //
237
+ // dsh 给的是**解析后的** JSON(对象或原始字符串),而 SDK 通道给的是原始字符串 ——
238
+ // 两条通道最终都要「一段文本」,所以这里:JSON 字符串取内容,其余保留其 JSON 文本。
239
+ func dshJSONArgs(raw json.RawMessage) string {
240
+ s := strings.TrimSpace(string(raw))
241
+ if s == "" || s == "null" {
242
+ return ""
243
+ }
244
+ if s[0] == '"' {
245
+ var unquoted string
246
+ if json.Unmarshal(raw, &unquoted) == nil {
247
+ return unquoted
248
+ }
249
+ }
250
+ return s
251
+ }
252
+
253
+ // dshResumeUnsupportedHint 给「这台 dsh 不支持 --session-id」时的可执行结论。
254
+ func dshResumeUnsupportedHint(bin string) string {
255
+ return fmt.Sprintf("dsh: 这台 dsh 的 headless 不认 --session-id,无法按 id 续接(%s)。"+
256
+ "升级 dsh(`npm i -g @deepseek-ai/dsh`)后重试;需要多轮上下文也可以改用常驻会话:"+
257
+ "`magic-agent -e dsh --stream --keep-alive \"首轮\"` 再 `magic-agent --append <run_id|session_id> -p \"追加\"`", bin)
258
+ }
@@ -61,7 +61,6 @@ import (
61
61
  "fmt"
62
62
  "io"
63
63
  "os"
64
- "path/filepath"
65
64
  "strings"
66
65
  "sync"
67
66
  "time"
@@ -176,9 +175,43 @@ type dshSDKTextBlock struct {
176
175
  Text string `json:"text"`
177
176
  }
178
177
 
178
+ // dshSDKImageBlock = SdkEncodedImageBlock:prompt 里内联的图片(官方 d.ts 具名结构)。
179
+ //
180
+ // mimeType 只接受下面四种(协议原文),故调用方先按 MIME 过滤(见 dshNativeImageMIME):
181
+ // 图片以 base64 inline 进运行时自己的附件存储,模型真的能看到,不再依赖读文件工具。
182
+ type dshSDKImageBlock struct {
183
+ Type string `json:"type"` // 恒为 "image"
184
+ Data string `json:"data"` // 规范 base64 编码的位图字节
185
+ MimeType string `json:"mimeType"` // image/png | image/jpeg | image/webp | image/gif
186
+ }
187
+
188
+ // dshSDKPromptParams = SessionPromptParams。contentBlocks 允许混排
189
+ // 普通 ContentBlock(text 等)与待收纳的内联图片块 → 用 []any 承载。
179
190
  type dshSDKPromptParams struct {
180
- SessionID string `json:"sessionId"`
181
- ContentBlocks []dshSDKTextBlock `json:"contentBlocks"`
191
+ SessionID string `json:"sessionId"`
192
+ ContentBlocks []any `json:"contentBlocks"`
193
+ }
194
+
195
+ // dshPromptBlocks 组装一条 prompt 的 contentBlocks:正文 text 块 + 图片附件的原生 image 块。
196
+ //
197
+ // 为什么图片不再走「路径写进提示词」:能读图的模型(如 deepseek-v4.1-flash)配好模态后,
198
+ // SDK 的 `session/prompt` 直接接受 SdkEncodedImageBlock,图片以 base64 inline 进模型上下文
199
+ // —— 真识图。非图片附件(PDF / bmp / 文本…)协议里没有内联类型,仍由调用方写进提示词。
200
+ //
201
+ // 读盘失败返回错误(附件是调用方点名要发的东西,静默丢掉比失败更糟)。
202
+ func dshPromptBlocks(text string, atts []Attachment) ([]any, error) {
203
+ blocks := []any{dshSDKTextBlock{Type: "text", Text: text}}
204
+ for _, a := range atts {
205
+ if !dshNativeImageMIME(a.MIME) {
206
+ continue
207
+ }
208
+ data, err := readAttachmentBase64(a)
209
+ if err != nil {
210
+ return nil, err
211
+ }
212
+ blocks = append(blocks, dshSDKImageBlock{Type: "image", Data: data, MimeType: a.MIME})
213
+ }
214
+ return blocks, nil
182
215
  }
183
216
 
184
217
  // dshSDKSessionEvent = SessionEventNotification。
@@ -259,6 +292,10 @@ func dshSDKTurn(ctx context.Context, bin, workspace string, req Request, prompt
259
292
  }
260
293
 
261
294
  cmd := newStreamCmdIn(workspace, bin, []string{"--profile", dshSDKProfile})
295
+ /* 权限档位:dsh 没有逐调用的权限 flag,档位走子进程环境变量 —— shipped 的 sdk
296
+ profile 里 sandbox-policy.mode 与 user-approval.policy 都直接读它(见
297
+ permission.go::dshPermissionMode 的核实来源)。空档位 = 不注入,用 dsh 自己的默认。 */
298
+ cmd.Env = append(cmd.Env, dshPermissionEnv(req.Permission)...)
262
299
 
263
300
  /* 三条管道都自建(不直挂 bytes.Buffer / StdinPipe 的 stdout 侧):
264
301
  沿用 runcmd.go / stream.go 的同一条经验 —— Go 只在目标是 *os.File 时才不做内部
@@ -406,6 +443,13 @@ func dshSDKTurn(ctx context.Context, bin, workspace string, req Request, prompt
406
443
  // agent on it」),但 SDK 协议只有 initialize / session/prompt / shutdown 三个方法,
407
444
  // 没有暴露它 —— 只有 Web/TUI 那条 host API 用得上 resume。
408
445
  sessionID := newDshSessionID()
446
+ // 会话 id 由 magic-agent 生成 → **立刻**回报给调用方(「看历史」持久化靠它)。
447
+ // ⚠️ 必须在 session/prompt 之前:core CLI 的 sessionWriter 拿到 id 才会开文件,
448
+ // 报晚了本轮前几条事件就没有落点(历史里会缺开头)。dsh 是唯一一个
449
+ // id 由我们自造、而不是从 CLI 流里读出来的引擎(见 engine.go 的 OnSessionID)。
450
+ if req.OnSessionID != nil {
451
+ req.OnSessionID(sessionID)
452
+ }
409
453
  out := dshSDKTurnResult{Provider: provider, Model: model, SessionID: sessionID}
410
454
  callIndex := map[string]int{}
411
455
  promptIDs := map[int]bool{} // 已发出的 prompt 请求 id(用来把它的错误响应挑出来)
@@ -423,10 +467,10 @@ func dshSDKTurn(ctx context.Context, bin, workspace string, req Request, prompt
423
467
  onEvent(ev)
424
468
  }
425
469
  }
426
- sendPrompt := func(text string) error {
470
+ sendPrompt := func(blocks []any) error {
427
471
  id, serr := send("session/prompt", dshSDKPromptParams{
428
472
  SessionID: sessionID,
429
- ContentBlocks: []dshSDKTextBlock{{Type: "text", Text: text}},
473
+ ContentBlocks: blocks,
430
474
  })
431
475
  if serr != nil {
432
476
  return serr
@@ -436,6 +480,10 @@ func dshSDKTurn(ctx context.Context, bin, workspace string, req Request, prompt
436
480
  roundEnded = false
437
481
  return nil
438
482
  }
483
+ // textBlocks 单文本块(追加轮用:附件只随首轮发)。
484
+ textBlocks := func(text string) []any {
485
+ return []any{dshSDKTextBlock{Type: "text", Text: text}}
486
+ }
439
487
  // finish 收尾:先让读线程转入「只读不投递」,再发 shutdown(协议唯一的「结束」
440
488
  // 手段,没有 cancel 方法)。幂等 —— turn/end、session.status:idle、追加通道关闭
441
489
  // 三条收尾路径都可能走到。
@@ -466,7 +514,12 @@ func dshSDKTurn(ctx context.Context, bin, workspace string, req Request, prompt
466
514
  }
467
515
  return dshSDKTurnResult{}, false, nil
468
516
  }
469
- if err := sendPrompt(prompt); err != nil {
517
+ // 首轮带附件:图片走协议原生 image 块(非图片附件已由调用方写进 prompt 正文)。
518
+ initialBlocks, berr := dshPromptBlocks(prompt, req.Attachments)
519
+ if berr != nil {
520
+ return dshSDKTurnResult{}, fmt.Errorf("dsh sdk: 读附件: %v", berr)
521
+ }
522
+ if err := sendPrompt(initialBlocks); err != nil {
470
523
  return dshSDKTurnResult{}, fmt.Errorf("dsh sdk: 写 session/prompt: %v", err)
471
524
  }
472
525
 
@@ -494,7 +547,7 @@ func dshSDKTurn(ctx context.Context, bin, workspace string, req Request, prompt
494
547
  if strings.TrimSpace(text) == "" {
495
548
  continue
496
549
  }
497
- if err := sendPrompt(text); err != nil {
550
+ if err := sendPrompt(textBlocks(text)); err != nil {
498
551
  return out, fmt.Errorf("dsh sdk: 写 session/prompt(追加): %v", err)
499
552
  }
500
553
  case msg, ok := <-inbound:
@@ -656,18 +709,16 @@ func dshStderrTail(s string) string {
656
709
  return ": " + strings.TrimSpace(lines[len(lines)-1])
657
710
  }
658
711
 
659
- // DshConfiguredRoute 读 $DSH_HOME/settings.yaml 里 agent-default-model 的 (provider, model)。
660
- // SDK 的 initialize 两者都是必填;读不到就只能回退 headless。
712
+ // DshConfiguredRoute 读 dsh 配置里 agent-default-model 的 (provider, model)
713
+ // (位置随版本变:0.1.7+ 在 profile 的 cordis.patch.yml,更早在 settings.yaml,
714
+ // 见 dshConfigDocs)。SDK 的 initialize 两者都是必填;读不到就只能回退 headless。
661
715
  func DshConfiguredRoute() (string, string) {
662
- home := DshHome()
663
- if home == "" {
664
- return "", ""
665
- }
666
- data, err := os.ReadFile(filepath.Join(home, "settings.yaml"))
667
- if err != nil {
668
- return "", ""
716
+ for _, d := range dshConfigDocs() {
717
+ if p, m := parseDshDefaultModelRoute(d.legacyText()); p != "" || m != "" {
718
+ return p, m
719
+ }
669
720
  }
670
- return parseDshDefaultModelRoute(string(data))
721
+ return "", ""
671
722
  }
672
723
 
673
724
  // newDshSessionID 生成 SDK 侧会话 id。
@@ -19,6 +19,8 @@ package agent
19
19
 
20
20
  import (
21
21
  "context"
22
+ "encoding/base64"
23
+ "encoding/json"
22
24
  "errors"
23
25
  "os"
24
26
  "path/filepath"
@@ -137,9 +139,9 @@ func TestDshSDKIsDefaultTransport(t *testing.T) {
137
139
  if resp.Model != "modelverse/deepseek-v4.1-flash" {
138
140
  t.Errorf("Model = %q want modelverse/deepseek-v4.1-flash", resp.Model)
139
141
  }
140
- // dsh 续不上会话 → 不回可续接的 id(见 errDshNoSessionResume)。
142
+ // Complete 只回正文,不回会话 id(Stream 才回,见下一条用例)。
141
143
  if resp.SessionID != "" {
142
- t.Errorf("SessionID = %q want 空(dsh 不支持续接)", resp.SessionID)
144
+ t.Errorf("SessionID = %q want 空", resp.SessionID)
143
145
  }
144
146
  args := readFileString(t, argsLog)
145
147
  if !strings.Contains(args, "--profile sdk") {
@@ -150,6 +152,26 @@ func TestDshSDKIsDefaultTransport(t *testing.T) {
150
152
  }
151
153
  }
152
154
 
155
+ // 会话 id 由 magic-agent 自造 → 必须回报给调用方(「看历史」持久化靠它;
156
+ // 早先 dsh 从不调 OnSessionID,导致 dsh 会话根本不落盘)。
157
+ func TestDshSDKReportsSessionID(t *testing.T) {
158
+ dshSDKHome(t, "modelverse", "deepseek-v4.1-flash")
159
+ dir := t.TempDir()
160
+ bin, _, _ := dshSDKStub(t, dir, dshSDKStubOpts{})
161
+
162
+ var got []string
163
+ _, err := (&DshEngine{BinPath: bin}).Stream(context.Background(), Request{
164
+ Messages: []Message{{Role: "user", Content: "hi"}},
165
+ OnSessionID: func(id string) { got = append(got, id) },
166
+ }, nil)
167
+ if err != nil {
168
+ t.Fatalf("Stream: %v", err)
169
+ }
170
+ if len(got) == 0 || !strings.HasPrefix(got[0], "magic-agent-") {
171
+ t.Errorf("OnSessionID 应收到自造的会话 id, got %v", got)
172
+ }
173
+ }
174
+
153
175
  // 流式:推理 / 工具调用 / 工具结果 / 正文 / turn 收尾,全部按顺序上报。
154
176
  func TestDshSDKStreamEmitsAllEventKinds(t *testing.T) {
155
177
  dshSDKHome(t, "modelverse", "deepseek-v4.1-flash")
@@ -706,6 +728,84 @@ func TestDshSDKAppendSkipsBlank(t *testing.T) {
706
728
  }
707
729
  }
708
730
 
731
+ // 图片附件走协议原生 image 块(SdkEncodedImageBlock):base64 inline 进
732
+ // session/prompt 的 contentBlocks(真进模型上下文);非图片附件仍写进提示词正文
733
+ // (协议里没有通用文件内联类型,只能走路径,靠 dsh 自己的读文件工具看)。
734
+ func TestDshSDKImageAttachmentIsNativeBlock(t *testing.T) {
735
+ dshSDKHome(t, "modelverse", "deepseek-v4.1-flash")
736
+ dir := t.TempDir()
737
+ bin, _, stdinLog := dshSDKStub(t, dir, dshSDKStubOpts{})
738
+
739
+ img := writeTestPNG(t, "shot.png")
740
+ txt := writeTestFile(t, "notes.txt", "文件里的说明")
741
+
742
+ if _, err := (&DshEngine{BinPath: bin}).Complete(context.Background(), Request{
743
+ Messages: []Message{{Role: "user", Content: "这张图什么颜色"}},
744
+ Attachments: []Attachment{NewAttachment(img), NewAttachment(txt)},
745
+ }); err != nil {
746
+ t.Fatalf("Complete: %v", err)
747
+ }
748
+
749
+ type block struct {
750
+ Type string `json:"type"`
751
+ Text string `json:"text"`
752
+ Data string `json:"data"`
753
+ MimeType string `json:"mimeType"`
754
+ }
755
+ type frame struct {
756
+ Method string `json:"method"`
757
+ Params struct {
758
+ ContentBlocks []block `json:"contentBlocks"`
759
+ } `json:"params"`
760
+ }
761
+
762
+ var blocks []block
763
+ var sawPrompt bool
764
+ for _, ln := range strings.Split(readFileString(t, stdinLog), "\n") {
765
+ var f frame
766
+ if err := json.Unmarshal([]byte(ln), &f); err != nil {
767
+ continue
768
+ }
769
+ if f.Method == "session/prompt" {
770
+ blocks = append(blocks, f.Params.ContentBlocks...)
771
+ sawPrompt = true
772
+ }
773
+ }
774
+ if !sawPrompt {
775
+ t.Fatal("没有发出 session/prompt 请求")
776
+ }
777
+
778
+ var textBlock string
779
+ var images []string
780
+ for _, b := range blocks {
781
+ switch b.Type {
782
+ case "text":
783
+ textBlock = b.Text
784
+ case "image":
785
+ if b.MimeType != "image/png" {
786
+ t.Errorf("image 块 mimeType = %q want image/png", b.MimeType)
787
+ }
788
+ images = append(images, b.Data)
789
+ }
790
+ }
791
+ if len(images) != 1 {
792
+ t.Fatalf("image 块数 = %d want 1(只有那张 png 走原生)", len(images))
793
+ }
794
+ want, err := os.ReadFile(img)
795
+ if err != nil {
796
+ t.Fatal(err)
797
+ }
798
+ if images[0] != base64.StdEncoding.EncodeToString(want) {
799
+ t.Errorf("image 块 data 应是图片字节的 base64")
800
+ }
801
+ if strings.Contains(textBlock, "shot.png") {
802
+ t.Errorf("图片走原生块后不该再以路径写进正文: %q", textBlock)
803
+ }
804
+ if !strings.Contains(textBlock, "notes.txt") {
805
+ t.Errorf("非图片附件应写进提示词路径清单: %q", textBlock)
806
+ }
807
+ }
808
+
709
809
  // ── 小工具 ────────────────────────────────────────────────────
710
810
 
711
811
  func readFileString(t *testing.T, p string) string {