aitable-workflow-cli 0.1.23 → 0.1.24
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 +1 -6
- package/dist/{chunk-IGB6WDLR.js → chunk-UNPV6DTX.js} +1 -1
- package/dist/cli.js +47 -47
- package/dist/config-set-FBOQBRSJ.js +13 -0
- package/dist/{config-ui-server-CORDAOYI.js → config-ui-server-IUSACAOT.js} +1 -1
- package/package.json +3 -3
- package/dist/config-set-EGLLIMVJ.js +0 -13
- package/templates/support-qa/.env.example +0 -16
- package/templates/support-qa/aitable-workflow.config.yml +0 -46
- package/templates/support-qa/coding-agents.config.json +0 -18
- package/templates/support-qa/lib/__tests__/declarative-matcher.test.ts +0 -240
- package/templates/support-qa/lib/__tests__/dingtalk-group-membership.test.ts +0 -144
- package/templates/support-qa/lib/__tests__/memory-events.test.ts +0 -90
- package/templates/support-qa/lib/__tests__/sandbox-agent.test.ts +0 -382
- package/templates/support-qa/lib/__tests__/thread-adapter.test.ts +0 -242
- package/templates/support-qa/lib/__tests__/thread-observe-step.test.ts +0 -391
- package/templates/support-qa/lib/__tests__/thread-observer-handler.test.ts +0 -274
- package/templates/support-qa/lib/action-dispatch.ts +0 -262
- package/templates/support-qa/lib/contract.ts +0 -253
- package/templates/support-qa/lib/declarative-matcher.ts +0 -398
- package/templates/support-qa/lib/dingtalk-group-membership.ts +0 -158
- package/templates/support-qa/lib/guards.ts +0 -18
- package/templates/support-qa/lib/memory-events.ts +0 -113
- package/templates/support-qa/lib/owner-resolver.ts +0 -94
- package/templates/support-qa/lib/preflight.ts +0 -294
- package/templates/support-qa/lib/profile.ts +0 -51
- package/templates/support-qa/lib/sandbox-agent.ts +0 -653
- package/templates/support-qa/lib/thread-adapter.ts +0 -281
- package/templates/support-qa/lib/thread-digest.ts +0 -144
- package/templates/support-qa/npmrc +0 -1
- package/templates/support-qa/package.json +0 -27
- package/templates/support-qa/profiles/aitable/profile/escalation/contacts.json +0 -37
- package/templates/support-qa/profiles/aitable/profile/escalation/owner-map.json +0 -18
- package/templates/support-qa/profiles/aitable/profile/fastpath/known-answers.yml +0 -561
- package/templates/support-qa/profiles/aitable/profile/profile.yml +0 -219
- package/templates/support-qa/profiles/aitable/profile/prompts/kb-editor.md +0 -3
- package/templates/support-qa/profiles/aitable/profile/prompts/persona.md +0 -49
- package/templates/support-qa/profiles/aitable/profile/prompts/troubleshooter.md +0 -5
- package/templates/support-qa/profiles/aitable/profile/troubleshoot/classifiers.yml +0 -36
- package/templates/support-qa/profiles/aitable/skills/troubleshooting/SKILL.md +0 -58
- package/templates/support-qa/profiles/aitable/skills/troubleshooting/aitable-import-troubleshooter/SKILL.md +0 -485
- package/templates/support-qa/profiles/aitable/skills/troubleshooting/aitable-openapi-troubleshooter/SKILL.md +0 -183
- package/templates/support-qa/profiles/aitable/skills/troubleshooting/import-openapi-e2e-troubleshooter/SKILL.md +0 -459
- package/templates/support-qa/profiles/aitable/skills/troubleshooting/notable-datasource-troubleshooter/SKILL.md +0 -1286
- package/templates/support-qa/profiles/aitable/skills/troubleshooting/notable-field-troubleshooter/SKILL.md +0 -673
- package/templates/support-qa/profiles/aitable/skills/troubleshooting/spreadsheet-datasync-troubleshooter/SKILL.md +0 -322
- package/templates/support-qa/profiles/default/profile/fastpath/known-answers.yml +0 -32
- package/templates/support-qa/profiles/default/profile/profile.yml +0 -217
- package/templates/support-qa/profiles/default/profile/troubleshoot/classifiers.yml +0 -37
- package/templates/support-qa/profiles/default/wiki/support//345/270/270/350/247/201/351/227/256/351/242/230.md +0 -10
- package/templates/support-qa/tsconfig.json +0 -16
- package/templates/support-qa/vitest.config.ts +0 -9
- package/templates/support-qa/workflows/admin-kb-update/recipes/kb-refine.recipe.ts +0 -137
- package/templates/support-qa/workflows/admin-kb-update/recipes/kb_sync_handler.recipe.ts +0 -119
- package/templates/support-qa/workflows/admin-kb-update/workflow.yml +0 -96
- package/templates/support-qa/workflows/config-sync/recipes/config_sync.recipe.ts +0 -668
- package/templates/support-qa/workflows/config-sync/recipes/config_sync_handler.recipe.ts +0 -16
- package/templates/support-qa/workflows/config-sync/workflow.yml +0 -62
- package/templates/support-qa/workflows/daily-review/recipes/daily_review.recipe.ts +0 -602
- package/templates/support-qa/workflows/daily-review/recipes/daily_review_handler.recipe.ts +0 -26
- package/templates/support-qa/workflows/daily-review/workflow.yml +0 -65
- package/templates/support-qa/workflows/group-qa/recipes/answer.recipe.ts +0 -231
- package/templates/support-qa/workflows/group-qa/recipes/conversation_handler.recipe.ts +0 -394
- package/templates/support-qa/workflows/group-qa/recipes/fastpath.recipe.ts +0 -72
- package/templates/support-qa/workflows/group-qa/recipes/on-action.recipe.ts +0 -225
- package/templates/support-qa/workflows/group-qa/recipes/reply-to-group.recipe.ts +0 -202
- package/templates/support-qa/workflows/group-qa/recipes/troubleshoot.recipe.ts +0 -273
- package/templates/support-qa/workflows/group-qa/workflow.yml +0 -268
- package/templates/support-qa/workflows/thread-observer/README.md +0 -96
- package/templates/support-qa/workflows/thread-observer/recipes/thread_observe.recipe.ts +0 -415
- package/templates/support-qa/workflows/thread-observer/recipes/thread_observer_handler.recipe.ts +0 -213
- package/templates/support-qa/workflows/thread-observer/workflow.yml +0 -185
- package/templates/support-qa/workspaces/daily-review/AGENTS.md +0 -233
- package/templates/support-qa/workspaces/kb-editor/AGENTS.md +0 -32
- package/templates/support-qa/workspaces/support/AGENTS.md +0 -156
- package/templates/support-qa/workspaces/thread-observer/AGENTS.md +0 -156
- package/templates/support-qa/workspaces/troubleshooter/AGENTS.md +0 -71
|
@@ -1,382 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,242 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* thread-adapter 的行为断言 —— support-qa payload → core thread 契约。
|
|
3
|
-
*
|
|
4
|
-
* 两条最容易回退的点,用例刻意钉死:
|
|
5
|
-
* ① **逐条拆 `payload.messages`**,不能用 `payload.content`(那是同一人多条消息拼出来的
|
|
6
|
-
* 多行文本,用它当一条会把每条自己的 openMessageId 与 quotedMessageId 全丢掉);
|
|
7
|
-
* ② **名单为空时降级不抛** —— `${globalVar.X}` 在首轮 config-sync 发布前解为 undefined。
|
|
8
|
-
*/
|
|
9
|
-
import { describe, expect, it } from "vitest";
|
|
10
|
-
import {
|
|
11
|
-
loadObserverRosters,
|
|
12
|
-
normalizeEventMessages,
|
|
13
|
-
parseGroupNames,
|
|
14
|
-
parseIdentities,
|
|
15
|
-
parseModuleTaxonomy,
|
|
16
|
-
parseThreadOptions,
|
|
17
|
-
resolveIdentity,
|
|
18
|
-
rosterStatus,
|
|
19
|
-
} from "../thread-adapter.js";
|
|
20
|
-
|
|
21
|
-
const CID = "cidTEST==";
|
|
22
|
-
|
|
23
|
-
function payload(messages: Array<Record<string, unknown>>, extra: Record<string, unknown> = {}): Record<string, unknown> {
|
|
24
|
-
return {
|
|
25
|
-
conversationId: CID,
|
|
26
|
-
content: messages.map((m) => String(m.content ?? "")).join("\n"),
|
|
27
|
-
messages,
|
|
28
|
-
...extra,
|
|
29
|
-
};
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const ROSTERS = loadObserverRosters({
|
|
33
|
-
bots: [{ groupId: CID, botNick: "答疑助理" }],
|
|
34
|
-
answerers: [{ nick: "答疑甲", openDingTalkId: "openB" }],
|
|
35
|
-
groupConfigs: [{ groupId: CID, groupName: "产品交流群" }],
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
describe("parseIdentities", () => {
|
|
39
|
-
it("兼容字符串数组 / {nick,openDingTalkId} / {botNick} 三种形状", () => {
|
|
40
|
-
expect(parseIdentities(["甲", " 乙 "]).nicks).toEqual(new Set(["甲", "乙"]));
|
|
41
|
-
const s = parseIdentities([{ nick: "丙", openDingTalkId: "openC" }, { botNick: "机器人" }]);
|
|
42
|
-
expect(s.nicks).toEqual(new Set(["丙", "机器人"]));
|
|
43
|
-
expect(s.ids).toEqual(new Set(["openC"]));
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
it("非数组(含 undefined)降级为空集合,不抛 —— globalVar 首轮为空", () => {
|
|
47
|
-
expect(parseIdentities(undefined).nicks.size).toBe(0);
|
|
48
|
-
expect(parseIdentities(null).ids.size).toBe(0);
|
|
49
|
-
expect(parseIdentities("不是数组").nicks.size).toBe(0);
|
|
50
|
-
});
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
describe("resolveIdentity / rosterStatus", () => {
|
|
54
|
-
const bots = parseIdentities(["答疑助理"]);
|
|
55
|
-
const answerers = parseIdentities([{ nick: "答疑甲", openDingTalkId: "openB" }]);
|
|
56
|
-
|
|
57
|
-
it("bot 优先于 answerer(否则机器人秒回会被当成真人响应)", () => {
|
|
58
|
-
const both = parseIdentities(["答疑助理"]);
|
|
59
|
-
expect(resolveIdentity(bots, both, "答疑助理", "")).toBe("bot");
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
it("openId 命中即算 answerer,昵称改了也不影响", () => {
|
|
63
|
-
expect(resolveIdentity(bots, answerers, "换了个花名", "openB")).toBe("answerer");
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it("都不命中 → user", () => {
|
|
67
|
-
expect(resolveIdentity(bots, answerers, "某用户", "openX")).toBe("user");
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
it("bots 缺失优先报(污染面比 answerers 缺失更大)", () => {
|
|
71
|
-
expect(rosterStatus(parseIdentities([]), answerers)).toBe("missing_bots");
|
|
72
|
-
expect(rosterStatus(bots, parseIdentities([]))).toBe("missing_answerers");
|
|
73
|
-
expect(rosterStatus(bots, answerers)).toBe("ok");
|
|
74
|
-
});
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
describe("normalizeEventMessages", () => {
|
|
78
|
-
it("一个 event 含同一人多条消息 → 逐条拆开,各自保留 openMessageId 与引用关系", () => {
|
|
79
|
-
const items = normalizeEventMessages(
|
|
80
|
-
payload([
|
|
81
|
-
{ messageId: "m1", content: "这单怎么还没成功", createTime: "2026-08-11T09:54:00+08:00", senderName: "用户甲" },
|
|
82
|
-
{ messageId: "m2", content: "有备注的", createTime: "2026-08-11T09:55:00+08:00", senderName: "用户甲", quotedMessageId: "m0" },
|
|
83
|
-
]),
|
|
84
|
-
ROSTERS,
|
|
85
|
-
);
|
|
86
|
-
expect(items.map((i) => i.message.openMessageId)).toEqual(["m1", "m2"]);
|
|
87
|
-
expect(items[0]!.message.quotedMessageId).toBeUndefined();
|
|
88
|
-
expect(items[1]!.message.quotedMessageId).toBe("m0");
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
it("无 openMessageId 的消息被丢弃(既无法归并也无法溯源)", () => {
|
|
92
|
-
const items = normalizeEventMessages(
|
|
93
|
-
payload([{ content: "没有 id", createTime: "2026-08-11T09:54:00+08:00", senderName: "用户甲" }]),
|
|
94
|
-
ROSTERS,
|
|
95
|
-
);
|
|
96
|
-
expect(items).toEqual([]);
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
it("既无正文又无图片的消息被丢弃(文件/系统消息)", () => {
|
|
100
|
-
const items = normalizeEventMessages(
|
|
101
|
-
payload([{ messageId: "m1", content: " ", createTime: "2026-08-11T09:54:00+08:00", senderName: "用户甲" }]),
|
|
102
|
-
ROSTERS,
|
|
103
|
-
);
|
|
104
|
-
expect(items).toEqual([]);
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
it("纯图片消息保留(正文空但有 images)", () => {
|
|
108
|
-
const items = normalizeEventMessages(
|
|
109
|
-
payload([{ messageId: "m1", content: "", images: ["/abs/p.png"], createTime: "2026-08-11T09:54:00+08:00", senderName: "用户甲" }]),
|
|
110
|
-
ROSTERS,
|
|
111
|
-
);
|
|
112
|
-
expect(items).toHaveLength(1);
|
|
113
|
-
expect(items[0]!.message.images).toEqual(["/abs/p.png"]);
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
it("bot 消息标 role=bot —— core 靠它避免把机器人当成提问人锚点", () => {
|
|
117
|
-
const items = normalizeEventMessages(
|
|
118
|
-
payload([{ messageId: "m1", content: "已收到", createTime: "2026-08-11T09:54:00+08:00", senderName: "答疑助理" }]),
|
|
119
|
-
ROSTERS,
|
|
120
|
-
);
|
|
121
|
-
expect(items[0]!.identity).toBe("bot");
|
|
122
|
-
expect(items[0]!.message.role).toBe("bot");
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
it("answerer 消息 role 仍是 unknown(谁是 asker 要等 thread 成型才知道)", () => {
|
|
126
|
-
const items = normalizeEventMessages(
|
|
127
|
-
payload([{ messageId: "m1", content: "我看看", createTime: "2026-08-11T09:54:00+08:00", senderName: "答疑甲", senderOpenDingTalkId: "openB" }]),
|
|
128
|
-
ROSTERS,
|
|
129
|
-
);
|
|
130
|
-
expect(items[0]!.identity).toBe("answerer");
|
|
131
|
-
expect(items[0]!.message.role).toBe("unknown");
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
it("群名从群配置按 groupId 反查 —— dingtalk_message 源不返回群名", () => {
|
|
135
|
-
const items = normalizeEventMessages(
|
|
136
|
-
payload([{ messageId: "m1", content: "问题", createTime: "2026-08-11T09:54:00+08:00", senderName: "用户甲" }]),
|
|
137
|
-
ROSTERS,
|
|
138
|
-
);
|
|
139
|
-
expect(items[0]!.message.groupName).toBe("产品交流群");
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
it("事件源给了 conversationTitle 时优先用它(list-all 源有)", () => {
|
|
143
|
-
const items = normalizeEventMessages(
|
|
144
|
-
payload(
|
|
145
|
-
[{ messageId: "m1", content: "问题", createTime: "2026-08-11T09:54:00+08:00", senderName: "用户甲" }],
|
|
146
|
-
{ conversationTitle: "源给的群名" },
|
|
147
|
-
),
|
|
148
|
-
ROSTERS,
|
|
149
|
-
);
|
|
150
|
-
expect(items[0]!.message.groupName).toBe("源给的群名");
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
it("群名都取不到时给空串而非 undefined,避免写表时整列被跳过", () => {
|
|
154
|
-
const items = normalizeEventMessages(
|
|
155
|
-
{ conversationId: "cidUnknown==", messages: [{ messageId: "m1", content: "问题", createTime: "2026-08-11T09:54:00+08:00", senderName: "用户甲" }] },
|
|
156
|
-
ROSTERS,
|
|
157
|
-
);
|
|
158
|
-
expect(items[0]!.message.groupName).toBe("");
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
it("按时间升序产出:乱序会把追问判成新 thread", () => {
|
|
162
|
-
const items = normalizeEventMessages(
|
|
163
|
-
payload([
|
|
164
|
-
{ messageId: "late", content: "后发", createTime: "2026-08-11T10:00:00+08:00", senderName: "用户甲" },
|
|
165
|
-
{ messageId: "early", content: "先发", createTime: "2026-08-11T09:00:00+08:00", senderName: "用户甲" },
|
|
166
|
-
]),
|
|
167
|
-
ROSTERS,
|
|
168
|
-
);
|
|
169
|
-
expect(items.map((i) => i.message.openMessageId)).toEqual(["early", "late"]);
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
it("缺 conversationId 的 payload 直接返回空(无法归群)", () => {
|
|
173
|
-
expect(normalizeEventMessages({ messages: [{ messageId: "m1", content: "x" }] }, ROSTERS)).toEqual([]);
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
it("时间解析不出来时回落当下,不给 0(0 会在时间排序里跑到所有 thread 之前)", () => {
|
|
177
|
-
const before = Date.now();
|
|
178
|
-
const items = normalizeEventMessages(
|
|
179
|
-
payload([{ messageId: "m1", content: "问题", createTime: "不是时间", senderName: "用户甲" }]),
|
|
180
|
-
ROSTERS,
|
|
181
|
-
);
|
|
182
|
-
expect(items[0]!.message.createTime).toBeGreaterThanOrEqual(before);
|
|
183
|
-
});
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
describe("parseGroupNames", () => {
|
|
187
|
-
it("没填群名的群不进 map(下游降级为空串)", () => {
|
|
188
|
-
const m = parseGroupNames([{ groupId: "a", groupName: "甲群" }, { groupId: "b" }]);
|
|
189
|
-
expect(m.get("a")).toBe("甲群");
|
|
190
|
-
expect(m.has("b")).toBe(false);
|
|
191
|
-
});
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
describe("parseModuleTaxonomy", () => {
|
|
195
|
-
it("从 [{module, owners}] 取模块名", () => {
|
|
196
|
-
expect(parseModuleTaxonomy([{ module: "表单", owners: ["甲"] }, { module: "自动化" }])).toBe("表单 / 自动化");
|
|
197
|
-
});
|
|
198
|
-
|
|
199
|
-
it("兼容 {模块名: [...]} 形状", () => {
|
|
200
|
-
expect(parseModuleTaxonomy({ 表单: ["甲"], 视图: ["乙"] })).toBe("表单 / 视图");
|
|
201
|
-
});
|
|
202
|
-
|
|
203
|
-
it("去重", () => {
|
|
204
|
-
expect(parseModuleTaxonomy([{ module: "表单" }, { module: "表单" }])).toBe("表单");
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
it("取不到时给空串(agent 侧据此省略 module,而不是乱造一个)", () => {
|
|
208
|
-
expect(parseModuleTaxonomy(undefined)).toBe("");
|
|
209
|
-
expect(parseModuleTaxonomy([])).toBe("");
|
|
210
|
-
});
|
|
211
|
-
});
|
|
212
|
-
|
|
213
|
-
describe("parseThreadOptions", () => {
|
|
214
|
-
it("分钟/小时换算成 ms", () => {
|
|
215
|
-
expect(parseThreadOptions({ thread_idle_close_min: 15, thread_max_open_hour: 3 })).toMatchObject({
|
|
216
|
-
idleCloseMs: 15 * 60_000,
|
|
217
|
-
maxOpenMs: 3 * 3_600_000,
|
|
218
|
-
});
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
it("归并开关默认不开:必须在 profile 显式打开(误合比漏合严重)", () => {
|
|
222
|
-
const opts = parseThreadOptions({});
|
|
223
|
-
expect(opts.enableMentionMerge).toBeUndefined();
|
|
224
|
-
expect(opts.enableHeuristicMerge).toBeUndefined();
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
it("显式 true 才打开;非布尔值不当真", () => {
|
|
228
|
-
expect(parseThreadOptions({ thread_enable_mention_merge: true }).enableMentionMerge).toBe(true);
|
|
229
|
-
expect(parseThreadOptions({ thread_enable_mention_merge: "true" }).enableMentionMerge).toBeUndefined();
|
|
230
|
-
});
|
|
231
|
-
|
|
232
|
-
it("不含 {name} 的 addressPattern 被丢弃(它无法定位被称呼者)", () => {
|
|
233
|
-
expect(parseThreadOptions({ thread_address_patterns: ["Hi {name}", "没有占位符"] })).toMatchObject({
|
|
234
|
-
addressPatterns: ["Hi {name}"],
|
|
235
|
-
});
|
|
236
|
-
});
|
|
237
|
-
|
|
238
|
-
it("非正数不当真,避免 0 或负数让 thread 立刻收敛", () => {
|
|
239
|
-
expect(parseThreadOptions({ thread_idle_close_min: 0 }).idleCloseMs).toBeUndefined();
|
|
240
|
-
expect(parseThreadOptions({ thread_max_open_hour: -1 }).maxOpenMs).toBeUndefined();
|
|
241
|
-
});
|
|
242
|
-
});
|