leduo-patrol 2.2.1 → 2.2.4
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 +11 -2
- package/dist/server/__tests__/acp-session.test.js +115 -0
- package/dist/server/__tests__/activity-monitor.test.js +13 -1
- package/dist/server/__tests__/session-manager.test.js +380 -1
- package/dist/server/acp-session.js +476 -0
- package/dist/server/activity-monitor.js +22 -7
- package/dist/server/index.js +57 -1
- package/dist/server/session-manager.js +1301 -121
- package/dist/web/assets/index-Bll9nc_X.js +21 -0
- package/dist/web/assets/index-y1qgSOLv.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +3 -1
- package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/LICENSE +191 -0
- package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/README.md +53 -0
- package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/acp.d.ts +823 -0
- package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/acp.js +965 -0
- package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/acp.js.map +1 -0
- package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/acp.test.d.ts +1 -0
- package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/acp.test.js +839 -0
- package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/acp.test.js.map +1 -0
- package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/examples/agent.d.ts +2 -0
- package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/examples/agent.js +225 -0
- package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/examples/agent.js.map +1 -0
- package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/examples/client.d.ts +2 -0
- package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/examples/client.js +130 -0
- package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/examples/client.js.map +1 -0
- package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/jsonrpc.d.ts +35 -0
- package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/jsonrpc.js +5 -0
- package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/jsonrpc.js.map +1 -0
- package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/schema/index.d.ts +27 -0
- package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/schema/index.js +28 -0
- package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/schema/index.js.map +1 -0
- package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/schema/types.gen.d.ts +2870 -0
- package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/schema/types.gen.js +3 -0
- package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/schema/types.gen.js.map +1 -0
- package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/schema/zod.gen.d.ts +5333 -0
- package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/schema/zod.gen.js +1554 -0
- package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/schema/zod.gen.js.map +1 -0
- package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/stream.d.ts +24 -0
- package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/stream.js +64 -0
- package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/dist/stream.js.map +1 -0
- package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/package.json +66 -0
- package/vendor/claude-code-acp/node_modules/@agentclientprotocol/sdk/schema/schema.json +4125 -0
- package/vendor/claude-code-acp/node_modules/@types/node/LICENSE +21 -0
- package/vendor/claude-code-acp/node_modules/@types/node/README.md +15 -0
- package/vendor/claude-code-acp/node_modules/@types/node/assert/strict.d.ts +105 -0
- package/vendor/claude-code-acp/node_modules/@types/node/assert.d.ts +955 -0
- package/vendor/claude-code-acp/node_modules/@types/node/async_hooks.d.ts +623 -0
- package/vendor/claude-code-acp/node_modules/@types/node/buffer.buffer.d.ts +466 -0
- package/vendor/claude-code-acp/node_modules/@types/node/buffer.d.ts +1810 -0
- package/vendor/claude-code-acp/node_modules/@types/node/child_process.d.ts +1428 -0
- package/vendor/claude-code-acp/node_modules/@types/node/cluster.d.ts +486 -0
- package/vendor/claude-code-acp/node_modules/@types/node/compatibility/iterators.d.ts +21 -0
- package/vendor/claude-code-acp/node_modules/@types/node/console.d.ts +151 -0
- package/vendor/claude-code-acp/node_modules/@types/node/constants.d.ts +20 -0
- package/vendor/claude-code-acp/node_modules/@types/node/crypto.d.ts +4065 -0
- package/vendor/claude-code-acp/node_modules/@types/node/dgram.d.ts +564 -0
- package/vendor/claude-code-acp/node_modules/@types/node/diagnostics_channel.d.ts +576 -0
- package/vendor/claude-code-acp/node_modules/@types/node/dns/promises.d.ts +503 -0
- package/vendor/claude-code-acp/node_modules/@types/node/dns.d.ts +922 -0
- package/vendor/claude-code-acp/node_modules/@types/node/domain.d.ts +166 -0
- package/vendor/claude-code-acp/node_modules/@types/node/events.d.ts +1054 -0
- package/vendor/claude-code-acp/node_modules/@types/node/fs/promises.d.ts +1329 -0
- package/vendor/claude-code-acp/node_modules/@types/node/fs.d.ts +4676 -0
- package/vendor/claude-code-acp/node_modules/@types/node/globals.d.ts +150 -0
- package/vendor/claude-code-acp/node_modules/@types/node/globals.typedarray.d.ts +101 -0
- package/vendor/claude-code-acp/node_modules/@types/node/http.d.ts +2167 -0
- package/vendor/claude-code-acp/node_modules/@types/node/http2.d.ts +2480 -0
- package/vendor/claude-code-acp/node_modules/@types/node/https.d.ts +405 -0
- package/vendor/claude-code-acp/node_modules/@types/node/index.d.ts +115 -0
- package/vendor/claude-code-acp/node_modules/@types/node/inspector/promises.d.ts +41 -0
- package/vendor/claude-code-acp/node_modules/@types/node/inspector.d.ts +224 -0
- package/vendor/claude-code-acp/node_modules/@types/node/inspector.generated.d.ts +4226 -0
- package/vendor/claude-code-acp/node_modules/@types/node/module.d.ts +819 -0
- package/vendor/claude-code-acp/node_modules/@types/node/net.d.ts +933 -0
- package/vendor/claude-code-acp/node_modules/@types/node/os.d.ts +507 -0
- package/vendor/claude-code-acp/node_modules/@types/node/package.json +155 -0
- package/vendor/claude-code-acp/node_modules/@types/node/path/posix.d.ts +8 -0
- package/vendor/claude-code-acp/node_modules/@types/node/path/win32.d.ts +8 -0
- package/vendor/claude-code-acp/node_modules/@types/node/path.d.ts +187 -0
- package/vendor/claude-code-acp/node_modules/@types/node/perf_hooks.d.ts +643 -0
- package/vendor/claude-code-acp/node_modules/@types/node/process.d.ts +2161 -0
- package/vendor/claude-code-acp/node_modules/@types/node/punycode.d.ts +117 -0
- package/vendor/claude-code-acp/node_modules/@types/node/querystring.d.ts +152 -0
- package/vendor/claude-code-acp/node_modules/@types/node/quic.d.ts +910 -0
- package/vendor/claude-code-acp/node_modules/@types/node/readline/promises.d.ts +161 -0
- package/vendor/claude-code-acp/node_modules/@types/node/readline.d.ts +541 -0
- package/vendor/claude-code-acp/node_modules/@types/node/repl.d.ts +415 -0
- package/vendor/claude-code-acp/node_modules/@types/node/sea.d.ts +162 -0
- package/vendor/claude-code-acp/node_modules/@types/node/sqlite.d.ts +955 -0
- package/vendor/claude-code-acp/node_modules/@types/node/stream/consumers.d.ts +38 -0
- package/vendor/claude-code-acp/node_modules/@types/node/stream/promises.d.ts +211 -0
- package/vendor/claude-code-acp/node_modules/@types/node/stream/web.d.ts +296 -0
- package/vendor/claude-code-acp/node_modules/@types/node/stream.d.ts +1760 -0
- package/vendor/claude-code-acp/node_modules/@types/node/string_decoder.d.ts +67 -0
- package/vendor/claude-code-acp/node_modules/@types/node/test/reporters.d.ts +96 -0
- package/vendor/claude-code-acp/node_modules/@types/node/test.d.ts +2240 -0
- package/vendor/claude-code-acp/node_modules/@types/node/timers/promises.d.ts +108 -0
- package/vendor/claude-code-acp/node_modules/@types/node/timers.d.ts +159 -0
- package/vendor/claude-code-acp/node_modules/@types/node/tls.d.ts +1198 -0
- package/vendor/claude-code-acp/node_modules/@types/node/trace_events.d.ts +197 -0
- package/vendor/claude-code-acp/node_modules/@types/node/ts5.6/buffer.buffer.d.ts +462 -0
- package/vendor/claude-code-acp/node_modules/@types/node/ts5.6/compatibility/float16array.d.ts +71 -0
- package/vendor/claude-code-acp/node_modules/@types/node/ts5.6/globals.typedarray.d.ts +36 -0
- package/vendor/claude-code-acp/node_modules/@types/node/ts5.6/index.d.ts +117 -0
- package/vendor/claude-code-acp/node_modules/@types/node/ts5.7/compatibility/float16array.d.ts +72 -0
- package/vendor/claude-code-acp/node_modules/@types/node/ts5.7/index.d.ts +117 -0
- package/vendor/claude-code-acp/node_modules/@types/node/tty.d.ts +250 -0
- package/vendor/claude-code-acp/node_modules/@types/node/url.d.ts +519 -0
- package/vendor/claude-code-acp/node_modules/@types/node/util/types.d.ts +558 -0
- package/vendor/claude-code-acp/node_modules/@types/node/util.d.ts +1662 -0
- package/vendor/claude-code-acp/node_modules/@types/node/v8.d.ts +983 -0
- package/vendor/claude-code-acp/node_modules/@types/node/vm.d.ts +1208 -0
- package/vendor/claude-code-acp/node_modules/@types/node/wasi.d.ts +202 -0
- package/vendor/claude-code-acp/node_modules/@types/node/web-globals/abortcontroller.d.ts +59 -0
- package/vendor/claude-code-acp/node_modules/@types/node/web-globals/blob.d.ts +23 -0
- package/vendor/claude-code-acp/node_modules/@types/node/web-globals/console.d.ts +9 -0
- package/vendor/claude-code-acp/node_modules/@types/node/web-globals/crypto.d.ts +39 -0
- package/vendor/claude-code-acp/node_modules/@types/node/web-globals/domexception.d.ts +68 -0
- package/vendor/claude-code-acp/node_modules/@types/node/web-globals/encoding.d.ts +11 -0
- package/vendor/claude-code-acp/node_modules/@types/node/web-globals/events.d.ts +106 -0
- package/vendor/claude-code-acp/node_modules/@types/node/web-globals/fetch.d.ts +69 -0
- package/vendor/claude-code-acp/node_modules/@types/node/web-globals/importmeta.d.ts +13 -0
- package/vendor/claude-code-acp/node_modules/@types/node/web-globals/messaging.d.ts +23 -0
- package/vendor/claude-code-acp/node_modules/@types/node/web-globals/navigator.d.ts +25 -0
- package/vendor/claude-code-acp/node_modules/@types/node/web-globals/performance.d.ts +45 -0
- package/vendor/claude-code-acp/node_modules/@types/node/web-globals/storage.d.ts +24 -0
- package/vendor/claude-code-acp/node_modules/@types/node/web-globals/streams.d.ts +115 -0
- package/vendor/claude-code-acp/node_modules/@types/node/web-globals/timers.d.ts +44 -0
- package/vendor/claude-code-acp/node_modules/@types/node/web-globals/url.d.ts +24 -0
- package/vendor/claude-code-acp/node_modules/@types/node/worker_threads.d.ts +717 -0
- package/vendor/claude-code-acp/node_modules/@types/node/zlib.d.ts +618 -0
- package/vendor/claude-code-acp/node_modules/undici-types/LICENSE +21 -0
- package/vendor/claude-code-acp/node_modules/undici-types/README.md +6 -0
- package/vendor/claude-code-acp/node_modules/undici-types/agent.d.ts +32 -0
- package/vendor/claude-code-acp/node_modules/undici-types/api.d.ts +43 -0
- package/vendor/claude-code-acp/node_modules/undici-types/balanced-pool.d.ts +29 -0
- package/vendor/claude-code-acp/node_modules/undici-types/cache-interceptor.d.ts +172 -0
- package/vendor/claude-code-acp/node_modules/undici-types/cache.d.ts +36 -0
- package/vendor/claude-code-acp/node_modules/undici-types/client-stats.d.ts +15 -0
- package/vendor/claude-code-acp/node_modules/undici-types/client.d.ts +108 -0
- package/vendor/claude-code-acp/node_modules/undici-types/connector.d.ts +34 -0
- package/vendor/claude-code-acp/node_modules/undici-types/content-type.d.ts +21 -0
- package/vendor/claude-code-acp/node_modules/undici-types/cookies.d.ts +30 -0
- package/vendor/claude-code-acp/node_modules/undici-types/diagnostics-channel.d.ts +74 -0
- package/vendor/claude-code-acp/node_modules/undici-types/dispatcher.d.ts +276 -0
- package/vendor/claude-code-acp/node_modules/undici-types/env-http-proxy-agent.d.ts +22 -0
- package/vendor/claude-code-acp/node_modules/undici-types/errors.d.ts +161 -0
- package/vendor/claude-code-acp/node_modules/undici-types/eventsource.d.ts +66 -0
- package/vendor/claude-code-acp/node_modules/undici-types/fetch.d.ts +211 -0
- package/vendor/claude-code-acp/node_modules/undici-types/formdata.d.ts +108 -0
- package/vendor/claude-code-acp/node_modules/undici-types/global-dispatcher.d.ts +9 -0
- package/vendor/claude-code-acp/node_modules/undici-types/global-origin.d.ts +7 -0
- package/vendor/claude-code-acp/node_modules/undici-types/h2c-client.d.ts +73 -0
- package/vendor/claude-code-acp/node_modules/undici-types/handlers.d.ts +15 -0
- package/vendor/claude-code-acp/node_modules/undici-types/header.d.ts +160 -0
- package/vendor/claude-code-acp/node_modules/undici-types/index.d.ts +80 -0
- package/vendor/claude-code-acp/node_modules/undici-types/interceptors.d.ts +39 -0
- package/vendor/claude-code-acp/node_modules/undici-types/mock-agent.d.ts +68 -0
- package/vendor/claude-code-acp/node_modules/undici-types/mock-call-history.d.ts +111 -0
- package/vendor/claude-code-acp/node_modules/undici-types/mock-client.d.ts +27 -0
- package/vendor/claude-code-acp/node_modules/undici-types/mock-errors.d.ts +12 -0
- package/vendor/claude-code-acp/node_modules/undici-types/mock-interceptor.d.ts +94 -0
- package/vendor/claude-code-acp/node_modules/undici-types/mock-pool.d.ts +27 -0
- package/vendor/claude-code-acp/node_modules/undici-types/package.json +55 -0
- package/vendor/claude-code-acp/node_modules/undici-types/patch.d.ts +29 -0
- package/vendor/claude-code-acp/node_modules/undici-types/pool-stats.d.ts +19 -0
- package/vendor/claude-code-acp/node_modules/undici-types/pool.d.ts +41 -0
- package/vendor/claude-code-acp/node_modules/undici-types/proxy-agent.d.ts +29 -0
- package/vendor/claude-code-acp/node_modules/undici-types/readable.d.ts +68 -0
- package/vendor/claude-code-acp/node_modules/undici-types/retry-agent.d.ts +8 -0
- package/vendor/claude-code-acp/node_modules/undici-types/retry-handler.d.ts +125 -0
- package/vendor/claude-code-acp/node_modules/undici-types/snapshot-agent.d.ts +109 -0
- package/vendor/claude-code-acp/node_modules/undici-types/util.d.ts +18 -0
- package/vendor/claude-code-acp/node_modules/undici-types/utility.d.ts +7 -0
- package/vendor/claude-code-acp/node_modules/undici-types/webidl.d.ts +341 -0
- package/vendor/claude-code-acp/node_modules/undici-types/websocket.d.ts +186 -0
- package/dist/web/assets/index-B5Dh2E8j.css +0 -1
- package/dist/web/assets/index-xPPPaEde.js +0 -13
package/README.md
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
# 乐多汪汪队 / leduo-patrol
|
|
2
2
|
|
|
3
|
-
一个部署在服务器上的 Web
|
|
3
|
+
一个部署在服务器上的 Web 控制台,可在浏览器里用两种方式连接 Claude Code:
|
|
4
|
+
- `CLI`:直接内嵌 Claude Code 终端
|
|
5
|
+
- `ACP`:结构化时间线 / 权限 / 提问 / 图片输入视图
|
|
6
|
+
|
|
7
|
+
两种引擎可在同一会话内切换,并共用同一个 Claude `sessionId`。
|
|
4
8
|
|
|
5
9
|
## Showcase
|
|
6
10
|
|
|
@@ -83,7 +87,10 @@
|
|
|
83
87
|
- **目录浏览**:创建会话时可在允许根目录范围内浏览子目录,安全限制越权访问
|
|
84
88
|
|
|
85
89
|
### 工具与集成
|
|
86
|
-
-
|
|
90
|
+
- **双引擎会话**:同一 workspace 会话可在 `CLI` 和 `ACP` 之间切换;切换时复用 Claude `sessionId`
|
|
91
|
+
- **CLI 终端**:主视图可直接运行 Claude Code 原生终端
|
|
92
|
+
- **ACP 结构化视图**:支持时间线、权限确认、AskUserQuestion、多模态图片输入
|
|
93
|
+
- **内置 Shell**:下方可展开终端抽屉,通过 xterm.js 提供完整 PTY shell;服务端默认开启,可通过 `LEDUO_ENABLE_SHELL=false` 显式关闭
|
|
87
94
|
|
|
88
95
|
### 界面与可访问性
|
|
89
96
|
- **访问 Key 认证**:所有请求(HTTP / WebSocket)均需携带 key;浏览器检测到无效 key 时展示输入页
|
|
@@ -151,6 +158,7 @@ LEDUO_PATROL_APP_NAME=乐多汪汪队
|
|
|
151
158
|
LEDUO_PATROL_WORKSPACE_PATH=/absolute/workspace/path
|
|
152
159
|
LEDUO_PATROL_ALLOWED_ROOTS=/absolute/workspace/path,/another/allowed/root
|
|
153
160
|
LEDUO_PATROL_CLAUDE_BIN=/absolute/path/to/claude
|
|
161
|
+
LEDUO_PATROL_AGENT_BIN=/absolute/path/to/claude-code-acp
|
|
154
162
|
LEDUO_PATROL_SHELL=/absolute/path/to/zsh
|
|
155
163
|
ANTHROPIC_API_KEY=sk-...
|
|
156
164
|
LEDUO_PATROL_ACCESS_KEY=your-fixed-key
|
|
@@ -161,6 +169,7 @@ LEDUO_ENABLE_SHELL=false
|
|
|
161
169
|
如果未设置 `LEDUO_PATROL_WORKSPACE_PATH`,默认工作目录为启动命令所在目录(`process.cwd()`),并在启动日志中提示如何通过环境变量修改。
|
|
162
170
|
如果未设置 `LEDUO_PATROL_ALLOWED_ROOTS`,默认允许根目录同样为启动命令所在目录,并会在启动日志中提示可配置项。
|
|
163
171
|
如果发布安装后的内嵌终端无法启动,可通过 `LEDUO_PATROL_SHELL` 显式指定 shell 路径;例如 macOS 上常见的 `/bin/zsh`。
|
|
172
|
+
如果 ACP agent 不在默认安装位置,可通过 `LEDUO_PATROL_AGENT_BIN` 显式指定 `claude-code-acp` 可执行文件。
|
|
164
173
|
|
|
165
174
|
## 状态持久化
|
|
166
175
|
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import test, { mock } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { EventEmitter } from "node:events";
|
|
4
|
+
import { PassThrough } from "node:stream";
|
|
5
|
+
import { ClaudeAcpSession, acpSessionTestables } from "../acp-session.js";
|
|
6
|
+
function makeSession() {
|
|
7
|
+
return new ClaudeAcpSession({
|
|
8
|
+
workspacePath: "/tmp/workspace",
|
|
9
|
+
agentBinPath: "claude-code-acp",
|
|
10
|
+
claudeBin: "/tmp/custom-claude",
|
|
11
|
+
onEvent: () => undefined,
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
test("ClaudeAcpSession.resolveWorkspacePath allows path within workspace", () => {
|
|
15
|
+
const session = makeSession();
|
|
16
|
+
const resolved = session.resolveWorkspacePath("a/b.txt");
|
|
17
|
+
assert.equal(resolved, "/tmp/workspace/a/b.txt");
|
|
18
|
+
});
|
|
19
|
+
test("ClaudeAcpSession.resolveWorkspacePath rejects traversal", () => {
|
|
20
|
+
const session = makeSession();
|
|
21
|
+
assert.throws(() => session.resolveWorkspacePath("../etc/passwd"), /outside workspace/);
|
|
22
|
+
});
|
|
23
|
+
test("ClaudeAcpSession.resolvePermission rejects unknown request", async () => {
|
|
24
|
+
const session = makeSession();
|
|
25
|
+
await assert.rejects(() => session.resolvePermission("missing", "allow"), /not found|already resolved/);
|
|
26
|
+
});
|
|
27
|
+
test("ClaudeAcpSession.answerQuestion resolves pending question", async () => {
|
|
28
|
+
const session = makeSession();
|
|
29
|
+
const calls = [];
|
|
30
|
+
session.pendingQuestions.set("q-1", {
|
|
31
|
+
resolve: (value) => calls.push(value),
|
|
32
|
+
reject: () => undefined,
|
|
33
|
+
});
|
|
34
|
+
await session.answerQuestion("q-1", "好的");
|
|
35
|
+
assert.deepEqual(calls[0], { answer: "好的" });
|
|
36
|
+
assert.equal(session.pendingQuestions.size, 0);
|
|
37
|
+
});
|
|
38
|
+
test("ClaudeAcpSession.handleExtMethod routes leduo/ask_question", async () => {
|
|
39
|
+
const events = [];
|
|
40
|
+
const session = new ClaudeAcpSession({
|
|
41
|
+
workspacePath: "/tmp/workspace",
|
|
42
|
+
agentBinPath: "claude-code-acp",
|
|
43
|
+
onEvent: (event) => events.push(event),
|
|
44
|
+
});
|
|
45
|
+
const resultPromise = session.handleExtMethod("leduo/ask_question", {
|
|
46
|
+
question: "选择颜色",
|
|
47
|
+
options: [{ id: "red", label: "红色" }],
|
|
48
|
+
allowCustomAnswer: true,
|
|
49
|
+
});
|
|
50
|
+
const event = events[0];
|
|
51
|
+
assert.equal(event.type, "question_requested");
|
|
52
|
+
await session.answerQuestion(event.payload.questionId, "红色");
|
|
53
|
+
const result = await resultPromise;
|
|
54
|
+
assert.deepEqual(result, { answer: "红色" });
|
|
55
|
+
});
|
|
56
|
+
test("ClaudeAcpSession.findRestorableSession requires exact match when preferred id is provided", async () => {
|
|
57
|
+
const session = makeSession();
|
|
58
|
+
session.connection = {
|
|
59
|
+
unstable_listSessions: async () => ({
|
|
60
|
+
sessions: [{ sessionId: "other-session-id" }],
|
|
61
|
+
}),
|
|
62
|
+
};
|
|
63
|
+
const result = await session.findRestorableSession("missing-session-id");
|
|
64
|
+
assert.equal(result, null);
|
|
65
|
+
});
|
|
66
|
+
test("ClaudeAcpSession.findRestorableSession falls back to the first session when no preferred id is provided", async () => {
|
|
67
|
+
const session = makeSession();
|
|
68
|
+
session.connection = {
|
|
69
|
+
unstable_listSessions: async () => ({
|
|
70
|
+
sessions: [{ sessionId: "restorable-session-id" }],
|
|
71
|
+
}),
|
|
72
|
+
};
|
|
73
|
+
const result = await session.findRestorableSession();
|
|
74
|
+
assert.equal(result, "restorable-session-id");
|
|
75
|
+
});
|
|
76
|
+
test("ClaudeAcpSession.prompt emits prompt_started with images", async () => {
|
|
77
|
+
const events = [];
|
|
78
|
+
const session = new ClaudeAcpSession({
|
|
79
|
+
workspacePath: "/tmp/workspace",
|
|
80
|
+
agentBinPath: "claude-code-acp",
|
|
81
|
+
onEvent: (event) => events.push(event),
|
|
82
|
+
});
|
|
83
|
+
let promptPayload = null;
|
|
84
|
+
session.connection = {
|
|
85
|
+
prompt: async (payload) => {
|
|
86
|
+
promptPayload = payload;
|
|
87
|
+
return { stopReason: "end_turn" };
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
session.sessionId = "session-1";
|
|
91
|
+
session.sessionPromise = Promise.resolve("session-1");
|
|
92
|
+
session.waitForDrain = async () => undefined;
|
|
93
|
+
await session.prompt("hello", [{ data: "abc", mimeType: "image/png" }]);
|
|
94
|
+
const started = events[0];
|
|
95
|
+
assert.equal(started.type, "prompt_started");
|
|
96
|
+
assert.deepEqual(started.payload.images, [{ data: "abc", mimeType: "image/png" }]);
|
|
97
|
+
assert.deepEqual(promptPayload.prompt.map((block) => block.type), ["image", "text"]);
|
|
98
|
+
});
|
|
99
|
+
test("ClaudeAcpSession.connect rejects gracefully when the ACP agent spawn emits EAGAIN", async (t) => {
|
|
100
|
+
const fakeChild = new EventEmitter();
|
|
101
|
+
fakeChild.stdin = new PassThrough();
|
|
102
|
+
fakeChild.stdout = new PassThrough();
|
|
103
|
+
fakeChild.stderr = new PassThrough();
|
|
104
|
+
fakeChild.kill = (() => true);
|
|
105
|
+
const spawnMock = mock.method(acpSessionTestables, "spawnAgent", () => {
|
|
106
|
+
queueMicrotask(() => {
|
|
107
|
+
const error = Object.assign(new Error("resource temporarily unavailable"), { code: "EAGAIN" });
|
|
108
|
+
fakeChild.emit("error", error);
|
|
109
|
+
});
|
|
110
|
+
return fakeChild;
|
|
111
|
+
});
|
|
112
|
+
t.after(() => spawnMock.mock.restore());
|
|
113
|
+
const session = makeSession();
|
|
114
|
+
await assert.rejects(() => session.connect(), /Failed to start Claude ACP agent.*EAGAIN/);
|
|
115
|
+
});
|
|
@@ -8,12 +8,24 @@ test("assistant with stop_reason null → running", () => {
|
|
|
8
8
|
message: { stop_reason: null, content: [{ type: "thinking" }] },
|
|
9
9
|
}), "running");
|
|
10
10
|
});
|
|
11
|
-
test("assistant with stop_reason undefined →
|
|
11
|
+
test("assistant with text content and stop_reason undefined → completed", () => {
|
|
12
12
|
assert.equal(determineActivityState({
|
|
13
13
|
type: "assistant",
|
|
14
14
|
message: { content: [{ type: "text" }] },
|
|
15
|
+
}), "completed");
|
|
16
|
+
});
|
|
17
|
+
test("assistant with thinking content and stop_reason undefined → running", () => {
|
|
18
|
+
assert.equal(determineActivityState({
|
|
19
|
+
type: "assistant",
|
|
20
|
+
message: { content: [{ type: "thinking" }] },
|
|
15
21
|
}), "running");
|
|
16
22
|
});
|
|
23
|
+
test("assistant with tool_use content and stop_reason undefined → pending", () => {
|
|
24
|
+
assert.equal(determineActivityState({
|
|
25
|
+
type: "assistant",
|
|
26
|
+
message: { content: [{ type: "tool_use" }] },
|
|
27
|
+
}), "pending");
|
|
28
|
+
});
|
|
17
29
|
test("assistant with no message field → running", () => {
|
|
18
30
|
assert.equal(determineActivityState({ type: "assistant" }), "running");
|
|
19
31
|
});
|
|
@@ -1,7 +1,386 @@
|
|
|
1
1
|
import test from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
-
import
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { mkdtemp, writeFile } from "node:fs/promises";
|
|
6
|
+
import { SessionManager, sessionManagerTestables } from "../session-manager.js";
|
|
7
|
+
function makeSnapshot(overrides = {}) {
|
|
8
|
+
return {
|
|
9
|
+
clientSessionId: "s1",
|
|
10
|
+
title: "demo",
|
|
11
|
+
workspacePath: process.cwd(),
|
|
12
|
+
connectionState: "connected",
|
|
13
|
+
activityState: "idle",
|
|
14
|
+
sessionId: "shared-session-id",
|
|
15
|
+
engine: "cli",
|
|
16
|
+
switchable: true,
|
|
17
|
+
updatedAt: new Date().toISOString(),
|
|
18
|
+
...overrides,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
function makeEntry(overrides = {}) {
|
|
22
|
+
return {
|
|
23
|
+
snapshot: makeSnapshot(overrides),
|
|
24
|
+
cliSession: null,
|
|
25
|
+
cliExitExpected: false,
|
|
26
|
+
acpSession: null,
|
|
27
|
+
acpFullTimeline: [],
|
|
28
|
+
outputBuffer: "",
|
|
29
|
+
switchInProgress: false,
|
|
30
|
+
acpQueueDrainActive: false,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
function makeAcpState(overrides = {}) {
|
|
34
|
+
return {
|
|
35
|
+
modes: ["default"],
|
|
36
|
+
defaultModeId: "default",
|
|
37
|
+
currentModeId: "default",
|
|
38
|
+
busy: false,
|
|
39
|
+
timeline: [],
|
|
40
|
+
historyTotal: 0,
|
|
41
|
+
historyStart: 0,
|
|
42
|
+
permissions: [],
|
|
43
|
+
questions: [],
|
|
44
|
+
availableCommands: [],
|
|
45
|
+
queuedPrompts: [],
|
|
46
|
+
...overrides,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function makeManager(options) {
|
|
50
|
+
const manager = new SessionManager(options);
|
|
51
|
+
manager.activityMonitor.watch = () => undefined;
|
|
52
|
+
manager.activityMonitor.unwatch = () => undefined;
|
|
53
|
+
return manager;
|
|
54
|
+
}
|
|
4
55
|
test("sessionManagerTestables.formatError handles Error and objects", () => {
|
|
5
56
|
assert.equal(sessionManagerTestables.formatError(new Error("boom")), "boom");
|
|
6
57
|
assert.match(sessionManagerTestables.formatError({ code: 1 }), /"code":1/);
|
|
7
58
|
});
|
|
59
|
+
test("SessionManager.getAvailableEngines exposes ACP only when agent path exists", () => {
|
|
60
|
+
const cliOnly = makeManager({ allowedRoots: [process.cwd()] });
|
|
61
|
+
const dual = makeManager({ allowedRoots: [process.cwd()], agentBinPath: "/tmp/acp" });
|
|
62
|
+
assert.deepEqual(cliOnly.getAvailableEngines(), ["cli"]);
|
|
63
|
+
assert.deepEqual(dual.getAvailableEngines(), ["cli", "acp"]);
|
|
64
|
+
});
|
|
65
|
+
test("SessionManager.switchEngine keeps the same Claude sessionId", async () => {
|
|
66
|
+
const manager = makeManager({ allowedRoots: [process.cwd()], agentBinPath: "/tmp/acp" });
|
|
67
|
+
const started = [];
|
|
68
|
+
const stopped = [];
|
|
69
|
+
const events = [];
|
|
70
|
+
manager.startEngine = async (entry, resume) => {
|
|
71
|
+
started.push({ engine: entry.snapshot.engine, resume, sessionId: entry.snapshot.sessionId });
|
|
72
|
+
entry.snapshot.connectionState = "connected";
|
|
73
|
+
};
|
|
74
|
+
manager.stopEngine = async (entry) => {
|
|
75
|
+
stopped.push(entry.snapshot.engine);
|
|
76
|
+
};
|
|
77
|
+
manager.subscribe((event) => events.push(event.type));
|
|
78
|
+
manager.sessions.set("s1", makeEntry());
|
|
79
|
+
await manager.switchEngine("s1", "acp");
|
|
80
|
+
const entry = manager.sessions.get("s1");
|
|
81
|
+
assert.equal(entry.snapshot.engine, "acp");
|
|
82
|
+
assert.equal(entry.snapshot.sessionId, "shared-session-id");
|
|
83
|
+
assert.deepEqual(stopped, ["cli"]);
|
|
84
|
+
assert.deepEqual(started, [{ engine: "acp", resume: true, sessionId: "shared-session-id" }]);
|
|
85
|
+
assert.equal(events.at(-1), "session_updated");
|
|
86
|
+
});
|
|
87
|
+
test("SessionManager.switchEngine clears buffered CLI output before starting the target engine", async () => {
|
|
88
|
+
const manager = makeManager({ allowedRoots: [process.cwd()], agentBinPath: "/tmp/acp" });
|
|
89
|
+
manager.sessions.set("s1", {
|
|
90
|
+
...makeEntry({ engine: "acp" }),
|
|
91
|
+
outputBuffer: "old cli output",
|
|
92
|
+
});
|
|
93
|
+
manager.startEngine = async (entry, resume) => {
|
|
94
|
+
assert.equal(entry.outputBuffer, "");
|
|
95
|
+
assert.equal(resume, true);
|
|
96
|
+
entry.snapshot.connectionState = "connected";
|
|
97
|
+
};
|
|
98
|
+
manager.stopEngine = SessionManager.prototype["stopEngine"].bind(manager);
|
|
99
|
+
await manager.switchEngine("s1", "cli");
|
|
100
|
+
const entry = manager.sessions.get("s1");
|
|
101
|
+
assert.equal(entry.outputBuffer, "");
|
|
102
|
+
});
|
|
103
|
+
test("SessionManager.createSession starts fresh CLI sessions and emits a connected snapshot", async () => {
|
|
104
|
+
const manager = makeManager({ allowedRoots: [process.cwd()], agentBinPath: "/tmp/acp" });
|
|
105
|
+
const started = [];
|
|
106
|
+
const events = [];
|
|
107
|
+
manager.resolveRequestedWorkspace = async (requestedWorkspacePath) => requestedWorkspacePath;
|
|
108
|
+
manager.startEngine = async (entry, resume) => {
|
|
109
|
+
started.push({ engine: entry.snapshot.engine, resume, sessionId: entry.snapshot.sessionId });
|
|
110
|
+
entry.snapshot.connectionState = "connected";
|
|
111
|
+
};
|
|
112
|
+
manager.subscribe((event) => {
|
|
113
|
+
if (event.type === "session_registered" || event.type === "session_updated") {
|
|
114
|
+
events.push(event);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
const snapshot = await manager.createSession("/tmp/fresh-cli-workspace", "fresh-cli", false, "cli");
|
|
118
|
+
assert.equal(snapshot.engine, "cli");
|
|
119
|
+
assert.ok(snapshot.sessionId);
|
|
120
|
+
assert.deepEqual(started, [{ engine: "cli", resume: false, sessionId: snapshot.sessionId }]);
|
|
121
|
+
assert.deepEqual(events.map((event) => event.type), ["session_registered", "session_updated"]);
|
|
122
|
+
assert.equal(events[0]?.payload.connectionState, "connecting");
|
|
123
|
+
assert.equal(events[1]?.payload.connectionState, "connected");
|
|
124
|
+
});
|
|
125
|
+
test("SessionManager.createSession emits an error snapshot when startup fails", async () => {
|
|
126
|
+
const manager = makeManager({ allowedRoots: [process.cwd()] });
|
|
127
|
+
const events = [];
|
|
128
|
+
manager.resolveRequestedWorkspace = async (requestedWorkspacePath) => requestedWorkspacePath;
|
|
129
|
+
manager.startEngine = async (entry) => {
|
|
130
|
+
entry.snapshot.connectionState = "error";
|
|
131
|
+
throw new Error("boom");
|
|
132
|
+
};
|
|
133
|
+
manager.subscribe((event) => {
|
|
134
|
+
if (event.type === "session_registered" || event.type === "session_updated") {
|
|
135
|
+
events.push(event);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
await assert.rejects(() => manager.createSession("/tmp/broken-cli-workspace", "broken-cli", false, "cli"), /boom/);
|
|
139
|
+
assert.deepEqual(events.map((event) => event.type), ["session_registered", "session_updated"]);
|
|
140
|
+
assert.equal(events[0]?.payload.connectionState, "connecting");
|
|
141
|
+
assert.equal(events[1]?.payload.connectionState, "error");
|
|
142
|
+
});
|
|
143
|
+
test("SessionManager.switchEngine rejects busy sessions", async () => {
|
|
144
|
+
const manager = makeManager({ allowedRoots: [process.cwd()], agentBinPath: "/tmp/acp" });
|
|
145
|
+
manager.sessions.set("s1", makeEntry({ activityState: "running" }));
|
|
146
|
+
manager.startEngine = async () => undefined;
|
|
147
|
+
manager.stopEngine = async () => undefined;
|
|
148
|
+
await assert.rejects(() => manager.switchEngine("s1", "acp"), /Session is not switchable: 运行中/);
|
|
149
|
+
});
|
|
150
|
+
test("SessionManager.switchEngine rejects pending ACP approvals", async () => {
|
|
151
|
+
const manager = makeManager({ allowedRoots: [process.cwd()], agentBinPath: "/tmp/acp" });
|
|
152
|
+
manager.sessions.set("s1", makeEntry({
|
|
153
|
+
engine: "acp",
|
|
154
|
+
acp: {
|
|
155
|
+
modes: ["default"],
|
|
156
|
+
defaultModeId: "default",
|
|
157
|
+
currentModeId: "default",
|
|
158
|
+
busy: false,
|
|
159
|
+
timeline: [],
|
|
160
|
+
historyTotal: 0,
|
|
161
|
+
historyStart: 0,
|
|
162
|
+
permissions: [{
|
|
163
|
+
clientSessionId: "s1",
|
|
164
|
+
requestId: "req-1",
|
|
165
|
+
toolCall: { toolCallId: "tool-1", title: "Write" },
|
|
166
|
+
options: [{ optionId: "allow", name: "允许", kind: "allow" }],
|
|
167
|
+
}],
|
|
168
|
+
questions: [],
|
|
169
|
+
availableCommands: [],
|
|
170
|
+
queuedPrompts: [],
|
|
171
|
+
},
|
|
172
|
+
}));
|
|
173
|
+
manager.startEngine = async () => undefined;
|
|
174
|
+
manager.stopEngine = async () => undefined;
|
|
175
|
+
await assert.rejects(() => manager.switchEngine("s1", "cli"), /Session is not switchable: 待审批/);
|
|
176
|
+
});
|
|
177
|
+
test("SessionManager.switchEngine allows idle ACP sessions despite stale activityState", async () => {
|
|
178
|
+
const manager = makeManager({ allowedRoots: [process.cwd()], agentBinPath: "/tmp/acp" });
|
|
179
|
+
const started = [];
|
|
180
|
+
const stopped = [];
|
|
181
|
+
manager.sessions.set("s1", makeEntry({
|
|
182
|
+
engine: "acp",
|
|
183
|
+
activityState: "running",
|
|
184
|
+
acp: {
|
|
185
|
+
modes: ["default"],
|
|
186
|
+
defaultModeId: "default",
|
|
187
|
+
currentModeId: "default",
|
|
188
|
+
busy: false,
|
|
189
|
+
timeline: [],
|
|
190
|
+
historyTotal: 0,
|
|
191
|
+
historyStart: 0,
|
|
192
|
+
permissions: [],
|
|
193
|
+
questions: [],
|
|
194
|
+
availableCommands: [],
|
|
195
|
+
queuedPrompts: [],
|
|
196
|
+
},
|
|
197
|
+
}));
|
|
198
|
+
manager.startEngine = async (entry, resume) => {
|
|
199
|
+
started.push({ engine: entry.snapshot.engine, resume });
|
|
200
|
+
entry.snapshot.connectionState = "connected";
|
|
201
|
+
};
|
|
202
|
+
manager.stopEngine = async (entry) => {
|
|
203
|
+
stopped.push(entry.snapshot.engine);
|
|
204
|
+
};
|
|
205
|
+
await manager.switchEngine("s1", "cli");
|
|
206
|
+
assert.deepEqual(stopped, ["acp"]);
|
|
207
|
+
assert.deepEqual(started, [{ engine: "cli", resume: true }]);
|
|
208
|
+
});
|
|
209
|
+
test("SessionManager.switchEngine allows ACP sessions after end_turn even if busy flag is stale", async () => {
|
|
210
|
+
const manager = makeManager({ allowedRoots: [process.cwd()], agentBinPath: "/tmp/acp" });
|
|
211
|
+
const started = [];
|
|
212
|
+
const stopped = [];
|
|
213
|
+
manager.sessions.set("s1", makeEntry({
|
|
214
|
+
engine: "acp",
|
|
215
|
+
acp: {
|
|
216
|
+
modes: ["default"],
|
|
217
|
+
defaultModeId: "default",
|
|
218
|
+
currentModeId: "default",
|
|
219
|
+
busy: true,
|
|
220
|
+
timeline: [{
|
|
221
|
+
id: "done-1",
|
|
222
|
+
kind: "system",
|
|
223
|
+
title: "本轮完成",
|
|
224
|
+
body: "end_turn",
|
|
225
|
+
}],
|
|
226
|
+
historyTotal: 1,
|
|
227
|
+
historyStart: 0,
|
|
228
|
+
permissions: [],
|
|
229
|
+
questions: [],
|
|
230
|
+
availableCommands: [],
|
|
231
|
+
queuedPrompts: [],
|
|
232
|
+
},
|
|
233
|
+
}));
|
|
234
|
+
manager.startEngine = async (entry, resume) => {
|
|
235
|
+
started.push({ engine: entry.snapshot.engine, resume });
|
|
236
|
+
entry.snapshot.connectionState = "connected";
|
|
237
|
+
};
|
|
238
|
+
manager.stopEngine = async (entry) => {
|
|
239
|
+
stopped.push(entry.snapshot.engine);
|
|
240
|
+
};
|
|
241
|
+
await manager.switchEngine("s1", "cli");
|
|
242
|
+
assert.deepEqual(stopped, ["acp"]);
|
|
243
|
+
assert.deepEqual(started, [{ engine: "cli", resume: true }]);
|
|
244
|
+
});
|
|
245
|
+
test("SessionManager.prompt queues ACP prompts while busy", async () => {
|
|
246
|
+
const manager = makeManager({ allowedRoots: [process.cwd()], agentBinPath: "/tmp/acp" });
|
|
247
|
+
const promptCalls = [];
|
|
248
|
+
manager.sessions.set("s1", {
|
|
249
|
+
...makeEntry({
|
|
250
|
+
engine: "acp",
|
|
251
|
+
acp: makeAcpState({ busy: true }),
|
|
252
|
+
}),
|
|
253
|
+
acpSession: {
|
|
254
|
+
setMode: async () => undefined,
|
|
255
|
+
prompt: async () => {
|
|
256
|
+
promptCalls.push("prompt");
|
|
257
|
+
},
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
await manager.prompt("s1", "queued hello");
|
|
261
|
+
const entry = manager.sessions.get("s1");
|
|
262
|
+
assert.equal(promptCalls.length, 0);
|
|
263
|
+
assert.equal(entry.snapshot.acp?.queuedPrompts.length, 1);
|
|
264
|
+
assert.equal(entry.snapshot.acp?.queuedPrompts[0]?.text, "queued hello");
|
|
265
|
+
assert.equal(entry.snapshot.acp?.queuedPrompts[0]?.status, "queued");
|
|
266
|
+
assert.equal(entry.snapshot.acp?.queuedPrompts[0]?.modeId, "default");
|
|
267
|
+
});
|
|
268
|
+
test("SessionManager.prompt sends immediately when ACP session is idle", async () => {
|
|
269
|
+
const manager = makeManager({ allowedRoots: [process.cwd()], agentBinPath: "/tmp/acp" });
|
|
270
|
+
const setModeCalls = [];
|
|
271
|
+
const promptCalls = [];
|
|
272
|
+
manager.sessions.set("s1", {
|
|
273
|
+
...makeEntry({
|
|
274
|
+
engine: "acp",
|
|
275
|
+
acp: makeAcpState(),
|
|
276
|
+
}),
|
|
277
|
+
acpSession: {
|
|
278
|
+
setMode: async (modeId) => {
|
|
279
|
+
setModeCalls.push(modeId);
|
|
280
|
+
},
|
|
281
|
+
prompt: async (text, images) => {
|
|
282
|
+
promptCalls.push({ text, images });
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
});
|
|
286
|
+
await manager.prompt("s1", "ship it", "plan", [{ data: "base64", mimeType: "image/png" }]);
|
|
287
|
+
const entry = manager.sessions.get("s1");
|
|
288
|
+
assert.deepEqual(setModeCalls, ["plan"]);
|
|
289
|
+
assert.deepEqual(promptCalls, [{ text: "ship it", images: [{ data: "base64", mimeType: "image/png" }] }]);
|
|
290
|
+
assert.equal(entry.snapshot.acp?.queuedPrompts.length, 0);
|
|
291
|
+
});
|
|
292
|
+
test("SessionManager.prompt keeps strict FIFO when queued prompts already exist", async () => {
|
|
293
|
+
const manager = makeManager({ allowedRoots: [process.cwd()], agentBinPath: "/tmp/acp" });
|
|
294
|
+
const promptCalls = [];
|
|
295
|
+
manager.sessions.set("s1", {
|
|
296
|
+
...makeEntry({
|
|
297
|
+
engine: "acp",
|
|
298
|
+
acp: makeAcpState({
|
|
299
|
+
queuedPrompts: [{
|
|
300
|
+
id: "queued-1",
|
|
301
|
+
text: "first",
|
|
302
|
+
images: [],
|
|
303
|
+
modeId: "default",
|
|
304
|
+
createdAt: new Date().toISOString(),
|
|
305
|
+
status: "queued",
|
|
306
|
+
}],
|
|
307
|
+
}),
|
|
308
|
+
}),
|
|
309
|
+
acpSession: {
|
|
310
|
+
setMode: async () => undefined,
|
|
311
|
+
prompt: async () => {
|
|
312
|
+
promptCalls.push("prompt");
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
});
|
|
316
|
+
await manager.prompt("s1", "second");
|
|
317
|
+
const queuedPrompts = manager.sessions.get("s1").snapshot.acp?.queuedPrompts ?? [];
|
|
318
|
+
assert.equal(promptCalls.length, 1);
|
|
319
|
+
assert.deepEqual(queuedPrompts.map((prompt) => prompt.text), ["first", "second"]);
|
|
320
|
+
assert.deepEqual(queuedPrompts.map((prompt) => prompt.status), ["sending", "queued"]);
|
|
321
|
+
});
|
|
322
|
+
test("SessionManager.switchEngine rejects ACP sessions with queued prompts", async () => {
|
|
323
|
+
const manager = makeManager({ allowedRoots: [process.cwd()], agentBinPath: "/tmp/acp" });
|
|
324
|
+
manager.sessions.set("s1", makeEntry({
|
|
325
|
+
engine: "acp",
|
|
326
|
+
acp: makeAcpState({
|
|
327
|
+
queuedPrompts: [{
|
|
328
|
+
id: "queued-1",
|
|
329
|
+
text: "first",
|
|
330
|
+
images: [],
|
|
331
|
+
modeId: "default",
|
|
332
|
+
createdAt: new Date().toISOString(),
|
|
333
|
+
status: "queued",
|
|
334
|
+
}],
|
|
335
|
+
}),
|
|
336
|
+
}));
|
|
337
|
+
await assert.rejects(() => manager.switchEngine("s1", "cli"), /Session is not switchable: 队列未清空/);
|
|
338
|
+
});
|
|
339
|
+
test("SessionManager recovers persisted ACP queues and drains them on initialize", async () => {
|
|
340
|
+
const manager = makeManager({ allowedRoots: [process.cwd()], agentBinPath: "/tmp/acp" });
|
|
341
|
+
const tempDir = await mkdtemp(path.join(os.tmpdir(), "leduo-patrol-session-manager-"));
|
|
342
|
+
const stateFilePath = path.join(tempDir, "state.json");
|
|
343
|
+
const promptCalls = [];
|
|
344
|
+
const setModeCalls = [];
|
|
345
|
+
await writeFile(stateFilePath, JSON.stringify({
|
|
346
|
+
sessions: [{
|
|
347
|
+
clientSessionId: "persisted-acp",
|
|
348
|
+
title: "persisted",
|
|
349
|
+
workspacePath: process.cwd(),
|
|
350
|
+
sessionId: "shared-session-id",
|
|
351
|
+
engine: "acp",
|
|
352
|
+
updatedAt: new Date().toISOString(),
|
|
353
|
+
acpDefaultModeId: "default",
|
|
354
|
+
acpCurrentModeId: "default",
|
|
355
|
+
acpQueuedPrompts: [{
|
|
356
|
+
id: "queued-1",
|
|
357
|
+
text: "resume me",
|
|
358
|
+
images: [{ data: "abc", mimeType: "image/png" }],
|
|
359
|
+
modeId: "plan",
|
|
360
|
+
createdAt: new Date().toISOString(),
|
|
361
|
+
status: "sending",
|
|
362
|
+
}],
|
|
363
|
+
}],
|
|
364
|
+
}), "utf8");
|
|
365
|
+
manager.stateFilePath = stateFilePath;
|
|
366
|
+
manager.isRestorableWorkspace = async () => true;
|
|
367
|
+
manager.startHistoryMonitor = async () => undefined;
|
|
368
|
+
manager.startEngine = async (entry) => {
|
|
369
|
+
entry.snapshot.connectionState = "connected";
|
|
370
|
+
entry.acpSession = {
|
|
371
|
+
setMode: async (modeId) => {
|
|
372
|
+
setModeCalls.push(modeId);
|
|
373
|
+
},
|
|
374
|
+
prompt: async (text, images) => {
|
|
375
|
+
promptCalls.push({ text, images });
|
|
376
|
+
},
|
|
377
|
+
};
|
|
378
|
+
manager.recoverSendingQueuedPrompts(entry);
|
|
379
|
+
await manager.drainAcpPromptQueue(entry);
|
|
380
|
+
};
|
|
381
|
+
await manager.initialize();
|
|
382
|
+
const restoredEntry = manager.sessions.get("persisted-acp");
|
|
383
|
+
assert.deepEqual(setModeCalls, ["plan"]);
|
|
384
|
+
assert.deepEqual(promptCalls, [{ text: "resume me", images: [{ data: "abc", mimeType: "image/png" }] }]);
|
|
385
|
+
assert.equal(restoredEntry.snapshot.acp?.queuedPrompts[0]?.status, "sending");
|
|
386
|
+
});
|