@mrrisega/dsh-remote 0.7.9 → 0.7.10-beta.10

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 (60) hide show
  1. package/README.md +9 -2
  2. package/clients/dsh-remote/agent/README.md +412 -0
  3. package/clients/dsh-remote/agent/agent-e2e-codex.mjs +185 -0
  4. package/clients/dsh-remote/agent/agent-e2e-real.mjs +443 -0
  5. package/clients/dsh-remote/agent/codex.mjs +736 -0
  6. package/clients/dsh-remote/agent/detect.mjs +399 -0
  7. package/clients/dsh-remote/agent/events.mjs +511 -0
  8. package/clients/dsh-remote/agent/gemini.mjs +503 -0
  9. package/clients/dsh-remote/agent/index.mjs +339 -0
  10. package/clients/dsh-remote/agent/opencode.mjs +628 -0
  11. package/clients/dsh-remote/agent/permission-bridge.mjs +534 -0
  12. package/clients/dsh-remote/agent/proc.mjs +179 -0
  13. package/clients/dsh-remote/agent/version.mjs +148 -0
  14. package/clients/dsh-remote/approval-coordinator.mjs +150 -0
  15. package/clients/dsh-remote/decision-poller.mjs +235 -0
  16. package/clients/dsh-remote/dsh-bridge.mjs +208 -1
  17. package/clients/dsh-remote/mobile-adapter.mjs +775 -11
  18. package/clients/dsh-remote/push-reporter.mjs +533 -0
  19. package/clients/dsh-remote/test/agent-codex.test.mjs +535 -0
  20. package/clients/dsh-remote/test/agent-detect.test.mjs +390 -0
  21. package/clients/dsh-remote/test/agent-events.test.mjs +340 -0
  22. package/clients/dsh-remote/test/agent-gemini.test.mjs +355 -0
  23. package/clients/dsh-remote/test/agent-jsonl-driver.test.mjs +165 -0
  24. package/clients/dsh-remote/test/agent-opencode.test.mjs +369 -0
  25. package/clients/dsh-remote/test/agent-permission-bridge.test.mjs +340 -0
  26. package/clients/dsh-remote/test/agent-proc.test.mjs +175 -0
  27. package/clients/dsh-remote/test/agent-runtime.test.mjs +282 -0
  28. package/clients/dsh-remote/test/agent-version.test.mjs +122 -0
  29. package/clients/dsh-remote/test/approval-coordinator.test.mjs +192 -0
  30. package/clients/dsh-remote/test/bridge-e2ee-reject-observable.test.mjs +90 -0
  31. package/clients/dsh-remote/test/decision-poller.test.mjs +328 -0
  32. package/clients/dsh-remote/test/mobile-adapter-guards.test.mjs +172 -0
  33. package/clients/dsh-remote/test/mobile-adapter-runtime.test.mjs +188 -4
  34. package/clients/dsh-remote/test/push-reporter.test.mjs +380 -0
  35. package/dsh-setup.mjs +5 -0
  36. package/package.json +2 -2
  37. package/packages/dsh-remote-web/lib/client.js +350 -8
  38. package/packages/dsh-remote-web/lib/index.js +26 -0
  39. package/packages/dsh-remote-web/package.json +1 -1
  40. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/README.md +412 -0
  41. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/agent-e2e-codex.mjs +185 -0
  42. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/agent-e2e-real.mjs +443 -0
  43. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/codex.mjs +736 -0
  44. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/detect.mjs +399 -0
  45. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/events.mjs +511 -0
  46. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/gemini.mjs +503 -0
  47. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/index.mjs +339 -0
  48. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/opencode.mjs +628 -0
  49. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/permission-bridge.mjs +534 -0
  50. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/proc.mjs +179 -0
  51. package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/version.mjs +148 -0
  52. package/packages/dsh-remote-web/runtime/clients/dsh-remote/approval-coordinator.mjs +150 -0
  53. package/packages/dsh-remote-web/runtime/clients/dsh-remote/decision-poller.mjs +235 -0
  54. package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +208 -1
  55. package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +775 -11
  56. package/packages/dsh-remote-web/runtime/clients/dsh-remote/push-reporter.mjs +533 -0
  57. package/packages/dsh-remote-web/runtime/dsh-setup.mjs +5 -0
  58. package/packages/dsh-remote-web/test/activation-entry.test.mjs +407 -0
  59. package/packages/dsh-remote-web/test/e2ee-reject-diag.test.mjs +70 -0
  60. package/packages/dsh-remote-web/test/setup-output.test.mjs +16 -0
