@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.
- package/README.md +9 -2
- package/clients/dsh-remote/agent/README.md +412 -0
- package/clients/dsh-remote/agent/agent-e2e-codex.mjs +185 -0
- package/clients/dsh-remote/agent/agent-e2e-real.mjs +443 -0
- package/clients/dsh-remote/agent/codex.mjs +736 -0
- package/clients/dsh-remote/agent/detect.mjs +399 -0
- package/clients/dsh-remote/agent/events.mjs +511 -0
- package/clients/dsh-remote/agent/gemini.mjs +503 -0
- package/clients/dsh-remote/agent/index.mjs +339 -0
- package/clients/dsh-remote/agent/opencode.mjs +628 -0
- package/clients/dsh-remote/agent/permission-bridge.mjs +534 -0
- package/clients/dsh-remote/agent/proc.mjs +179 -0
- package/clients/dsh-remote/agent/version.mjs +148 -0
- package/clients/dsh-remote/approval-coordinator.mjs +150 -0
- package/clients/dsh-remote/decision-poller.mjs +235 -0
- package/clients/dsh-remote/dsh-bridge.mjs +208 -1
- package/clients/dsh-remote/mobile-adapter.mjs +775 -11
- package/clients/dsh-remote/push-reporter.mjs +533 -0
- package/clients/dsh-remote/test/agent-codex.test.mjs +535 -0
- package/clients/dsh-remote/test/agent-detect.test.mjs +390 -0
- package/clients/dsh-remote/test/agent-events.test.mjs +340 -0
- package/clients/dsh-remote/test/agent-gemini.test.mjs +355 -0
- package/clients/dsh-remote/test/agent-jsonl-driver.test.mjs +165 -0
- package/clients/dsh-remote/test/agent-opencode.test.mjs +369 -0
- package/clients/dsh-remote/test/agent-permission-bridge.test.mjs +340 -0
- package/clients/dsh-remote/test/agent-proc.test.mjs +175 -0
- package/clients/dsh-remote/test/agent-runtime.test.mjs +282 -0
- package/clients/dsh-remote/test/agent-version.test.mjs +122 -0
- package/clients/dsh-remote/test/approval-coordinator.test.mjs +192 -0
- package/clients/dsh-remote/test/bridge-e2ee-reject-observable.test.mjs +90 -0
- package/clients/dsh-remote/test/decision-poller.test.mjs +328 -0
- package/clients/dsh-remote/test/mobile-adapter-guards.test.mjs +172 -0
- package/clients/dsh-remote/test/mobile-adapter-runtime.test.mjs +188 -4
- package/clients/dsh-remote/test/push-reporter.test.mjs +380 -0
- package/dsh-setup.mjs +5 -0
- package/package.json +2 -2
- package/packages/dsh-remote-web/lib/client.js +350 -8
- package/packages/dsh-remote-web/lib/index.js +26 -0
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/README.md +412 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/agent-e2e-codex.mjs +185 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/agent-e2e-real.mjs +443 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/codex.mjs +736 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/detect.mjs +399 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/events.mjs +511 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/gemini.mjs +503 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/index.mjs +339 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/opencode.mjs +628 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/permission-bridge.mjs +534 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/proc.mjs +179 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/version.mjs +148 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/approval-coordinator.mjs +150 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/decision-poller.mjs +235 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +208 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +775 -11
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/push-reporter.mjs +533 -0
- package/packages/dsh-remote-web/runtime/dsh-setup.mjs +5 -0
- package/packages/dsh-remote-web/test/activation-entry.test.mjs +407 -0
- package/packages/dsh-remote-web/test/e2ee-reject-diag.test.mjs +70 -0
- 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
|
+
});
|