aitable-workflow-cli 0.1.22 → 0.1.23
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/dist/{chunk-VYYLEYUZ.js → chunk-IGB6WDLR.js} +9 -9
- package/dist/cli.js +24 -24
- package/dist/{config-ui-server-GSFSGBRK.js → config-ui-server-CORDAOYI.js} +1 -1
- package/package.json +3 -3
- package/templates/support-qa/.env.example +7 -1
- package/templates/support-qa/coding-agents.config.json +2 -1
- package/templates/support-qa/lib/__tests__/sandbox-agent.test.ts +382 -0
- package/templates/support-qa/lib/sandbox-agent.ts +653 -0
- package/templates/support-qa/package.json +11 -1
- package/templates/support-qa/tsconfig.json +16 -0
- package/templates/support-qa/vitest.config.ts +9 -0
- package/templates/support-qa/workflows/group-qa/recipes/answer.recipe.ts +19 -6
- package/templates/support-qa/workspaces/support/AGENTS.md +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
import{c as a,d as b,e as c,f as d,g as e,h as f,i as g,j as h,k as i,l as j,m as k,n as l,o as m,p as n,q as o,r as p,s as q,t as r,u as s,v as t,w as u}from"./chunk-
|
|
1
|
+
import{c as a,d as b,e as c,f as d,g as e,h as f,i as g,j as h,k as i,l as j,m as k,n as l,o as m,p as n,q as o,r as p,s as q,t as r,u as s,v as t,w as u}from"./chunk-IGB6WDLR.js";import"./chunk-4B6V7VHM.js";import"./chunk-EI7MMDWY.js";export{h as CONSOLE_ACCESS_FILE_REL,d as PromotionHttpError,c as TriggerOnceHttpError,j as clearConsoleAccessFile,a as consoleStartPath,b as createIdleTimer,e as detectEnvironment,l as getRuntimeRunStatus,t as getSetupProgressForTest,p as handleConsolePromotion,m as handleRuntimeRunControl,n as handleTriggerWorkflowOnce,g as hasWebRoot,r as resetPromotionForTest,q as resetRuntimeRunStatusForTest,o as resetTriggerOnceForTest,f as resolveWebRoot,k as runConfigUI,u as serveStatic,s as setSetupProgressForTest,i as writeConsoleAccessFile};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "aitable-workflow-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.23",
|
|
4
4
|
"description": "aitable-workflow 命令行工具(cli):init/design/setup/run/serve 等命令;含 relay 本地反连客户端与 daemon 进程管理。bin: aitable-workflow。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -19,8 +19,8 @@
|
|
|
19
19
|
"access": "public"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"aitable-workflow-core": "0.1.
|
|
23
|
-
"aitable-workflow-server": "0.1.
|
|
22
|
+
"aitable-workflow-core": "0.1.23",
|
|
23
|
+
"aitable-workflow-server": "0.1.23",
|
|
24
24
|
"@inquirer/prompts": "^8.5.2",
|
|
25
25
|
"commander": "^14.0.2",
|
|
26
26
|
"dotenv": "^17.4.2",
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# LLM(知识库编译 + 答疑推理)
|
|
2
|
-
IDEALAB_API_KEY
|
|
2
|
+
# 沙箱模式下 qodercli 在沙箱内运行,本地不需要 IDEALAB_API_KEY;
|
|
3
|
+
# 如需本地 fallback 调试可保留。
|
|
4
|
+
# IDEALAB_API_KEY=your-idealab-key
|
|
5
|
+
|
|
6
|
+
# Aone Sandbox 凭据(support-qa 沙箱模式必填)
|
|
7
|
+
SANDBOX_API_KEY=your-sandbox-api-key
|
|
8
|
+
QODER_PERSONAL_ACCESS_TOKEN=your-qoder-pat
|
|
3
9
|
|
|
4
10
|
# dws CLI 需在 PATH:消息拉取(群答疑)、todo(知识缺口待办通知)
|
|
5
11
|
# L2 装 config-sync 包后:群配置与管理员名单来自 globalVar(group_configs/admins)
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest";
|
|
6
|
+
import { unzipSync } from "fflate";
|
|
7
|
+
import { SandboxManager } from "@ali/aone-sandbox";
|
|
8
|
+
import { CodeInterpreter, QoderRemoteAgent, Sandbox, type SandboxClient } from "@ali/aone-remote-agent";
|
|
9
|
+
import { SupportQaSandboxAgentPool, runSandboxAgent, stopSandboxAgentPool } from "../sandbox-agent.js";
|
|
10
|
+
|
|
11
|
+
vi.mock("@ali/aone-sandbox", () => ({
|
|
12
|
+
SandboxManager: { create: vi.fn() },
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
vi.mock("@ali/aone-remote-agent", () => ({
|
|
16
|
+
CodeInterpreter: { fromSandbox: vi.fn() },
|
|
17
|
+
QoderRemoteAgent: vi.fn(),
|
|
18
|
+
Sandbox: { create: vi.fn() },
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
const createMockAcpClient = () => {
|
|
22
|
+
const listeners = new Set<(update: { sessionId: string; update: Record<string, unknown>; raw: Record<string, unknown> }) => void>();
|
|
23
|
+
const emitText = (sessionId: string, text: string) => {
|
|
24
|
+
listeners.forEach((listener) =>
|
|
25
|
+
listener({
|
|
26
|
+
sessionId,
|
|
27
|
+
update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text } },
|
|
28
|
+
raw: {},
|
|
29
|
+
}),
|
|
30
|
+
);
|
|
31
|
+
};
|
|
32
|
+
return {
|
|
33
|
+
newAcpSession: vi.fn().mockResolvedValue("session-1"),
|
|
34
|
+
query: vi.fn().mockImplementation(async (_prompt: string, sessionId: string) => {
|
|
35
|
+
emitText(sessionId, '{"action":"reply","reply":"hi"}');
|
|
36
|
+
return { stopReason: "end_turn", raw: {} };
|
|
37
|
+
}),
|
|
38
|
+
addUpdateListener: vi.fn().mockImplementation((listener) => {
|
|
39
|
+
listeners.add(listener);
|
|
40
|
+
return () => listeners.delete(listener);
|
|
41
|
+
}),
|
|
42
|
+
closeAcpSession: vi.fn().mockResolvedValue(undefined),
|
|
43
|
+
_emitText: emitText,
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const createMockAgent = (sandbox: SandboxClient) => {
|
|
48
|
+
const client = createMockAcpClient();
|
|
49
|
+
return {
|
|
50
|
+
start: vi.fn().mockResolvedValue(undefined),
|
|
51
|
+
stop: vi.fn().mockResolvedValue(undefined),
|
|
52
|
+
sandbox: vi.fn().mockReturnValue(sandbox),
|
|
53
|
+
client: vi.fn().mockReturnValue(client),
|
|
54
|
+
_client: client,
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
function createLocalWorkspace(): { root: string; workDir: string; indexPath: string; mediaDir: string } {
|
|
59
|
+
const root = mkdtempSync(path.join(tmpdir(), "support-qa-sandbox-"));
|
|
60
|
+
const workDir = path.join(root, "workspaces", "support");
|
|
61
|
+
const compiledWiki = path.join(root, "wiki", "faq-support", ".kb", "wiki");
|
|
62
|
+
const mediaDir = path.join(root, ".aitable", "media", "dingtalk");
|
|
63
|
+
mkdirSync(path.join(workDir, "wiki"), { recursive: true });
|
|
64
|
+
mkdirSync(compiledWiki, { recursive: true });
|
|
65
|
+
mkdirSync(mediaDir, { recursive: true });
|
|
66
|
+
writeFileSync(path.join(workDir, "AGENTS.md"), "agent instructions");
|
|
67
|
+
writeFileSync(path.join(workDir, "owner-map.json"), "{}");
|
|
68
|
+
writeFileSync(path.join(root, "coding-agents.config.json"), JSON.stringify({
|
|
69
|
+
version: 1,
|
|
70
|
+
agents: { qoder: { enabled: true, model: "ConfiguredModel" } },
|
|
71
|
+
}));
|
|
72
|
+
writeFileSync(path.join(compiledWiki, "_index.md"), "# first index");
|
|
73
|
+
symlinkSync(compiledWiki, path.join(workDir, "wiki", "faq-support"), process.platform === "win32" ? "junction" : "dir");
|
|
74
|
+
return { root, workDir, indexPath: path.join(compiledWiki, "_index.md"), mediaDir };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function createRawSandbox(id: string) {
|
|
78
|
+
return {
|
|
79
|
+
id,
|
|
80
|
+
connectionConfig: { getBaseUrl: vi.fn().mockReturnValue("https://sandbox.test"), apiKey: "test-api-key" },
|
|
81
|
+
getEndpoint: vi.fn().mockResolvedValue({ endpoint: "https://endpoint.test", headers: {} }),
|
|
82
|
+
kill: vi.fn().mockResolvedValue(undefined),
|
|
83
|
+
close: vi.fn().mockResolvedValue(undefined),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
describe("runSandboxAgent", () => {
|
|
88
|
+
const originalEnv = process.env;
|
|
89
|
+
let local: ReturnType<typeof createLocalWorkspace>;
|
|
90
|
+
let manager: { listSandboxInfos: Mock; killSandbox: Mock; close: Mock };
|
|
91
|
+
let mockInterpreter: {
|
|
92
|
+
commands: { run: Mock };
|
|
93
|
+
files: { readFile: Mock; writeFiles: Mock };
|
|
94
|
+
};
|
|
95
|
+
let rawSandboxes: ReturnType<typeof createRawSandbox>[];
|
|
96
|
+
let agents: ReturnType<typeof createMockAgent>[];
|
|
97
|
+
let configureAgent: (agent: ReturnType<typeof createMockAgent>) => void;
|
|
98
|
+
|
|
99
|
+
beforeEach(() => {
|
|
100
|
+
process.env = { ...originalEnv, SANDBOX_API_KEY: "test-api-key", QODER_PERSONAL_ACCESS_TOKEN: "test-pat" };
|
|
101
|
+
local = createLocalWorkspace();
|
|
102
|
+
manager = {
|
|
103
|
+
listSandboxInfos: vi.fn().mockResolvedValue({ items: [] }),
|
|
104
|
+
killSandbox: vi.fn().mockResolvedValue(undefined),
|
|
105
|
+
close: vi.fn().mockResolvedValue(undefined),
|
|
106
|
+
};
|
|
107
|
+
mockInterpreter = {
|
|
108
|
+
commands: { run: vi.fn().mockResolvedValue({ exitCode: 0, logs: { stdout: [], stderr: [] } }) },
|
|
109
|
+
files: {
|
|
110
|
+
readFile: vi.fn(),
|
|
111
|
+
writeFiles: vi.fn().mockResolvedValue(undefined),
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
rawSandboxes = [];
|
|
115
|
+
agents = [];
|
|
116
|
+
configureAgent = () => {};
|
|
117
|
+
|
|
118
|
+
vi.mocked(SandboxManager.create).mockReturnValue(manager as unknown as SandboxManager);
|
|
119
|
+
vi.mocked(Sandbox.create).mockImplementation(async () => {
|
|
120
|
+
const sandbox = createRawSandbox(`sandbox-${rawSandboxes.length + 1}`);
|
|
121
|
+
rawSandboxes.push(sandbox);
|
|
122
|
+
return sandbox as unknown as Sandbox;
|
|
123
|
+
});
|
|
124
|
+
vi.mocked(CodeInterpreter.fromSandbox).mockResolvedValue(
|
|
125
|
+
mockInterpreter as unknown as Awaited<ReturnType<typeof CodeInterpreter.fromSandbox>>,
|
|
126
|
+
);
|
|
127
|
+
vi.mocked(QoderRemoteAgent).mockImplementation((sandbox) => {
|
|
128
|
+
const agent = createMockAgent(sandbox as SandboxClient);
|
|
129
|
+
configureAgent(agent);
|
|
130
|
+
agents.push(agent);
|
|
131
|
+
return agent as unknown as QoderRemoteAgent;
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
afterEach(async () => {
|
|
136
|
+
await stopSandboxAgentPool(() => {});
|
|
137
|
+
rmSync(local.root, { recursive: true, force: true });
|
|
138
|
+
process.env = originalEnv;
|
|
139
|
+
vi.clearAllMocks();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("缺少 SANDBOX_API_KEY 时抛出可读错误", async () => {
|
|
143
|
+
delete process.env.SANDBOX_API_KEY;
|
|
144
|
+
await expect(runSandboxAgent("prompt", local.workDir, () => {})).rejects.toThrow("SANDBOX_API_KEY");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("缺少 QODER_PERSONAL_ACCESS_TOKEN 时抛出可读错误", async () => {
|
|
148
|
+
delete process.env.QODER_PERSONAL_ACCESS_TOKEN;
|
|
149
|
+
await expect(runSandboxAgent("prompt", local.workDir, () => {})).rejects.toThrow("QODER_PERSONAL_ACCESS_TOKEN");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("创建三个独立 Sandbox、预装 qodercli 并返回 ACP 文本与会话标识", async () => {
|
|
153
|
+
const logs: string[] = [];
|
|
154
|
+
const result = await runSandboxAgent("prompt", local.workDir, (message) => logs.push(message));
|
|
155
|
+
|
|
156
|
+
expect(Sandbox.create).toHaveBeenCalledTimes(3);
|
|
157
|
+
for (let index = 0; index < 3; index++) {
|
|
158
|
+
expect(Sandbox.create).toHaveBeenNthCalledWith(index + 1, expect.objectContaining({
|
|
159
|
+
template: "code-interpreter",
|
|
160
|
+
metadata: expect.objectContaining({ slot: String(index + 1) }),
|
|
161
|
+
}));
|
|
162
|
+
}
|
|
163
|
+
expect(QoderRemoteAgent).toHaveBeenCalledTimes(3);
|
|
164
|
+
expect(QoderRemoteAgent).toHaveBeenCalledWith(
|
|
165
|
+
expect.anything(),
|
|
166
|
+
expect.objectContaining({ model: "ConfiguredModel" }),
|
|
167
|
+
"/workspace",
|
|
168
|
+
false,
|
|
169
|
+
);
|
|
170
|
+
expect(agents.every((agent) => agent.start.mock.calls.length === 1)).toBe(true);
|
|
171
|
+
|
|
172
|
+
const commands = mockInterpreter.commands.run.mock.calls.map(([command]) => String(command));
|
|
173
|
+
expect(commands.filter((command) => command.includes("@qoder-ai/qodercli"))).toHaveLength(3);
|
|
174
|
+
expect(commands.some((command) => command.includes("python3 -c"))).toBe(true);
|
|
175
|
+
expect(commands.some((command) => command.includes("ln -s /workspace/.kb/faq-support/"))).toBe(true);
|
|
176
|
+
|
|
177
|
+
const uploaded = mockInterpreter.files.writeFiles.mock.calls[0][0] as Array<{ path: string; data: Uint8Array }>;
|
|
178
|
+
expect(uploaded).toEqual(expect.arrayContaining([
|
|
179
|
+
expect.objectContaining({ path: "/workspace/.request/AGENTS.md" }),
|
|
180
|
+
expect.objectContaining({ path: "/workspace/.request/owner-map.json" }),
|
|
181
|
+
]));
|
|
182
|
+
const archive = uploaded.find((entry) => entry.path.endsWith(".zip"));
|
|
183
|
+
expect(archive).toBeDefined();
|
|
184
|
+
const archiveFiles = unzipSync(archive!.data);
|
|
185
|
+
expect(new TextDecoder().decode(archiveFiles["wiki/_index.md"])).toBe("# first index");
|
|
186
|
+
|
|
187
|
+
expect(agents[0]._client.newAcpSession).toHaveBeenCalledWith("/workspace");
|
|
188
|
+
expect(agents[0]._client.query).toHaveBeenCalledWith("prompt", "session-1");
|
|
189
|
+
expect(result).toMatchObject({ sandboxId: "sandbox-1", sessionId: "session-1" });
|
|
190
|
+
expect(result.response).toContain('"action":"reply"');
|
|
191
|
+
expect(logs).toContain("沙箱:ACP Session 已创建 sandboxId=sandbox-1 sessionId=session-1");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("启动时清理同 workspace 的全部遗留 Sandbox", async () => {
|
|
195
|
+
manager.listSandboxInfos.mockResolvedValue({
|
|
196
|
+
items: [{ id: "old-1" }, { id: "old-2" }],
|
|
197
|
+
});
|
|
198
|
+
const pool = new SupportQaSandboxAgentPool(2);
|
|
199
|
+
try {
|
|
200
|
+
await pool.run("prompt", local.workDir, () => {});
|
|
201
|
+
expect(manager.listSandboxInfos).toHaveBeenCalledWith(expect.objectContaining({
|
|
202
|
+
states: ["Running"],
|
|
203
|
+
metadata: expect.objectContaining({ application: "aitable-agent", workload: "support-qa" }),
|
|
204
|
+
}));
|
|
205
|
+
expect(manager.killSandbox).toHaveBeenCalledTimes(2);
|
|
206
|
+
expect(manager.killSandbox).toHaveBeenCalledWith("old-1");
|
|
207
|
+
expect(manager.killSandbox).toHaveBeenCalledWith("old-2");
|
|
208
|
+
expect(Sandbox.create).toHaveBeenCalledTimes(2);
|
|
209
|
+
} finally {
|
|
210
|
+
await pool.stop(() => {});
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("同一进程的后续请求复用已启动的 Sandbox 池", async () => {
|
|
215
|
+
const pool = new SupportQaSandboxAgentPool(2);
|
|
216
|
+
try {
|
|
217
|
+
await pool.run("first", local.workDir, () => {});
|
|
218
|
+
await pool.run("second", local.workDir, () => {});
|
|
219
|
+
expect(manager.listSandboxInfos).toHaveBeenCalledTimes(1);
|
|
220
|
+
expect(Sandbox.create).toHaveBeenCalledTimes(2);
|
|
221
|
+
expect(QoderRemoteAgent).toHaveBeenCalledTimes(2);
|
|
222
|
+
expect(agents[0]._client.query).toHaveBeenCalledTimes(2);
|
|
223
|
+
expect(pool.status()).toMatchObject({
|
|
224
|
+
capacity: 2,
|
|
225
|
+
busy: 0,
|
|
226
|
+
idle: 2,
|
|
227
|
+
sandboxIds: ["sandbox-1", "sandbox-2"],
|
|
228
|
+
});
|
|
229
|
+
} finally {
|
|
230
|
+
await pool.stop(() => {});
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("生命周期重建先释放旧 Sandbox,再创建替代实例", async () => {
|
|
235
|
+
const pool = new SupportQaSandboxAgentPool(1);
|
|
236
|
+
try {
|
|
237
|
+
await pool.run("first", local.workDir, () => {});
|
|
238
|
+
const firstSandbox = rawSandboxes[0];
|
|
239
|
+
|
|
240
|
+
await (pool as unknown as { rebuild(log: (message: string) => void): Promise<void> }).rebuild(() => {});
|
|
241
|
+
|
|
242
|
+
expect(Sandbox.create).toHaveBeenCalledTimes(2);
|
|
243
|
+
expect(firstSandbox.kill).toHaveBeenCalledTimes(1);
|
|
244
|
+
expect(firstSandbox.kill.mock.invocationCallOrder[0]).toBeLessThan(
|
|
245
|
+
vi.mocked(Sandbox.create).mock.invocationCallOrder[1],
|
|
246
|
+
);
|
|
247
|
+
expect(pool.status().sandboxIds).toEqual(["sandbox-2"]);
|
|
248
|
+
} finally {
|
|
249
|
+
await pool.stop(() => {});
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("上传消息图片并重写 prompt,忽略远程 URL 与目录外路径", async () => {
|
|
254
|
+
const imageData = Buffer.from("local image");
|
|
255
|
+
const localImage = path.join(local.mediaDir, "question.PNG");
|
|
256
|
+
const outsideImage = path.join(local.root, "outside.png");
|
|
257
|
+
writeFileSync(localImage, imageData);
|
|
258
|
+
writeFileSync(outsideImage, "outside");
|
|
259
|
+
const digest = createHash("sha256").update(imageData).digest("hex").slice(0, 20);
|
|
260
|
+
const sandboxImage = `/workspace/.media/${digest}.png`;
|
|
261
|
+
const remoteImage = "https://example.test/image.png";
|
|
262
|
+
const prompt = [
|
|
263
|
+
`第一张 `,
|
|
264
|
+
`重复引用 `,
|
|
265
|
+
`远程图片 `,
|
|
266
|
+
`目录外图片 `,
|
|
267
|
+
].join("\n");
|
|
268
|
+
const logs: string[] = [];
|
|
269
|
+
|
|
270
|
+
await runSandboxAgent(prompt, local.workDir, (message) => logs.push(message));
|
|
271
|
+
|
|
272
|
+
const imageUploads = mockInterpreter.files.writeFiles.mock.calls
|
|
273
|
+
.flatMap(([entries]) => entries as Array<{ path: string; data: Uint8Array }>)
|
|
274
|
+
.filter((entry) => entry.path.startsWith("/workspace/.media/"));
|
|
275
|
+
expect(imageUploads).toHaveLength(1);
|
|
276
|
+
expect(imageUploads[0]).toEqual({ path: sandboxImage, data: imageData });
|
|
277
|
+
|
|
278
|
+
const rewrittenPrompt = agents[0]._client.query.mock.calls[0][0] as string;
|
|
279
|
+
expect(rewrittenPrompt.match(new RegExp(sandboxImage, "g"))).toHaveLength(2);
|
|
280
|
+
expect(rewrittenPrompt).not.toContain(localImage);
|
|
281
|
+
expect(rewrittenPrompt).toContain(remoteImage);
|
|
282
|
+
expect(rewrittenPrompt).toContain(outsideImage);
|
|
283
|
+
expect(logs).toContain("沙箱:已同步消息图片 1 张");
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("知识库未变化时不重复上传归档,内容变化后上传新版本", async () => {
|
|
287
|
+
await runSandboxAgent("p1", local.workDir, () => {});
|
|
288
|
+
await runSandboxAgent("p2", local.workDir, () => {});
|
|
289
|
+
writeFileSync(local.indexPath, "# second index");
|
|
290
|
+
await runSandboxAgent("p3", local.workDir, () => {});
|
|
291
|
+
|
|
292
|
+
const archives = mockInterpreter.files.writeFiles.mock.calls.flatMap(([entries]) =>
|
|
293
|
+
(entries as Array<{ path: string }>).filter((entry) => entry.path.endsWith(".zip")),
|
|
294
|
+
);
|
|
295
|
+
expect(archives).toHaveLength(2);
|
|
296
|
+
expect(archives[0].path).not.toBe(archives[1].path);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("status 反映当前并发占用", async () => {
|
|
300
|
+
let resolveQuery: ((value: { stopReason: string; raw: { text: string } }) => void) | undefined;
|
|
301
|
+
configureAgent = (agent) => {
|
|
302
|
+
agent._client.addUpdateListener.mockImplementation(() => () => {});
|
|
303
|
+
agent._client.query.mockImplementation(() => new Promise((resolve) => {
|
|
304
|
+
resolveQuery = resolve;
|
|
305
|
+
}));
|
|
306
|
+
};
|
|
307
|
+
const pool = new SupportQaSandboxAgentPool(1);
|
|
308
|
+
const running = pool.run("prompt", local.workDir, () => {});
|
|
309
|
+
await vi.waitFor(() => expect(agents[0]._client.query).toHaveBeenCalledTimes(1));
|
|
310
|
+
expect(pool.status()).toMatchObject({ capacity: 1, busy: 1, idle: 0, sandboxIds: ["sandbox-1"] });
|
|
311
|
+
|
|
312
|
+
resolveQuery?.({ stopReason: "end_turn", raw: { text: "done" } });
|
|
313
|
+
await running;
|
|
314
|
+
expect(pool.status()).toMatchObject({ capacity: 1, busy: 0, idle: 1 });
|
|
315
|
+
await pool.stop(() => {});
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("ACP 查询失败时在错误中保留模型已输出的内容", async () => {
|
|
319
|
+
configureAgent = (agent) => {
|
|
320
|
+
agent._client.query.mockImplementation(async (_prompt: string, sessionId: string) => {
|
|
321
|
+
agent._client._emitText(sessionId, "partial model output");
|
|
322
|
+
throw new Error("connection reset");
|
|
323
|
+
});
|
|
324
|
+
};
|
|
325
|
+
const pool = new SupportQaSandboxAgentPool(1);
|
|
326
|
+
await expect(pool.run("prompt", local.workDir, () => {})).rejects.toThrow(
|
|
327
|
+
'responseLength=20, response="partial model output", error=connection reset',
|
|
328
|
+
);
|
|
329
|
+
await pool.stop(() => {});
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it("Agent 返回空响应时抛出错误", async () => {
|
|
333
|
+
configureAgent = (agent) => {
|
|
334
|
+
agent._client.query.mockResolvedValue({ stopReason: "end_turn", raw: {} });
|
|
335
|
+
agent._client.addUpdateListener.mockImplementation(() => () => {});
|
|
336
|
+
};
|
|
337
|
+
const pool = new SupportQaSandboxAgentPool(1);
|
|
338
|
+
await expect(pool.run("prompt", local.workDir, () => {})).rejects.toThrow("空响应");
|
|
339
|
+
await pool.stop(() => {});
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
describe("stopSandboxAgentPool", () => {
|
|
344
|
+
it("停止时释放每个 Agent 与 Sandbox", async () => {
|
|
345
|
+
process.env = { ...process.env, SANDBOX_API_KEY: "k", QODER_PERSONAL_ACCESS_TOKEN: "p" };
|
|
346
|
+
const local = createLocalWorkspace();
|
|
347
|
+
const interpreter = {
|
|
348
|
+
commands: { run: vi.fn().mockResolvedValue({ exitCode: 0, logs: { stdout: [], stderr: [] } }) },
|
|
349
|
+
files: { readFile: vi.fn(), writeFiles: vi.fn().mockResolvedValue(undefined) },
|
|
350
|
+
};
|
|
351
|
+
const sandboxes = [createRawSandbox("sandbox-a"), createRawSandbox("sandbox-b")];
|
|
352
|
+
const agents: ReturnType<typeof createMockAgent>[] = [];
|
|
353
|
+
vi.mocked(SandboxManager.create).mockReturnValue({
|
|
354
|
+
listSandboxInfos: vi.fn().mockResolvedValue({ items: [] }),
|
|
355
|
+
killSandbox: vi.fn(),
|
|
356
|
+
close: vi.fn().mockResolvedValue(undefined),
|
|
357
|
+
} as unknown as SandboxManager);
|
|
358
|
+
vi.mocked(Sandbox.create).mockImplementation(async () => sandboxes.shift() as unknown as Sandbox);
|
|
359
|
+
vi.mocked(CodeInterpreter.fromSandbox).mockResolvedValue(
|
|
360
|
+
interpreter as unknown as Awaited<ReturnType<typeof CodeInterpreter.fromSandbox>>,
|
|
361
|
+
);
|
|
362
|
+
vi.mocked(QoderRemoteAgent).mockImplementation((sandbox) => {
|
|
363
|
+
const agent = createMockAgent(sandbox as SandboxClient);
|
|
364
|
+
agents.push(agent);
|
|
365
|
+
return agent as unknown as QoderRemoteAgent;
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
const pool = new SupportQaSandboxAgentPool(2);
|
|
369
|
+
const ownedSandboxes = [...sandboxes];
|
|
370
|
+
try {
|
|
371
|
+
await pool.run("p", local.workDir, () => {});
|
|
372
|
+
await pool.stop(() => {});
|
|
373
|
+
expect(agents.every((agent) => agent.stop.mock.calls.length === 1)).toBe(true);
|
|
374
|
+
expect(ownedSandboxes.every((sandbox) => sandbox.kill.mock.calls.length === 1)).toBe(true);
|
|
375
|
+
expect(ownedSandboxes.every((sandbox) => sandbox.close.mock.calls.length === 1)).toBe(true);
|
|
376
|
+
} finally {
|
|
377
|
+
rmSync(local.root, { recursive: true, force: true });
|
|
378
|
+
await pool.stop(() => {});
|
|
379
|
+
await stopSandboxAgentPool(() => {});
|
|
380
|
+
}
|
|
381
|
+
});
|
|
382
|
+
});
|