@@ -0,0 +1,355 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Gemini CLI(ACP)适配契约测试(M2 第 5 周)。
4
+ *
5
+ * ## 夹具来源(全部官方,逐字摘录;ACP v1 文档)
6
+ *
7
+ * | 夹具 | 出处 |
8
+ * |---|---|
9
+ * | `session/update` 的 `agent_message_chunk` / `plan` / `notice` 样本 | <https://agentclientprotocol.com/protocol/v1/prompt-turn> |
10
+ * | `tool_call` / `tool_call_update` / `session/request_permission` 样本 | <https://agentclientprotocol.com/protocol/v1/tool-calls> |
11
+ * | `session/prompt` 响应 `{"result":{"stopReason":"end_turn"}}` | <https://agentclientprotocol.com/protocol/v1/prompt-turn> |
12
+ * | stopReason 闭集(end_turn/max_tokens/max_turn_requests/refusal/cancelled) | 同上 "Stop reasons" |
13
+ * | stdio 传输"一行一条 JSON-RPC" | <https://agentclientprotocol.com/protocol/v1/transports> |
14
+ * | 能力位(`--experimental-acp`) | 本机 `gemini --help`(`gemini 0.31.0`,Homebrew 安装,实测) |
15
+ *
16
+ * ## 本机探测
17
+ *
18
+ * 末尾条件用例:本机装了 gemini(0.31.0)就真跑 `gemini --version` + `gemini --help`(**不调模型**),
19
+ * 断言 ACP 能力位;没装则 SKIP。
20
+ *
21
+ * 用法: node --test clients/dsh-remote/test/agent-gemini.test.mjs
22
+ */
23
+ import assert from "node:assert/strict";
24
+ import { spawnSync as nodeSpawnSync } from "node:child_process";
25
+ import test from "node:test";
26
+
27
+ import {
28
+ ACP_SESSION_UPDATE_VARIANTS,
29
+ ACP_TOOL_KINDS,
30
+ GEMINI_BIN_ENV,
31
+ buildGeminiCandidates,
32
+ createGeminiDriver,
33
+ detectGemini,
34
+ mapGeminiLine,
35
+ mapGeminiObject,
36
+ stopReasonOf
37
+ } from "../agent/gemini.mjs";
38
+
39
+ const NOW = 1_700_000_000_000;
40
+ const at = () => NOW;
41
+ const line = (obj) => JSON.stringify(obj);
42
+
43
+ /** ACP JSON-RPC 通知外壳(官方 transports:一行一条 JSON-RPC)。 */
44
+ const notif = (method, params) => ({ jsonrpc: "2.0", method, params });
45
+ const sessionUpdate = (update) => notif("session/update", { sessionId: "sess_abc123def456", update });
46
+
47
+ test("ACP 词表:sessionUpdate 变体与工具 kind 与官方文档一致", () => {
48
+ assert.ok(ACP_SESSION_UPDATE_VARIANTS.includes("agent_message_chunk"));
49
+ assert.ok(ACP_SESSION_UPDATE_VARIANTS.includes("tool_call_update"));
50
+ assert.ok(ACP_SESSION_UPDATE_VARIANTS.includes("usage_update"));
51
+ assert.deepEqual([...ACP_TOOL_KINDS], ["read", "edit", "delete", "move", "search", "execute", "think", "fetch", "switch_mode", "other"]);
52
+ });
53
+
54
+ test("非 JSON-RPC 行 → ok:false(防止 stderr/日志混进 stdout)", () => {
55
+ assert.equal(mapGeminiObject({ type: "assistant" }, { now: at }).reason, "not-jsonrpc");
56
+ assert.equal(mapGeminiLine("hello", { now: at }).reason, "bad-json");
57
+ assert.equal(mapGeminiLine("", { now: at }).reason, "empty");
58
+ });
59
+
60
+ test("agent_message_chunk → message.delta(final:false)(ACP 是流式增量)", () => {
61
+ // 官方样本(prompt-turn):content 是单个 ContentBlock
62
+ const r = mapGeminiObject(sessionUpdate({
63
+ sessionUpdate: "agent_message_chunk",
64
+ messageId: "msg_agent_c42b9",
65
+ content: { type: "text", text: "I'll analyze your code for potential issues." }
66
+ }), { now: at });
67
+ assert.equal(r.ok, true);
68
+ const ev = r.events[0];
69
+ assert.equal(ev.type, "message.delta");
70
+ assert.equal(ev.final, false);
71
+ assert.match(ev.text, /analyze your code/);
72
+ });
73
+
74
+ test("agent_thought_chunk → status(thinking) 只带字节数(推理内容不落)", () => {
75
+ const r = mapGeminiObject(sessionUpdate({
76
+ sessionUpdate: "agent_thought_chunk",
77
+ content: { type: "text", text: "I should run rm -rf build" }
78
+ }), { now: at });
79
+ assert.equal(r.events[0].status, "thinking");
80
+ assert.ok(r.events[0].textBytes > 0);
81
+ assert.equal(JSON.stringify(r.events).includes("rm -rf"), false);
82
+ });
83
+
84
+ test("user_message_chunk → 忽略(不是 Agent 输出)", () => {
85
+ const r = mapGeminiObject(sessionUpdate({ sessionUpdate: "user_message_chunk", content: { type: "text", text: "hi" } }), { now: at });
86
+ assert.deepEqual(r, { ok: true, events: [] });
87
+ });
88
+
89
+ test("tool_call → tool.call(kind/status/输入字节数;title 不落)", () => {
90
+ // 官方样本(tool-calls):title "Reading configuration file"、name read_file、kind read、status pending
91
+ const r = mapGeminiObject(sessionUpdate({
92
+ sessionUpdate: "tool_call",
93
+ toolCallId: "call_001",
94
+ title: "Reading configuration file",
95
+ name: "read_file",
96
+ kind: "read",
97
+ status: "pending",
98
+ rawInput: { path: "/repo/config.yml" }
99
+ }), { now: at });
100
+ const ev = r.events[0];
101
+ assert.equal(ev.type, "tool.call");
102
+ assert.equal(ev.toolCallId, "call_001");
103
+ assert.equal(ev.name, "read_file");
104
+ assert.equal(ev.kind, "read");
105
+ assert.equal(ev.status, "pending");
106
+ assert.ok(ev.inputBytes > 0);
107
+ assert.equal(JSON.stringify(ev).includes("Reading configuration file"), false, "title 可能夹带命令,不落");
108
+ assert.equal(JSON.stringify(ev).includes("/repo/config.yml"), false, "rawInput 内容不落");
109
+ });
110
+
111
+ test("tool_call_update:in_progress 不产事件;completed/failed → tool.result", () => {
112
+ const progress = mapGeminiObject(sessionUpdate({
113
+ sessionUpdate: "tool_call_update", toolCallId: "call_001", status: "in_progress",
114
+ content: [{ type: "content", content: { type: "text", text: "Found 3 configuration files..." } }]
115
+ }), { now: at });
116
+ assert.deepEqual(progress, { ok: true, events: [] }, "in_progress 增量不产生事件(避免重复)");
117
+
118
+ const done = mapGeminiObject(sessionUpdate({
119
+ sessionUpdate: "tool_call_update", toolCallId: "call_001", status: "completed",
120
+ content: [{ type: "content", content: { type: "text", text: "Found 3 configuration files" } }]
121
+ }), { now: at });
122
+ assert.equal(done.events[0].type, "tool.result");
123
+ assert.equal(done.events[0].isError, false);
124
+ assert.match(done.events[0].outputPreview, /Found 3 configuration files/);
125
+
126
+ const failed = mapGeminiObject(sessionUpdate({
127
+ sessionUpdate: "tool_call_update", toolCallId: "call_002", status: "failed"
128
+ }), { now: at });
129
+ assert.equal(failed.events[0].isError, true);
130
+ });
131
+
132
+ test("plan → status(plan) 带条数/完成数", () => {
133
+ const r = mapGeminiObject(sessionUpdate({
134
+ sessionUpdate: "plan",
135
+ entries: [
136
+ { status: "completed", description: "a" },
137
+ { status: "in_progress", description: "b" }
138
+ ]
139
+ }), { now: at });
140
+ assert.equal(r.events[0].status, "plan");
141
+ assert.equal(r.events[0].planItems, 2);
142
+ assert.equal(r.events[0].planDone, 1);
143
+ });
144
+
145
+ test("notice / usage_update → status;available_commands_update 等 → 忽略而不是 unknown", () => {
146
+ assert.equal(mapGeminiObject(sessionUpdate({ sessionUpdate: "notice", message: "限流中" }), { now: at }).events[0].status, "notice");
147
+ assert.equal(mapGeminiObject(sessionUpdate({ sessionUpdate: "usage_update" }), { now: at }).events[0].status, "usage");
148
+ for (const v of ["available_commands_update", "current_mode_update", "config_option_update", "session_info_update", "compaction_update"]) {
149
+ const r = mapGeminiObject(sessionUpdate({ sessionUpdate: v }), { now: at });
150
+ assert.deepEqual(r, { ok: true, events: [] }, `${v} 应忽略`);
151
+ }
152
+ });
153
+
154
+ test("session/request_permission → status(waiting_approval):只暴露协议枚举,不暴露 title/命令", () => {
155
+ // 官方样本(tool-calls)
156
+ const r = mapGeminiObject({
157
+ jsonrpc: "2.0",
158
+ id: 5,
159
+ method: "session/request_permission",
160
+ params: {
161
+ sessionId: "sess_abc123def456",
162
+ toolCall: { toolCallId: "call_001", title: "Run bash -lc rm -rf build", kind: "execute" },
163
+ options: [
164
+ { optionId: "allow-once", name: "Allow once", kind: "allow_once" },
165
+ { optionId: "reject-once", name: "Reject", kind: "reject_once" }
166
+ ]
167
+ }
168
+ }, { now: at });
169
+ const ev = r.events[0];
170
+ assert.equal(ev.type, "status");
171
+ assert.equal(ev.status, "waiting_approval");
172
+ assert.equal(ev.requestId, "5", "JSON-RPC id 就是回执 id");
173
+ assert.equal(ev.toolCallId, "call_001");
174
+ assert.equal(ev.toolKind, "execute");
175
+ assert.deepEqual(ev.optionKinds, ["allow_once", "reject_once"]);
176
+ assert.equal(JSON.stringify(ev).includes("rm -rf"), false, "审批文案不得落命令原文");
177
+ });
178
+
179
+ test("session/prompt 响应:stopReason → turn.end(含全部 5 种)", () => {
180
+ const cases = {
181
+ end_turn: "completed",
182
+ max_tokens: "max-tokens",
183
+ max_turn_requests: "max-turns",
184
+ refusal: "refused",
185
+ cancelled: "aborted"
186
+ };
187
+ for (const [raw, mapped] of Object.entries(cases)) {
188
+ const r = mapGeminiObject({ jsonrpc: "2.0", id: 2, result: { stopReason: raw } }, { now: at });
189
+ assert.equal(r.events[0].type, "turn.end");
190
+ assert.equal(r.events[0].reason, mapped);
191
+ assert.equal(r.events[0].rawStopReason, raw);
192
+ assert.equal(stopReasonOf(raw), mapped);
193
+ }
194
+ });
195
+
196
+ test("JSON-RPC 错误响应 → status(error);未知 method → ok:false", () => {
197
+ const err = mapGeminiObject({ jsonrpc: "2.0", id: 2, error: { code: -32603, message: "internal" } }, { now: at });
198
+ assert.equal(err.events[0].status, "error");
199
+ assert.equal(err.events[0].scope, "jsonrpc");
200
+ assert.equal(err.events[0].message, "internal");
201
+
202
+ assert.equal(mapGeminiObject(notif("session/brand_new", {}), { now: at }).reason, "unknown-method:session/brand_new");
203
+ assert.equal(mapGeminiObject({ jsonrpc: "2.0" }, { now: at }).reason, "unknown-shape");
204
+ });
205
+
206
+ test("驱动:一条真实 ACP 流(多行 JSON-RPC)→ 5 类事件、unknown=0", () => {
207
+ const d = createGeminiDriver({ now: at });
208
+ const stream = [
209
+ line(notif("session/update", { sessionId: "s", update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "working…" } } })),
210
+ line(notif("session/update", { sessionId: "s", update: { sessionUpdate: "tool_call", toolCallId: "c1", name: "bash", kind: "execute", status: "pending", rawInput: { command: "ls -la" } } })),
211
+ line(notif("session/update", { sessionId: "s", update: { sessionUpdate: "tool_call_update", toolCallId: "c1", status: "completed", content: [{ type: "content", content: { type: "text", text: "a.txt" } }] } })),
212
+ line({ jsonrpc: "2.0", id: 2, result: { stopReason: "end_turn" } })
213
+ ];
214
+ const events = [];
215
+ for (const l of stream) events.push(...d.writeLine(l));
216
+ const s = d.stats();
217
+ assert.equal(s.driverName, "gemini-acp");
218
+ assert.equal(s.unknown, 0);
219
+ assert.equal(s.badJson, 0);
220
+ assert.deepEqual(events.map((e) => e.type), ["message.delta", "tool.call", "tool.result", "turn.end"]);
221
+ assert.deepEqual(events.map((e) => e.seq), [1, 2, 3, 4]);
222
+ assert.equal(JSON.stringify(events).includes("ls -la"), false, "命令原文不落");
223
+ });
224
+
225
+ // ── CLI 探测(假实现) ────────────────────────────────────────────
226
+
227
+ const HELP_WITH_EXPERIMENTAL = "Usage: gemini [options]\n --experimental-acp Starts the agent in ACP mode [boolean]\n --output-format <format>\n";
228
+ const HELP_WITH_ACP = "Usage: gemini [options]\n --acp Starts the agent in ACP mode\n";
229
+
230
+ function makeFs(overrides = {}) {
231
+ const enoent = () => {
232
+ throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
233
+ };
234
+ return { existsSync: () => false, statSync: () => enoent(), realpathSync: (p) => p, ...overrides };
235
+ }
236
+
237
+ function fakeSpawn({ version = "0.31.0", help = HELP_WITH_EXPERIMENTAL } = {}) {
238
+ return (command, args) => {
239
+ if (args.includes("--version")) return { status: 0, stdout: `${version}\n`, stderr: "" };
240
+ if (args.includes("--help")) return { status: 0, stdout: help, stderr: "" };
241
+ return { status: 0, stdout: "", stderr: "" };
242
+ };
243
+ }
244
+
245
+ test("候选:显式 env(.js→node)/ PATH 解软链 / npm 全局 bundle/gemini.js", () => {
246
+ const js = "/usr/lib/node_modules/@google/gemini-cli/bundle/gemini.js";
247
+ const c = buildGeminiCandidates({
248
+ env: { [GEMINI_BIN_ENV]: "/custom/gemini", PATH: "/usr/bin" },
249
+ platform: "linux",
250
+ execPath: "/usr/bin/node",
251
+ fs: makeFs({ statSync: () => ({ isFile: () => true }) }),
252
+ npmRoot: "/usr/lib/node_modules",
253
+ isExecutable: (p) => p === "/usr/bin/gemini" || p === js,
254
+ realpath: (p) => (p === "/usr/bin/gemini" ? "/cellar/gemini" : p)
255
+ });
256
+ assert.deepEqual(c.map((x) => x.kind), ["explicit", "path", "npm-node"]);
257
+ assert.equal(c[1].command, "/cellar/gemini");
258
+ assert.deepEqual(c[2].argsPrefix, [js]);
259
+ });
260
+
261
+ test("探测:ACP 可用(--experimental-acp)→ ok + mode=approval + experimental 警告", () => {
262
+ const r = detectGemini({
263
+ env: { [GEMINI_BIN_ENV]: "/fake/gemini", PATH: "" },
264
+ platform: "linux",
265
+ fs: makeFs(),
266
+ spawnSync: fakeSpawn(),
267
+ npmRoot: ""
268
+ });
269
+ assert.equal(r.ok, true);
270
+ assert.equal(r.version.text, "0.31.0");
271
+ assert.equal(r.caps.acp, true);
272
+ assert.equal(r.caps.acpFlag, "--experimental-acp");
273
+ assert.equal(r.caps.acpApproval, true);
274
+ assert.equal(r.mode, "approval");
275
+ assert.equal(r.approvalChannel, "acp-jsonrpc");
276
+ assert.ok(r.warnings.some((w) => w.code === "acp-experimental"));
277
+ });
278
+
279
+ test("探测:新 flag --acp 也算 ACP 可用(且不产生 experimental 警告)", () => {
280
+ const r = detectGemini({
281
+ env: { [GEMINI_BIN_ENV]: "/fake/gemini" },
282
+ platform: "linux",
283
+ fs: makeFs(),
284
+ spawnSync: fakeSpawn({ help: HELP_WITH_ACP }),
285
+ npmRoot: ""
286
+ });
287
+ assert.equal(r.caps.acp, true);
288
+ assert.equal(r.caps.acpFlag, "--acp");
289
+ assert.equal(r.warnings.some((w) => w.code === "acp-experimental"), false);
290
+ });
291
+
292
+ test("探测:无 ACP → notify-only;CLI 缺失 → cli-missing;版本认不出 → version-unknown", () => {
293
+ const noAcp = detectGemini({
294
+ env: { [GEMINI_BIN_ENV]: "/fake/gemini" },
295
+ platform: "linux",
296
+ fs: makeFs(),
297
+ spawnSync: fakeSpawn({ help: "Usage: gemini\n --output-format <format>" }),
298
+ npmRoot: ""
299
+ });
300
+ assert.equal(noAcp.caps.acp, false);
301
+ assert.equal(noAcp.mode, "notify-only");
302
+
303
+ const missing = detectGemini({ env: { PATH: "" }, platform: "linux", fs: makeFs(), spawnSync: fakeSpawn(), npmRoot: "" });
304
+ assert.equal(missing.status, "cli-missing");
305
+
306
+ const unknown = detectGemini({
307
+ env: { [GEMINI_BIN_ENV]: "/fake/gemini" },
308
+ platform: "linux",
309
+ fs: makeFs(),
310
+ spawnSync: () => ({ status: 0, stdout: "weird", stderr: "" }),
311
+ npmRoot: ""
312
+ });
313
+ assert.equal(unknown.status, "version-unknown");
314
+ });
315
+
316
+ test("探测:--help 失败不致命(ok,但 caps.acp=false + warning)", () => {
317
+ const r = detectGemini({
318
+ env: { [GEMINI_BIN_ENV]: "/fake/gemini" },
319
+ platform: "linux",
320
+ fs: makeFs(),
321
+ spawnSync: (command, args) => {
322
+ if (args.includes("--version")) return { status: 0, stdout: "0.31.0", stderr: "" };
323
+ return { status: 2, stdout: "", stderr: "help boom" };
324
+ },
325
+ npmRoot: ""
326
+ });
327
+ assert.equal(r.ok, true);
328
+ assert.equal(r.caps.acp, false);
329
+ assert.ok(r.warnings.some((w) => w.code === "caps-probe-failed"));
330
+ });
331
+
332
+ // ── 本机真实探测(装了就跑;不调模型) ─────────────────────────────
333
+
334
+ const REAL_GEMINI = (() => {
335
+ try {
336
+ const r = nodeSpawnSync("gemini", ["--version"], { encoding: "utf8", timeout: 8000, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
337
+ return r.status === 0 ? { ok: true, raw: String(r.stdout).trim() } : { ok: false, raw: String(r.stdout || r.stderr).trim() };
338
+ } catch {
339
+ return { ok: false, raw: "" };
340
+ }
341
+ })();
342
+
343
+ test(
344
+ "本机 gemini 真实探测(未安装则 SKIP):版本 + ACP 能力位",
345
+ { skip: REAL_GEMINI.ok ? false : `本机未安装 gemini CLI(${REAL_GEMINI.raw || "无"})` },
346
+ () => {
347
+ const r = detectGemini({ env: { ...process.env, [GEMINI_BIN_ENV]: "" }, platform: process.platform, npmRoot: "" });
348
+ assert.equal(r.ok, true, `探测失败: ${JSON.stringify(r.attempts || r.reason)}`);
349
+ assert.match(r.version.text, /^\d+\.\d+\.\d+$/, "版本号必须能解析");
350
+ assert.equal(r.caps.acp, true, "本机 help 里应有 ACP 开关");
351
+ assert.ok(["--acp", "--experimental-acp"].includes(r.caps.acpFlag));
352
+ // 本机 0.31.0 实测是 --experimental-acp
353
+ assert.equal(r.caps.acpFlag, "--experimental-acp", "本机 0.31.0 实测 ACP 开关名");
354
+ }
355
+ );
@@ -0,0 +1,165 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 通用 jsonl 驱动契约测试(M2 第 4 周)—— 证明"驱动不认识任何一家 CLI"。
4
+ *
5
+ * 用一个**玩具 mapLine**(不是 Claude、不是 Codex)来验证驱动的全部通用能力:
6
+ * 分帧(空行/超长)→ 坏 JSON → mapLine → 信封(v/seq)→ sink/onEvent/onUnknown。
7
+ *
8
+ * 这条用例是"接第二家只换 mapLine"这个架构承诺的**可执行证据**:
9
+ * 驱动里没有任何 harness 专有分支,换 mapper 就能跑另一家。
10
+ *
11
+ * 用法: node --test clients/dsh-remote/test/agent-jsonl-driver.test.mjs
12
+ */
13
+ import assert from "node:assert/strict";
14
+ import fsSync from "node:fs";
15
+ import os from "node:os";
16
+ import path from "node:path";
17
+ import test from "node:test";
18
+
19
+ import { createJsonlDriver, createJsonlSink, frameLine, parseLineJson } from "../agent/events.mjs";
20
+
21
+ const NOW = 1_700_000_000_000;
22
+
23
+ /** 玩具 mapper:只有 ping/end/boom 三种行为,与任何真实 CLI 无关。 */
24
+ const toyMap = (obj, ctx) => {
25
+ if (obj.type === "ping") {
26
+ return { ok: true, events: [{ v: 1, type: "status", at: ctx.now(), status: "working" }] };
27
+ }
28
+ if (obj.type === "end") {
29
+ return { ok: true, events: [{ v: 1, type: "turn.end", at: ctx.now(), reason: obj.reason || "completed" }] };
30
+ }
31
+ if (obj.type === "boom") throw new Error("mapper exploded");
32
+ return { ok: false, reason: `unknown-type:${obj.type}` };
33
+ };
34
+
35
+ test("驱动可以完全脱离 Claude/Codex 使用(玩具 mapper 就能跑)", () => {
36
+ const d = createJsonlDriver({ mapLine: toyMap, now: () => NOW, driverName: "toy" });
37
+ const out = d.writeLine('{"type":"ping"}');
38
+ assert.equal(out.length, 1);
39
+ assert.equal(out[0].type, "status");
40
+ assert.equal(out[0].at, NOW);
41
+ assert.equal(out[0].v, 1);
42
+ assert.equal(out[0].seq, 1);
43
+ assert.equal(d.stats().driverName, "toy");
44
+ });
45
+
46
+ test("seq 跨行、跨事件连续自增", () => {
47
+ const d = createJsonlDriver({ mapLine: toyMap, now: () => NOW });
48
+ d.writeLine('{"type":"ping"}');
49
+ d.writeLine('{"type":"end"}');
50
+ d.writeLine('{"type":"ping"}');
51
+ const events = [];
52
+ const d2 = createJsonlDriver({ mapLine: toyMap, now: () => NOW, onEvent: (e) => events.push(e) });
53
+ d2.writeLine('{"type":"ping"}');
54
+ d2.writeLine('{"type":"end"}');
55
+ assert.deepEqual(events.map((e) => e.seq), [1, 2]);
56
+ });
57
+
58
+ test("分帧:空行计 empty、超长行计 tooLong、坏 JSON 计 badJson —— 都不抛错", () => {
59
+ const d = createJsonlDriver({ mapLine: toyMap, now: () => NOW, maxLineBytes: 50 });
60
+ assert.deepEqual(d.writeLine(" "), []);
61
+ assert.deepEqual(d.writeLine(JSON.stringify({ type: "ping", pad: "x".repeat(100) })), []);
62
+ assert.deepEqual(d.writeLine("{not json"), []);
63
+ const s = d.stats();
64
+ assert.equal(s.empty, 1);
65
+ assert.equal(s.tooLong, 1);
66
+ assert.equal(s.badJson, 1);
67
+ assert.equal(s.events, 0);
68
+ });
69
+
70
+ test("未知形状:计数 + onUnknown 回调(拿得到原因与原始行)", () => {
71
+ const seen = [];
72
+ const d = createJsonlDriver({
73
+ mapLine: toyMap,
74
+ now: () => NOW,
75
+ onUnknown: (reason, raw) => seen.push({ reason, raw })
76
+ });
77
+ d.writeLine('{"type":"mystery","x":1}');
78
+ const s = d.stats();
79
+ assert.equal(s.unknown, 1);
80
+ assert.deepEqual(s.unknownReasons, { "unknown-type:mystery": 1 });
81
+ assert.equal(seen.length, 1);
82
+ assert.match(seen[0].reason, /unknown-type/);
83
+ assert.match(seen[0].raw, /mystery/);
84
+ });
85
+
86
+ test("mapLine 自己抛错 → 兜住,记 mapperErrors,不向上抛", () => {
87
+ let called = 0;
88
+ const d = createJsonlDriver({
89
+ mapLine: toyMap,
90
+ now: () => NOW,
91
+ onUnknown: () => {
92
+ called += 1;
93
+ }
94
+ });
95
+ assert.doesNotThrow(() => d.writeLine('{"type":"boom"}'));
96
+ const s = d.stats();
97
+ assert.equal(s.mapperErrors, 1);
98
+ assert.equal(s.unknown, 1);
99
+ assert.match(Object.keys(s.unknownReasons)[0], /mapper-error/);
100
+ assert.equal(called, 1);
101
+ });
102
+
103
+ test("onUnknown / onEvent 抛错也不影响后续采集", () => {
104
+ const d = createJsonlDriver({
105
+ mapLine: toyMap,
106
+ now: () => NOW,
107
+ onUnknown: () => {
108
+ throw new Error("回调炸了");
109
+ },
110
+ onEvent: () => {
111
+ throw new Error("回调炸了");
112
+ }
113
+ });
114
+ assert.doesNotThrow(() => d.writeLine('{"type":"nope"}'));
115
+ assert.doesNotThrow(() => d.writeLine('{"type":"ping"}'));
116
+ assert.equal(d.stats().events, 1);
117
+ });
118
+
119
+ test("sink 串联:事件按序落盘;sink 未启用时静默跳过", () => {
120
+ const dir = fsSync.mkdtempSync(path.join(os.tmpdir(), "jsonl-driver-"));
121
+ try {
122
+ const off = createJsonlSink({});
123
+ const d0 = createJsonlDriver({ mapLine: toyMap, sink: off, now: () => NOW });
124
+ assert.doesNotThrow(() => d0.writeLine('{"type":"ping"}'));
125
+ assert.equal(off.stats().events, 0);
126
+
127
+ const file = path.join(dir, "e.jsonl");
128
+ const sink = createJsonlSink({ filePath: file });
129
+ const d = createJsonlDriver({ mapLine: toyMap, sink, now: () => NOW });
130
+ d.writeLine('{"type":"ping"}');
131
+ d.writeLine('{"type":"end"}');
132
+ const lines = fsSync.readFileSync(file, "utf8").trim().split("\n").map((l) => JSON.parse(l));
133
+ assert.deepEqual(lines.map((x) => x.type), ["status", "turn.end"]);
134
+ assert.deepEqual(lines.map((x) => x.seq), [1, 2]);
135
+ } finally {
136
+ fsSync.rmSync(dir, { recursive: true, force: true });
137
+ }
138
+ });
139
+
140
+ test("落盘失败静默:驱动照常返回事件(磁盘满不能影响会话)", () => {
141
+ const sink = createJsonlSink({
142
+ filePath: "/nope/x.jsonl",
143
+ fs: {
144
+ appendFileSync: () => {
145
+ throw Object.assign(new Error("ENOSPC"), { code: "ENOSPC" });
146
+ }
147
+ }
148
+ });
149
+ const d = createJsonlDriver({ mapLine: toyMap, sink, now: () => NOW });
150
+ const out = d.writeLine('{"type":"ping"}');
151
+ assert.equal(out.length, 1, "事件仍需产出");
152
+ assert.equal(sink.stats().writeErrors, 1);
153
+ });
154
+
155
+ test("缺少 mapLine 在创建时立即报错(配置错误不要拖到运行期)", () => {
156
+ assert.throws(() => createJsonlDriver({}), /缺少 mapLine/);
157
+ });
158
+
159
+ test("frameLine / parseLineJson:纯函数契约", () => {
160
+ assert.deepEqual(frameLine(" "), { ok: false, reason: "empty" });
161
+ assert.equal(frameLine("{}", { maxLineBytes: 1 }).reason, "too-long");
162
+ assert.equal(parseLineJson("{bad").reason, "bad-json");
163
+ assert.equal(parseLineJson("42").reason, "not-object");
164
+ assert.equal(parseLineJson('{"a":1}').obj.a, 1);
165
+ });