mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 23:21:36 +00:00
refactor(tests): dedupe tool, projector, and delivery fixtures
This commit is contained in:
@@ -64,6 +64,21 @@ function stubMinimaxOkFetch() {
|
|||||||
return fetch;
|
return fetch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stubMinimaxFetch(baseResp: { status_code: number; status_msg: string }, content = "ok") {
|
||||||
|
const fetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
statusText: "OK",
|
||||||
|
headers: new Headers(),
|
||||||
|
json: async () => ({
|
||||||
|
content,
|
||||||
|
base_resp: baseResp,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
global.fetch = withFetchPreconnect(fetch);
|
||||||
|
return fetch;
|
||||||
|
}
|
||||||
|
|
||||||
function stubOpenAiCompletionsOkFetch(text = "ok") {
|
function stubOpenAiCompletionsOkFetch(text = "ok") {
|
||||||
const fetch = vi.fn().mockResolvedValue(
|
const fetch = vi.fn().mockResolvedValue(
|
||||||
new Response(
|
new Response(
|
||||||
@@ -120,6 +135,13 @@ function createMinimaxImageConfig(): OpenClawConfig {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createDefaultImageFallbackExpectation(primary: string) {
|
||||||
|
return {
|
||||||
|
primary,
|
||||||
|
fallbacks: ["openai/gpt-5-mini", "anthropic/claude-opus-4-5"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function makeModelDefinition(id: string, input: Array<"text" | "image">): ModelDefinitionConfig {
|
function makeModelDefinition(id: string, input: Array<"text" | "image">): ModelDefinitionConfig {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@@ -156,6 +178,36 @@ function requireImageTool<T>(tool: T | null | undefined): T {
|
|||||||
return tool;
|
return tool;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createRequiredImageTool(args: Parameters<typeof createImageTool>[0]) {
|
||||||
|
return requireImageTool(createImageTool(args));
|
||||||
|
}
|
||||||
|
|
||||||
|
type ImageToolInstance = ReturnType<typeof createRequiredImageTool>;
|
||||||
|
|
||||||
|
async function withTempSandboxState(
|
||||||
|
run: (ctx: { stateDir: string; agentDir: string; sandboxRoot: string }) => Promise<void>,
|
||||||
|
) {
|
||||||
|
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-image-sandbox-"));
|
||||||
|
const agentDir = path.join(stateDir, "agent");
|
||||||
|
const sandboxRoot = path.join(stateDir, "sandbox");
|
||||||
|
await fs.mkdir(agentDir, { recursive: true });
|
||||||
|
await fs.mkdir(sandboxRoot, { recursive: true });
|
||||||
|
try {
|
||||||
|
await run({ stateDir, agentDir, sandboxRoot });
|
||||||
|
} finally {
|
||||||
|
await fs.rm(stateDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withMinimaxImageToolFromTempAgentDir(
|
||||||
|
run: (tool: ImageToolInstance) => Promise<void>,
|
||||||
|
) {
|
||||||
|
await withTempAgentDir(async (agentDir) => {
|
||||||
|
const cfg = createMinimaxImageConfig();
|
||||||
|
await run(createRequiredImageTool({ config: cfg, agentDir }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function findSchemaUnionKeywords(schema: unknown, path = "root"): string[] {
|
function findSchemaUnionKeywords(schema: unknown, path = "root"): string[] {
|
||||||
if (!schema || typeof schema !== "object") {
|
if (!schema || typeof schema !== "object") {
|
||||||
return [];
|
return [];
|
||||||
@@ -214,10 +266,9 @@ describe("image tool implicit imageModel config", () => {
|
|||||||
const cfg: OpenClawConfig = {
|
const cfg: OpenClawConfig = {
|
||||||
agents: { defaults: { model: { primary: "minimax/MiniMax-M2.5" } } },
|
agents: { defaults: { model: { primary: "minimax/MiniMax-M2.5" } } },
|
||||||
};
|
};
|
||||||
expect(resolveImageModelConfigForTool({ cfg, agentDir })).toEqual({
|
expect(resolveImageModelConfigForTool({ cfg, agentDir })).toEqual(
|
||||||
primary: "minimax/MiniMax-VL-01",
|
createDefaultImageFallbackExpectation("minimax/MiniMax-VL-01"),
|
||||||
fallbacks: ["openai/gpt-5-mini", "anthropic/claude-opus-4-5"],
|
);
|
||||||
});
|
|
||||||
expect(createImageTool({ config: cfg, agentDir })).not.toBeNull();
|
expect(createImageTool({ config: cfg, agentDir })).not.toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -230,10 +281,9 @@ describe("image tool implicit imageModel config", () => {
|
|||||||
const cfg: OpenClawConfig = {
|
const cfg: OpenClawConfig = {
|
||||||
agents: { defaults: { model: { primary: "zai/glm-4.7" } } },
|
agents: { defaults: { model: { primary: "zai/glm-4.7" } } },
|
||||||
};
|
};
|
||||||
expect(resolveImageModelConfigForTool({ cfg, agentDir })).toEqual({
|
expect(resolveImageModelConfigForTool({ cfg, agentDir })).toEqual(
|
||||||
primary: "zai/glm-4.6v",
|
createDefaultImageFallbackExpectation("zai/glm-4.6v"),
|
||||||
fallbacks: ["openai/gpt-5-mini", "anthropic/claude-opus-4-5"],
|
);
|
||||||
});
|
|
||||||
expect(createImageTool({ config: cfg, agentDir })).not.toBeNull();
|
expect(createImageTool({ config: cfg, agentDir })).not.toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -383,11 +433,7 @@ describe("image tool implicit imageModel config", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("exposes an Anthropic-safe image schema without union keywords", async () => {
|
it("exposes an Anthropic-safe image schema without union keywords", async () => {
|
||||||
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-image-"));
|
await withMinimaxImageToolFromTempAgentDir(async (tool) => {
|
||||||
try {
|
|
||||||
const cfg = createMinimaxImageConfig();
|
|
||||||
const tool = requireImageTool(createImageTool({ config: cfg, agentDir }));
|
|
||||||
|
|
||||||
const violations = findSchemaUnionKeywords(tool.parameters, "image.parameters");
|
const violations = findSchemaUnionKeywords(tool.parameters, "image.parameters");
|
||||||
expect(violations).toEqual([]);
|
expect(violations).toEqual([]);
|
||||||
|
|
||||||
@@ -403,17 +449,11 @@ describe("image tool implicit imageModel config", () => {
|
|||||||
expect(imageSchema?.type).toBe("string");
|
expect(imageSchema?.type).toBe("string");
|
||||||
expect(imagesSchema?.type).toBe("array");
|
expect(imagesSchema?.type).toBe("array");
|
||||||
expect(imageItems?.type).toBe("string");
|
expect(imageItems?.type).toBe("string");
|
||||||
} finally {
|
});
|
||||||
await fs.rm(agentDir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps an Anthropic-safe image schema snapshot", async () => {
|
it("keeps an Anthropic-safe image schema snapshot", async () => {
|
||||||
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-image-"));
|
await withMinimaxImageToolFromTempAgentDir(async (tool) => {
|
||||||
try {
|
|
||||||
const cfg = createMinimaxImageConfig();
|
|
||||||
const tool = requireImageTool(createImageTool({ config: cfg, agentDir }));
|
|
||||||
|
|
||||||
expect(JSON.parse(JSON.stringify(tool.parameters))).toEqual({
|
expect(JSON.parse(JSON.stringify(tool.parameters))).toEqual({
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
@@ -429,19 +469,16 @@ describe("image tool implicit imageModel config", () => {
|
|||||||
maxImages: { type: "number" },
|
maxImages: { type: "number" },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} finally {
|
});
|
||||||
await fs.rm(agentDir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows workspace images outside default local media roots", async () => {
|
it("allows workspace images outside default local media roots", async () => {
|
||||||
await withTempWorkspacePng(async ({ workspaceDir, imagePath }) => {
|
await withTempWorkspacePng(async ({ workspaceDir, imagePath }) => {
|
||||||
const fetch = stubMinimaxOkFetch();
|
const fetch = stubMinimaxOkFetch();
|
||||||
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-image-"));
|
await withTempAgentDir(async (agentDir) => {
|
||||||
try {
|
|
||||||
const cfg = createMinimaxImageConfig();
|
const cfg = createMinimaxImageConfig();
|
||||||
|
|
||||||
const withoutWorkspace = requireImageTool(createImageTool({ config: cfg, agentDir }));
|
const withoutWorkspace = createRequiredImageTool({ config: cfg, agentDir });
|
||||||
await expect(
|
await expect(
|
||||||
withoutWorkspace.execute("t0", {
|
withoutWorkspace.execute("t0", {
|
||||||
prompt: "Describe the image.",
|
prompt: "Describe the image.",
|
||||||
@@ -449,34 +486,27 @@ describe("image tool implicit imageModel config", () => {
|
|||||||
}),
|
}),
|
||||||
).rejects.toThrow(/Local media path is not under an allowed directory/i);
|
).rejects.toThrow(/Local media path is not under an allowed directory/i);
|
||||||
|
|
||||||
const withWorkspace = requireImageTool(
|
const withWorkspace = createRequiredImageTool({ config: cfg, agentDir, workspaceDir });
|
||||||
createImageTool({ config: cfg, agentDir, workspaceDir }),
|
|
||||||
);
|
|
||||||
|
|
||||||
await expectImageToolExecOk(withWorkspace, imagePath);
|
await expectImageToolExecOk(withWorkspace, imagePath);
|
||||||
|
|
||||||
expect(fetch).toHaveBeenCalledTimes(1);
|
expect(fetch).toHaveBeenCalledTimes(1);
|
||||||
} finally {
|
});
|
||||||
await fs.rm(agentDir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("respects fsPolicy.workspaceOnly for non-sandbox image paths", async () => {
|
it("respects fsPolicy.workspaceOnly for non-sandbox image paths", async () => {
|
||||||
await withTempWorkspacePng(async ({ workspaceDir, imagePath }) => {
|
await withTempWorkspacePng(async ({ workspaceDir, imagePath }) => {
|
||||||
const fetch = stubMinimaxOkFetch();
|
const fetch = stubMinimaxOkFetch();
|
||||||
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-image-"));
|
await withTempAgentDir(async (agentDir) => {
|
||||||
try {
|
|
||||||
const cfg = createMinimaxImageConfig();
|
const cfg = createMinimaxImageConfig();
|
||||||
|
|
||||||
const tool = requireImageTool(
|
const tool = createRequiredImageTool({
|
||||||
createImageTool({
|
config: cfg,
|
||||||
config: cfg,
|
agentDir,
|
||||||
agentDir,
|
workspaceDir,
|
||||||
workspaceDir,
|
fsPolicy: { workspaceOnly: true },
|
||||||
fsPolicy: { workspaceOnly: true },
|
});
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// File inside workspace is allowed.
|
// File inside workspace is allowed.
|
||||||
await expectImageToolExecOk(tool, imagePath);
|
await expectImageToolExecOk(tool, imagePath);
|
||||||
@@ -493,17 +523,14 @@ describe("image tool implicit imageModel config", () => {
|
|||||||
} finally {
|
} finally {
|
||||||
await fs.rm(outsideDir, { recursive: true, force: true });
|
await fs.rm(outsideDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
} finally {
|
});
|
||||||
await fs.rm(agentDir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows workspace images via createOpenClawCodingTools default workspace root", async () => {
|
it("allows workspace images via createOpenClawCodingTools default workspace root", async () => {
|
||||||
await withTempWorkspacePng(async ({ imagePath }) => {
|
await withTempWorkspacePng(async ({ imagePath }) => {
|
||||||
const fetch = stubMinimaxOkFetch();
|
const fetch = stubMinimaxOkFetch();
|
||||||
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-image-"));
|
await withTempAgentDir(async (agentDir) => {
|
||||||
try {
|
|
||||||
const cfg = createMinimaxImageConfig();
|
const cfg = createMinimaxImageConfig();
|
||||||
|
|
||||||
const tools = createOpenClawCodingTools({ config: cfg, agentDir });
|
const tools = createOpenClawCodingTools({ config: cfg, agentDir });
|
||||||
@@ -512,52 +539,44 @@ describe("image tool implicit imageModel config", () => {
|
|||||||
await expectImageToolExecOk(tool, imagePath);
|
await expectImageToolExecOk(tool, imagePath);
|
||||||
|
|
||||||
expect(fetch).toHaveBeenCalledTimes(1);
|
expect(fetch).toHaveBeenCalledTimes(1);
|
||||||
} finally {
|
});
|
||||||
await fs.rm(agentDir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sandboxes image paths like the read tool", async () => {
|
it("sandboxes image paths like the read tool", async () => {
|
||||||
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-image-sandbox-"));
|
await withTempSandboxState(async ({ agentDir, sandboxRoot }) => {
|
||||||
const agentDir = path.join(stateDir, "agent");
|
await fs.writeFile(path.join(sandboxRoot, "img.png"), "fake", "utf8");
|
||||||
const sandboxRoot = path.join(stateDir, "sandbox");
|
const sandbox = { root: sandboxRoot, bridge: createHostSandboxFsBridge(sandboxRoot) };
|
||||||
await fs.mkdir(agentDir, { recursive: true });
|
|
||||||
await fs.mkdir(sandboxRoot, { recursive: true });
|
|
||||||
await fs.writeFile(path.join(sandboxRoot, "img.png"), "fake", "utf8");
|
|
||||||
const sandbox = { root: sandboxRoot, bridge: createHostSandboxFsBridge(sandboxRoot) };
|
|
||||||
|
|
||||||
vi.stubEnv("OPENAI_API_KEY", "openai-test");
|
vi.stubEnv("OPENAI_API_KEY", "openai-test");
|
||||||
const cfg: OpenClawConfig = {
|
const cfg: OpenClawConfig = {
|
||||||
agents: { defaults: { model: { primary: "minimax/MiniMax-M2.5" } } },
|
agents: { defaults: { model: { primary: "minimax/MiniMax-M2.5" } } },
|
||||||
};
|
};
|
||||||
const tool = requireImageTool(createImageTool({ config: cfg, agentDir, sandbox }));
|
const tool = createRequiredImageTool({ config: cfg, agentDir, sandbox });
|
||||||
|
|
||||||
await expect(tool.execute("t1", { image: "https://example.com/a.png" })).rejects.toThrow(
|
await expect(tool.execute("t1", { image: "https://example.com/a.png" })).rejects.toThrow(
|
||||||
/Sandboxed image tool does not allow remote URLs/i,
|
/Sandboxed image tool does not allow remote URLs/i,
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(tool.execute("t2", { image: "../escape.png" })).rejects.toThrow(
|
await expect(tool.execute("t2", { image: "../escape.png" })).rejects.toThrow(
|
||||||
/escapes sandbox root/i,
|
/escapes sandbox root/i,
|
||||||
);
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("applies tools.fs.workspaceOnly to image paths in sandbox mode", async () => {
|
it("applies tools.fs.workspaceOnly to image paths in sandbox mode", async () => {
|
||||||
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-image-sandbox-"));
|
await withTempSandboxState(async ({ agentDir, sandboxRoot }) => {
|
||||||
const agentDir = path.join(stateDir, "agent");
|
await fs.writeFile(
|
||||||
const sandboxRoot = path.join(stateDir, "sandbox");
|
path.join(agentDir, "secret.png"),
|
||||||
await fs.mkdir(agentDir, { recursive: true });
|
Buffer.from(ONE_PIXEL_PNG_B64, "base64"),
|
||||||
await fs.mkdir(sandboxRoot, { recursive: true });
|
);
|
||||||
await fs.writeFile(path.join(agentDir, "secret.png"), Buffer.from(ONE_PIXEL_PNG_B64, "base64"));
|
const sandbox = createUnsafeMountedSandbox({ sandboxRoot, agentRoot: agentDir });
|
||||||
|
const fetch = stubMinimaxOkFetch();
|
||||||
|
const cfg: OpenClawConfig = {
|
||||||
|
...createMinimaxImageConfig(),
|
||||||
|
tools: { fs: { workspaceOnly: true } },
|
||||||
|
};
|
||||||
|
|
||||||
const sandbox = createUnsafeMountedSandbox({ sandboxRoot, agentRoot: agentDir });
|
|
||||||
const fetch = stubMinimaxOkFetch();
|
|
||||||
const cfg: OpenClawConfig = {
|
|
||||||
...createMinimaxImageConfig(),
|
|
||||||
tools: { fs: { workspaceOnly: true } },
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const tools = createOpenClawCodingTools({
|
const tools = createOpenClawCodingTools({
|
||||||
config: cfg,
|
config: cfg,
|
||||||
agentDir,
|
agentDir,
|
||||||
@@ -580,46 +599,40 @@ describe("image tool implicit imageModel config", () => {
|
|||||||
}),
|
}),
|
||||||
).rejects.toThrow(/Path escapes sandbox root/i);
|
).rejects.toThrow(/Path escapes sandbox root/i);
|
||||||
expect(fetch).not.toHaveBeenCalled();
|
expect(fetch).not.toHaveBeenCalled();
|
||||||
} finally {
|
});
|
||||||
await fs.rm(stateDir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rewrites inbound absolute paths into sandbox media/inbound", async () => {
|
it("rewrites inbound absolute paths into sandbox media/inbound", async () => {
|
||||||
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-image-sandbox-"));
|
await withTempSandboxState(async ({ agentDir, sandboxRoot }) => {
|
||||||
const agentDir = path.join(stateDir, "agent");
|
await fs.mkdir(path.join(sandboxRoot, "media", "inbound"), {
|
||||||
const sandboxRoot = path.join(stateDir, "sandbox");
|
recursive: true,
|
||||||
await fs.mkdir(agentDir, { recursive: true });
|
});
|
||||||
await fs.mkdir(path.join(sandboxRoot, "media", "inbound"), {
|
await fs.writeFile(
|
||||||
recursive: true,
|
path.join(sandboxRoot, "media", "inbound", "photo.png"),
|
||||||
});
|
Buffer.from(ONE_PIXEL_PNG_B64, "base64"),
|
||||||
const pngB64 =
|
);
|
||||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/woAAn8B9FD5fHAAAAAASUVORK5CYII=";
|
|
||||||
await fs.writeFile(
|
|
||||||
path.join(sandboxRoot, "media", "inbound", "photo.png"),
|
|
||||||
Buffer.from(pngB64, "base64"),
|
|
||||||
);
|
|
||||||
|
|
||||||
const fetch = stubMinimaxOkFetch();
|
const fetch = stubMinimaxOkFetch();
|
||||||
|
|
||||||
const cfg: OpenClawConfig = {
|
const cfg: OpenClawConfig = {
|
||||||
agents: {
|
agents: {
|
||||||
defaults: {
|
defaults: {
|
||||||
model: { primary: "minimax/MiniMax-M2.5" },
|
model: { primary: "minimax/MiniMax-M2.5" },
|
||||||
imageModel: { primary: "minimax/MiniMax-VL-01" },
|
imageModel: { primary: "minimax/MiniMax-VL-01" },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
};
|
||||||
};
|
const sandbox = { root: sandboxRoot, bridge: createHostSandboxFsBridge(sandboxRoot) };
|
||||||
const sandbox = { root: sandboxRoot, bridge: createHostSandboxFsBridge(sandboxRoot) };
|
const tool = createRequiredImageTool({ config: cfg, agentDir, sandbox });
|
||||||
const tool = requireImageTool(createImageTool({ config: cfg, agentDir, sandbox }));
|
|
||||||
|
|
||||||
const res = await tool.execute("t1", {
|
const res = await tool.execute("t1", {
|
||||||
prompt: "Describe the image.",
|
prompt: "Describe the image.",
|
||||||
image: "@/Users/steipete/.openclaw/media/inbound/photo.png",
|
image: "@/Users/steipete/.openclaw/media/inbound/photo.png",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(1);
|
||||||
|
expect((res.details as { rewrittenFrom?: string }).rewrittenFrom).toContain("photo.png");
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(fetch).toHaveBeenCalledTimes(1);
|
|
||||||
expect((res.details as { rewrittenFrom?: string }).rewrittenFrom).toContain("photo.png");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -658,24 +671,14 @@ describe("image tool MiniMax VLM routing", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function createMinimaxVlmFixture(baseResp: { status_code: number; status_msg: string }) {
|
async function createMinimaxVlmFixture(baseResp: { status_code: number; status_msg: string }) {
|
||||||
const fetch = vi.fn().mockResolvedValue({
|
const fetch = stubMinimaxFetch(baseResp, baseResp.status_code === 0 ? "ok" : "");
|
||||||
ok: true,
|
|
||||||
status: 200,
|
|
||||||
statusText: "OK",
|
|
||||||
headers: new Headers(),
|
|
||||||
json: async () => ({
|
|
||||||
content: baseResp.status_code === 0 ? "ok" : "",
|
|
||||||
base_resp: baseResp,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
global.fetch = withFetchPreconnect(fetch);
|
|
||||||
|
|
||||||
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-minimax-vlm-"));
|
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-minimax-vlm-"));
|
||||||
vi.stubEnv("MINIMAX_API_KEY", "minimax-test");
|
vi.stubEnv("MINIMAX_API_KEY", "minimax-test");
|
||||||
const cfg: OpenClawConfig = {
|
const cfg: OpenClawConfig = {
|
||||||
agents: { defaults: { model: { primary: "minimax/MiniMax-M2.5" } } },
|
agents: { defaults: { model: { primary: "minimax/MiniMax-M2.5" } } },
|
||||||
};
|
};
|
||||||
const tool = requireImageTool(createImageTool({ config: cfg, agentDir }));
|
const tool = createRequiredImageTool({ config: cfg, agentDir });
|
||||||
return { fetch, tool };
|
return { fetch, tool };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ async function withTempAgentDir<T>(run: (agentDir: string) => Promise<T>): Promi
|
|||||||
|
|
||||||
const ANTHROPIC_PDF_MODEL = "anthropic/claude-opus-4-6";
|
const ANTHROPIC_PDF_MODEL = "anthropic/claude-opus-4-6";
|
||||||
const OPENAI_PDF_MODEL = "openai/gpt-5-mini";
|
const OPENAI_PDF_MODEL = "openai/gpt-5-mini";
|
||||||
|
const TEST_PDF_INPUT = { base64: "dGVzdA==", filename: "doc.pdf" } as const;
|
||||||
const FAKE_PDF_MEDIA = {
|
const FAKE_PDF_MEDIA = {
|
||||||
kind: "document",
|
kind: "document",
|
||||||
buffer: Buffer.from("%PDF-1.4 fake"),
|
buffer: Buffer.from("%PDF-1.4 fake"),
|
||||||
@@ -38,6 +39,64 @@ const FAKE_PDF_MEDIA = {
|
|||||||
fileName: "doc.pdf",
|
fileName: "doc.pdf",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
function requirePdfTool(tool: ReturnType<typeof createPdfTool>) {
|
||||||
|
expect(tool).not.toBeNull();
|
||||||
|
if (!tool) {
|
||||||
|
throw new Error("expected pdf tool");
|
||||||
|
}
|
||||||
|
return tool;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PdfToolInstance = ReturnType<typeof requirePdfTool>;
|
||||||
|
|
||||||
|
async function withAnthropicPdfTool(
|
||||||
|
run: (tool: PdfToolInstance, agentDir: string) => Promise<void>,
|
||||||
|
) {
|
||||||
|
await withTempAgentDir(async (agentDir) => {
|
||||||
|
vi.stubEnv("ANTHROPIC_API_KEY", "anthropic-test");
|
||||||
|
const cfg = withDefaultModel(ANTHROPIC_PDF_MODEL);
|
||||||
|
const tool = requirePdfTool(createPdfTool({ config: cfg, agentDir }));
|
||||||
|
await run(tool, agentDir);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeAnthropicAnalyzeParams(
|
||||||
|
overrides: Partial<{
|
||||||
|
apiKey: string;
|
||||||
|
modelId: string;
|
||||||
|
prompt: string;
|
||||||
|
pdfs: Array<{ base64: string; filename: string }>;
|
||||||
|
maxTokens: number;
|
||||||
|
baseUrl: string;
|
||||||
|
}> = {},
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
apiKey: "test-key",
|
||||||
|
modelId: "claude-opus-4-6",
|
||||||
|
prompt: "test",
|
||||||
|
pdfs: [TEST_PDF_INPUT],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeGeminiAnalyzeParams(
|
||||||
|
overrides: Partial<{
|
||||||
|
apiKey: string;
|
||||||
|
modelId: string;
|
||||||
|
prompt: string;
|
||||||
|
pdfs: Array<{ base64: string; filename: string }>;
|
||||||
|
baseUrl: string;
|
||||||
|
}> = {},
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
apiKey: "test-key",
|
||||||
|
modelId: "gemini-2.5-pro",
|
||||||
|
prompt: "test",
|
||||||
|
pdfs: [TEST_PDF_INPUT],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function resetAuthEnv() {
|
function resetAuthEnv() {
|
||||||
vi.stubEnv("OPENAI_API_KEY", "");
|
vi.stubEnv("OPENAI_API_KEY", "");
|
||||||
vi.stubEnv("ANTHROPIC_API_KEY", "");
|
vi.stubEnv("ANTHROPIC_API_KEY", "");
|
||||||
@@ -291,35 +350,23 @@ describe("createPdfTool", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("creates tool when auth is available", async () => {
|
it("creates tool when auth is available", async () => {
|
||||||
await withTempAgentDir(async (agentDir) => {
|
await withAnthropicPdfTool(async (tool) => {
|
||||||
vi.stubEnv("ANTHROPIC_API_KEY", "anthropic-test");
|
expect(tool.name).toBe("pdf");
|
||||||
const cfg = withDefaultModel(ANTHROPIC_PDF_MODEL);
|
expect(tool.label).toBe("PDF");
|
||||||
const tool = createPdfTool({ config: cfg, agentDir });
|
expect(tool.description).toContain("PDF documents");
|
||||||
expect(tool).not.toBeNull();
|
|
||||||
expect(tool?.name).toBe("pdf");
|
|
||||||
expect(tool?.label).toBe("PDF");
|
|
||||||
expect(tool?.description).toContain("PDF documents");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects when no pdf input provided", async () => {
|
it("rejects when no pdf input provided", async () => {
|
||||||
await withTempAgentDir(async (agentDir) => {
|
await withAnthropicPdfTool(async (tool) => {
|
||||||
vi.stubEnv("ANTHROPIC_API_KEY", "anthropic-test");
|
await expect(tool.execute("t1", { prompt: "test" })).rejects.toThrow("pdf required");
|
||||||
const cfg = withDefaultModel(ANTHROPIC_PDF_MODEL);
|
|
||||||
const tool = createPdfTool({ config: cfg, agentDir });
|
|
||||||
expect(tool).not.toBeNull();
|
|
||||||
await expect(tool!.execute("t1", { prompt: "test" })).rejects.toThrow("pdf required");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects too many PDFs", async () => {
|
it("rejects too many PDFs", async () => {
|
||||||
await withTempAgentDir(async (agentDir) => {
|
await withAnthropicPdfTool(async (tool) => {
|
||||||
vi.stubEnv("ANTHROPIC_API_KEY", "anthropic-test");
|
|
||||||
const cfg = withDefaultModel(ANTHROPIC_PDF_MODEL);
|
|
||||||
const tool = createPdfTool({ config: cfg, agentDir });
|
|
||||||
expect(tool).not.toBeNull();
|
|
||||||
const manyPdfs = Array.from({ length: 15 }, (_, i) => `/tmp/doc${i}.pdf`);
|
const manyPdfs = Array.from({ length: 15 }, (_, i) => `/tmp/doc${i}.pdf`);
|
||||||
const result = await tool!.execute("t1", { prompt: "test", pdfs: manyPdfs });
|
const result = await tool.execute("t1", { prompt: "test", pdfs: manyPdfs });
|
||||||
expect(result).toMatchObject({
|
expect(result).toMatchObject({
|
||||||
details: { error: "too_many_pdfs" },
|
details: { error: "too_many_pdfs" },
|
||||||
});
|
});
|
||||||
@@ -333,18 +380,19 @@ describe("createPdfTool", () => {
|
|||||||
const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-pdf-out-"));
|
const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-pdf-out-"));
|
||||||
try {
|
try {
|
||||||
const cfg = withDefaultModel(ANTHROPIC_PDF_MODEL);
|
const cfg = withDefaultModel(ANTHROPIC_PDF_MODEL);
|
||||||
const tool = createPdfTool({
|
const tool = requirePdfTool(
|
||||||
config: cfg,
|
createPdfTool({
|
||||||
agentDir,
|
config: cfg,
|
||||||
workspaceDir,
|
agentDir,
|
||||||
fsPolicy: { workspaceOnly: true },
|
workspaceDir,
|
||||||
});
|
fsPolicy: { workspaceOnly: true },
|
||||||
expect(tool).not.toBeNull();
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const outsidePdf = path.join(outsideDir, "secret.pdf");
|
const outsidePdf = path.join(outsideDir, "secret.pdf");
|
||||||
await fs.writeFile(outsidePdf, "%PDF-1.4 fake");
|
await fs.writeFile(outsidePdf, "%PDF-1.4 fake");
|
||||||
|
|
||||||
await expect(tool!.execute("t1", { prompt: "test", pdf: outsidePdf })).rejects.toThrow(
|
await expect(tool.execute("t1", { prompt: "test", pdf: outsidePdf })).rejects.toThrow(
|
||||||
/not under an allowed directory/i,
|
/not under an allowed directory/i,
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -355,12 +403,8 @@ describe("createPdfTool", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("rejects unsupported scheme references", async () => {
|
it("rejects unsupported scheme references", async () => {
|
||||||
await withTempAgentDir(async (agentDir) => {
|
await withAnthropicPdfTool(async (tool) => {
|
||||||
vi.stubEnv("ANTHROPIC_API_KEY", "anthropic-test");
|
const result = await tool.execute("t1", {
|
||||||
const cfg = withDefaultModel(ANTHROPIC_PDF_MODEL);
|
|
||||||
const tool = createPdfTool({ config: cfg, agentDir });
|
|
||||||
expect(tool).not.toBeNull();
|
|
||||||
const result = await tool!.execute("t1", {
|
|
||||||
prompt: "test",
|
prompt: "test",
|
||||||
pdf: "ftp://example.com/doc.pdf",
|
pdf: "ftp://example.com/doc.pdf",
|
||||||
});
|
});
|
||||||
@@ -374,11 +418,10 @@ describe("createPdfTool", () => {
|
|||||||
await withTempAgentDir(async (agentDir) => {
|
await withTempAgentDir(async (agentDir) => {
|
||||||
const { loadSpy } = await stubPdfToolInfra(agentDir, { modelFound: false });
|
const { loadSpy } = await stubPdfToolInfra(agentDir, { modelFound: false });
|
||||||
const cfg = withPdfModel(ANTHROPIC_PDF_MODEL);
|
const cfg = withPdfModel(ANTHROPIC_PDF_MODEL);
|
||||||
const tool = createPdfTool({ config: cfg, agentDir });
|
const tool = requirePdfTool(createPdfTool({ config: cfg, agentDir }));
|
||||||
expect(tool).not.toBeNull();
|
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
tool!.execute("t1", {
|
tool.execute("t1", {
|
||||||
prompt: "test",
|
prompt: "test",
|
||||||
pdf: "/tmp/nonexistent.pdf",
|
pdf: "/tmp/nonexistent.pdf",
|
||||||
pdfs: ["/tmp/nonexistent.pdf"],
|
pdfs: ["/tmp/nonexistent.pdf"],
|
||||||
@@ -400,10 +443,9 @@ describe("createPdfTool", () => {
|
|||||||
const extractSpy = vi.spyOn(extractModule, "extractPdfContent");
|
const extractSpy = vi.spyOn(extractModule, "extractPdfContent");
|
||||||
|
|
||||||
const cfg = withPdfModel(ANTHROPIC_PDF_MODEL);
|
const cfg = withPdfModel(ANTHROPIC_PDF_MODEL);
|
||||||
const tool = createPdfTool({ config: cfg, agentDir });
|
const tool = requirePdfTool(createPdfTool({ config: cfg, agentDir }));
|
||||||
expect(tool).not.toBeNull();
|
|
||||||
|
|
||||||
const result = await tool!.execute("t1", {
|
const result = await tool.execute("t1", {
|
||||||
prompt: "summarize",
|
prompt: "summarize",
|
||||||
pdf: "/tmp/doc.pdf",
|
pdf: "/tmp/doc.pdf",
|
||||||
});
|
});
|
||||||
@@ -420,11 +462,10 @@ describe("createPdfTool", () => {
|
|||||||
await withTempAgentDir(async (agentDir) => {
|
await withTempAgentDir(async (agentDir) => {
|
||||||
await stubPdfToolInfra(agentDir, { provider: "anthropic", input: ["text", "document"] });
|
await stubPdfToolInfra(agentDir, { provider: "anthropic", input: ["text", "document"] });
|
||||||
const cfg = withPdfModel(ANTHROPIC_PDF_MODEL);
|
const cfg = withPdfModel(ANTHROPIC_PDF_MODEL);
|
||||||
const tool = createPdfTool({ config: cfg, agentDir });
|
const tool = requirePdfTool(createPdfTool({ config: cfg, agentDir }));
|
||||||
expect(tool).not.toBeNull();
|
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
tool!.execute("t1", {
|
tool.execute("t1", {
|
||||||
prompt: "summarize",
|
prompt: "summarize",
|
||||||
pdf: "/tmp/doc.pdf",
|
pdf: "/tmp/doc.pdf",
|
||||||
pages: "1-2",
|
pages: "1-2",
|
||||||
@@ -452,10 +493,9 @@ describe("createPdfTool", () => {
|
|||||||
|
|
||||||
const cfg = withPdfModel(OPENAI_PDF_MODEL);
|
const cfg = withPdfModel(OPENAI_PDF_MODEL);
|
||||||
|
|
||||||
const tool = createPdfTool({ config: cfg, agentDir });
|
const tool = requirePdfTool(createPdfTool({ config: cfg, agentDir }));
|
||||||
expect(tool).not.toBeNull();
|
|
||||||
|
|
||||||
const result = await tool!.execute("t1", {
|
const result = await tool.execute("t1", {
|
||||||
prompt: "summarize",
|
prompt: "summarize",
|
||||||
pdf: "/tmp/doc.pdf",
|
pdf: "/tmp/doc.pdf",
|
||||||
});
|
});
|
||||||
@@ -469,12 +509,8 @@ describe("createPdfTool", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("tool parameters have correct schema shape", async () => {
|
it("tool parameters have correct schema shape", async () => {
|
||||||
await withTempAgentDir(async (agentDir) => {
|
await withAnthropicPdfTool(async (tool) => {
|
||||||
vi.stubEnv("ANTHROPIC_API_KEY", "anthropic-test");
|
const schema = tool.parameters;
|
||||||
const cfg = withDefaultModel(ANTHROPIC_PDF_MODEL);
|
|
||||||
const tool = createPdfTool({ config: cfg, agentDir });
|
|
||||||
expect(tool).not.toBeNull();
|
|
||||||
const schema = tool!.parameters;
|
|
||||||
expect(schema.type).toBe("object");
|
expect(schema.type).toBe("object");
|
||||||
expect(schema.properties).toBeDefined();
|
expect(schema.properties).toBeDefined();
|
||||||
const props = schema.properties as Record<string, { type?: string }>;
|
const props = schema.properties as Record<string, { type?: string }>;
|
||||||
@@ -514,11 +550,11 @@ describe("native PDF provider API calls", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await anthropicAnalyzePdf({
|
const result = await anthropicAnalyzePdf({
|
||||||
apiKey: "test-key",
|
...makeAnthropicAnalyzeParams({
|
||||||
modelId: "claude-opus-4-6",
|
modelId: "claude-opus-4-6",
|
||||||
prompt: "Summarize this document",
|
prompt: "Summarize this document",
|
||||||
pdfs: [{ base64: "dGVzdA==", filename: "doc.pdf" }],
|
maxTokens: 4096,
|
||||||
maxTokens: 4096,
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toBe("Analysis of PDF");
|
expect(result).toBe("Analysis of PDF");
|
||||||
@@ -542,14 +578,9 @@ describe("native PDF provider API calls", () => {
|
|||||||
text: async () => "invalid request",
|
text: async () => "invalid request",
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(anthropicAnalyzePdf(makeAnthropicAnalyzeParams())).rejects.toThrow(
|
||||||
anthropicAnalyzePdf({
|
"Anthropic PDF request failed",
|
||||||
apiKey: "test-key",
|
);
|
||||||
modelId: "claude-opus-4-6",
|
|
||||||
prompt: "test",
|
|
||||||
pdfs: [{ base64: "dGVzdA==", filename: "doc.pdf" }],
|
|
||||||
}),
|
|
||||||
).rejects.toThrow("Anthropic PDF request failed");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("anthropicAnalyzePdf throws when response has no text", async () => {
|
it("anthropicAnalyzePdf throws when response has no text", async () => {
|
||||||
@@ -561,14 +592,9 @@ describe("native PDF provider API calls", () => {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(anthropicAnalyzePdf(makeAnthropicAnalyzeParams())).rejects.toThrow(
|
||||||
anthropicAnalyzePdf({
|
"Anthropic PDF returned no text",
|
||||||
apiKey: "test-key",
|
);
|
||||||
modelId: "claude-opus-4-6",
|
|
||||||
prompt: "test",
|
|
||||||
pdfs: [{ base64: "dGVzdA==", filename: "doc.pdf" }],
|
|
||||||
}),
|
|
||||||
).rejects.toThrow("Anthropic PDF returned no text");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("geminiAnalyzePdf sends correct request shape", async () => {
|
it("geminiAnalyzePdf sends correct request shape", async () => {
|
||||||
@@ -585,10 +611,10 @@ describe("native PDF provider API calls", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await geminiAnalyzePdf({
|
const result = await geminiAnalyzePdf({
|
||||||
apiKey: "test-key",
|
...makeGeminiAnalyzeParams({
|
||||||
modelId: "gemini-2.5-pro",
|
modelId: "gemini-2.5-pro",
|
||||||
prompt: "Summarize this",
|
prompt: "Summarize this",
|
||||||
pdfs: [{ base64: "dGVzdA==", filename: "doc.pdf" }],
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toBe("Gemini PDF analysis");
|
expect(result).toBe("Gemini PDF analysis");
|
||||||
@@ -611,14 +637,9 @@ describe("native PDF provider API calls", () => {
|
|||||||
text: async () => "server error",
|
text: async () => "server error",
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(geminiAnalyzePdf(makeGeminiAnalyzeParams())).rejects.toThrow(
|
||||||
geminiAnalyzePdf({
|
"Gemini PDF request failed",
|
||||||
apiKey: "test-key",
|
);
|
||||||
modelId: "gemini-2.5-pro",
|
|
||||||
prompt: "test",
|
|
||||||
pdfs: [{ base64: "dGVzdA==", filename: "doc.pdf" }],
|
|
||||||
}),
|
|
||||||
).rejects.toThrow("Gemini PDF request failed");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("geminiAnalyzePdf throws when no candidates returned", async () => {
|
it("geminiAnalyzePdf throws when no candidates returned", async () => {
|
||||||
@@ -628,14 +649,9 @@ describe("native PDF provider API calls", () => {
|
|||||||
json: async () => ({ candidates: [] }),
|
json: async () => ({ candidates: [] }),
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(geminiAnalyzePdf(makeGeminiAnalyzeParams())).rejects.toThrow(
|
||||||
geminiAnalyzePdf({
|
"Gemini PDF returned no candidates",
|
||||||
apiKey: "test-key",
|
);
|
||||||
modelId: "gemini-2.5-pro",
|
|
||||||
prompt: "test",
|
|
||||||
pdfs: [{ base64: "dGVzdA==", filename: "doc.pdf" }],
|
|
||||||
}),
|
|
||||||
).rejects.toThrow("Gemini PDF returned no candidates");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("anthropicAnalyzePdf supports multiple PDFs", async () => {
|
it("anthropicAnalyzePdf supports multiple PDFs", async () => {
|
||||||
@@ -648,13 +664,14 @@ describe("native PDF provider API calls", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await anthropicAnalyzePdf({
|
await anthropicAnalyzePdf({
|
||||||
apiKey: "test-key",
|
...makeAnthropicAnalyzeParams({
|
||||||
modelId: "claude-opus-4-6",
|
modelId: "claude-opus-4-6",
|
||||||
prompt: "Compare these documents",
|
prompt: "Compare these documents",
|
||||||
pdfs: [
|
pdfs: [
|
||||||
{ base64: "cGRmMQ==", filename: "doc1.pdf" },
|
{ base64: "cGRmMQ==", filename: "doc1.pdf" },
|
||||||
{ base64: "cGRmMg==", filename: "doc2.pdf" },
|
{ base64: "cGRmMg==", filename: "doc2.pdf" },
|
||||||
],
|
],
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||||
@@ -675,11 +692,7 @@ describe("native PDF provider API calls", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await anthropicAnalyzePdf({
|
await anthropicAnalyzePdf({
|
||||||
apiKey: "test-key",
|
...makeAnthropicAnalyzeParams({ baseUrl: "https://custom.example.com" }),
|
||||||
modelId: "claude-opus-4-6",
|
|
||||||
prompt: "test",
|
|
||||||
pdfs: [{ base64: "dGVzdA==", filename: "doc.pdf" }],
|
|
||||||
baseUrl: "https://custom.example.com",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(fetchMock.mock.calls[0][0]).toContain("https://custom.example.com/v1/messages");
|
expect(fetchMock.mock.calls[0][0]).toContain("https://custom.example.com/v1/messages");
|
||||||
@@ -687,26 +700,16 @@ describe("native PDF provider API calls", () => {
|
|||||||
|
|
||||||
it("anthropicAnalyzePdf requires apiKey", async () => {
|
it("anthropicAnalyzePdf requires apiKey", async () => {
|
||||||
const { anthropicAnalyzePdf } = await import("./pdf-native-providers.js");
|
const { anthropicAnalyzePdf } = await import("./pdf-native-providers.js");
|
||||||
await expect(
|
await expect(anthropicAnalyzePdf(makeAnthropicAnalyzeParams({ apiKey: "" }))).rejects.toThrow(
|
||||||
anthropicAnalyzePdf({
|
"apiKey required",
|
||||||
apiKey: "",
|
);
|
||||||
modelId: "claude-opus-4-6",
|
|
||||||
prompt: "test",
|
|
||||||
pdfs: [{ base64: "dGVzdA==", filename: "doc.pdf" }],
|
|
||||||
}),
|
|
||||||
).rejects.toThrow("apiKey required");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("geminiAnalyzePdf requires apiKey", async () => {
|
it("geminiAnalyzePdf requires apiKey", async () => {
|
||||||
const { geminiAnalyzePdf } = await import("./pdf-native-providers.js");
|
const { geminiAnalyzePdf } = await import("./pdf-native-providers.js");
|
||||||
await expect(
|
await expect(geminiAnalyzePdf(makeGeminiAnalyzeParams({ apiKey: "" }))).rejects.toThrow(
|
||||||
geminiAnalyzePdf({
|
"apiKey required",
|
||||||
apiKey: "",
|
);
|
||||||
modelId: "gemini-2.5-pro",
|
|
||||||
prompt: "test",
|
|
||||||
pdfs: [{ base64: "dGVzdA==", filename: "doc.pdf" }],
|
|
||||||
}),
|
|
||||||
).rejects.toThrow("apiKey required");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,30 @@ function createProjectorHarness(cfgOverrides?: Parameters<typeof createCfg>[0])
|
|||||||
return { deliveries, projector };
|
return { deliveries, projector };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createLiveCfgOverrides(
|
||||||
|
streamOverrides: Record<string, unknown>,
|
||||||
|
): Parameters<typeof createCfg>[0] {
|
||||||
|
return {
|
||||||
|
acp: {
|
||||||
|
enabled: true,
|
||||||
|
stream: {
|
||||||
|
deliveryMode: "live",
|
||||||
|
...streamOverrides,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as Parameters<typeof createCfg>[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHiddenBoundaryCfg(
|
||||||
|
streamOverrides: Record<string, unknown> = {},
|
||||||
|
): Parameters<typeof createCfg>[0] {
|
||||||
|
return createLiveCfgOverrides({
|
||||||
|
coalesceIdleMs: 0,
|
||||||
|
maxChunkChars: 256,
|
||||||
|
...streamOverrides,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function blockDeliveries(deliveries: Delivery[]) {
|
function blockDeliveries(deliveries: Delivery[]) {
|
||||||
return deliveries.filter((entry) => entry.kind === "block");
|
return deliveries.filter((entry) => entry.kind === "block");
|
||||||
}
|
}
|
||||||
@@ -92,6 +116,22 @@ function createLiveStatusAndToolLifecycleHarness(params?: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function emitToolLifecycleEvent(
|
||||||
|
projector: ReturnType<typeof createProjectorHarness>["projector"],
|
||||||
|
event: {
|
||||||
|
tag: "tool_call" | "tool_call_update";
|
||||||
|
toolCallId: string;
|
||||||
|
status: "in_progress" | "completed";
|
||||||
|
title?: string;
|
||||||
|
text: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
await projector.onEvent({
|
||||||
|
type: "tool_call",
|
||||||
|
...event,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function runHiddenBoundaryCase(params: {
|
async function runHiddenBoundaryCase(params: {
|
||||||
cfgOverrides?: Parameters<typeof createCfg>[0];
|
cfgOverrides?: Parameters<typeof createCfg>[0];
|
||||||
toolCallId: string;
|
toolCallId: string;
|
||||||
@@ -152,16 +192,12 @@ describe("createAcpReplyProjector", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("does not suppress identical short text across terminal turn boundaries", async () => {
|
it("does not suppress identical short text across terminal turn boundaries", async () => {
|
||||||
const { deliveries, projector } = createProjectorHarness({
|
const { deliveries, projector } = createProjectorHarness(
|
||||||
acp: {
|
createLiveCfgOverrides({
|
||||||
enabled: true,
|
coalesceIdleMs: 0,
|
||||||
stream: {
|
maxChunkChars: 64,
|
||||||
deliveryMode: "live",
|
}),
|
||||||
coalesceIdleMs: 0,
|
);
|
||||||
maxChunkChars: 64,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await projector.onEvent({ type: "text_delta", text: "A", tag: "agent_message_chunk" });
|
await projector.onEvent({ type: "text_delta", text: "A", tag: "agent_message_chunk" });
|
||||||
await projector.onEvent({ type: "done", stopReason: "end_turn" });
|
await projector.onEvent({ type: "done", stopReason: "end_turn" });
|
||||||
@@ -177,16 +213,12 @@ describe("createAcpReplyProjector", () => {
|
|||||||
it("flushes staggered live text deltas after idle gaps", async () => {
|
it("flushes staggered live text deltas after idle gaps", async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
try {
|
try {
|
||||||
const { deliveries, projector } = createProjectorHarness({
|
const { deliveries, projector } = createProjectorHarness(
|
||||||
acp: {
|
createLiveCfgOverrides({
|
||||||
enabled: true,
|
coalesceIdleMs: 50,
|
||||||
stream: {
|
maxChunkChars: 64,
|
||||||
deliveryMode: "live",
|
}),
|
||||||
coalesceIdleMs: 50,
|
);
|
||||||
maxChunkChars: 64,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await projector.onEvent({ type: "text_delta", text: "A", tag: "agent_message_chunk" });
|
await projector.onEvent({ type: "text_delta", text: "A", tag: "agent_message_chunk" });
|
||||||
await vi.advanceTimersByTimeAsync(760);
|
await vi.advanceTimersByTimeAsync(760);
|
||||||
@@ -236,16 +268,12 @@ describe("createAcpReplyProjector", () => {
|
|||||||
it("does not flush short live fragments mid-phrase on idle", async () => {
|
it("does not flush short live fragments mid-phrase on idle", async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
try {
|
try {
|
||||||
const { deliveries, projector } = createProjectorHarness({
|
const { deliveries, projector } = createProjectorHarness(
|
||||||
acp: {
|
createLiveCfgOverrides({
|
||||||
enabled: true,
|
coalesceIdleMs: 100,
|
||||||
stream: {
|
maxChunkChars: 256,
|
||||||
deliveryMode: "live",
|
}),
|
||||||
coalesceIdleMs: 100,
|
);
|
||||||
maxChunkChars: 256,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await projector.onEvent({
|
await projector.onEvent({
|
||||||
type: "text_delta",
|
type: "text_delta",
|
||||||
@@ -350,19 +378,15 @@ describe("createAcpReplyProjector", () => {
|
|||||||
});
|
});
|
||||||
expect(hidden).toEqual([]);
|
expect(hidden).toEqual([]);
|
||||||
|
|
||||||
const { deliveries: shown, projector: shownProjector } = createProjectorHarness({
|
const { deliveries: shown, projector: shownProjector } = createProjectorHarness(
|
||||||
acp: {
|
createLiveCfgOverrides({
|
||||||
enabled: true,
|
coalesceIdleMs: 0,
|
||||||
stream: {
|
maxChunkChars: 64,
|
||||||
coalesceIdleMs: 0,
|
tagVisibility: {
|
||||||
maxChunkChars: 64,
|
usage_update: true,
|
||||||
deliveryMode: "live",
|
|
||||||
tagVisibility: {
|
|
||||||
usage_update: true,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
}),
|
||||||
});
|
);
|
||||||
|
|
||||||
await shownProjector.onEvent({
|
await shownProjector.onEvent({
|
||||||
type: "status",
|
type: "status",
|
||||||
@@ -406,32 +430,28 @@ describe("createAcpReplyProjector", () => {
|
|||||||
it("dedupes repeated tool lifecycle updates when repeatSuppression is enabled", async () => {
|
it("dedupes repeated tool lifecycle updates when repeatSuppression is enabled", async () => {
|
||||||
const { deliveries, projector } = createLiveToolLifecycleHarness();
|
const { deliveries, projector } = createLiveToolLifecycleHarness();
|
||||||
|
|
||||||
await projector.onEvent({
|
await emitToolLifecycleEvent(projector, {
|
||||||
type: "tool_call",
|
|
||||||
tag: "tool_call",
|
tag: "tool_call",
|
||||||
toolCallId: "call_1",
|
toolCallId: "call_1",
|
||||||
status: "in_progress",
|
status: "in_progress",
|
||||||
title: "List files",
|
title: "List files",
|
||||||
text: "List files (in_progress)",
|
text: "List files (in_progress)",
|
||||||
});
|
});
|
||||||
await projector.onEvent({
|
await emitToolLifecycleEvent(projector, {
|
||||||
type: "tool_call",
|
|
||||||
tag: "tool_call_update",
|
tag: "tool_call_update",
|
||||||
toolCallId: "call_1",
|
toolCallId: "call_1",
|
||||||
status: "in_progress",
|
status: "in_progress",
|
||||||
title: "List files",
|
title: "List files",
|
||||||
text: "List files (in_progress)",
|
text: "List files (in_progress)",
|
||||||
});
|
});
|
||||||
await projector.onEvent({
|
await emitToolLifecycleEvent(projector, {
|
||||||
type: "tool_call",
|
|
||||||
tag: "tool_call_update",
|
tag: "tool_call_update",
|
||||||
toolCallId: "call_1",
|
toolCallId: "call_1",
|
||||||
status: "completed",
|
status: "completed",
|
||||||
title: "List files",
|
title: "List files",
|
||||||
text: "List files (completed)",
|
text: "List files (completed)",
|
||||||
});
|
});
|
||||||
await projector.onEvent({
|
await emitToolLifecycleEvent(projector, {
|
||||||
type: "tool_call",
|
|
||||||
tag: "tool_call_update",
|
tag: "tool_call_update",
|
||||||
toolCallId: "call_1",
|
toolCallId: "call_1",
|
||||||
status: "completed",
|
status: "completed",
|
||||||
@@ -451,16 +471,14 @@ describe("createAcpReplyProjector", () => {
|
|||||||
|
|
||||||
const longTitle =
|
const longTitle =
|
||||||
"Run an intentionally long command title that truncates before lifecycle status is visible";
|
"Run an intentionally long command title that truncates before lifecycle status is visible";
|
||||||
await projector.onEvent({
|
await emitToolLifecycleEvent(projector, {
|
||||||
type: "tool_call",
|
|
||||||
tag: "tool_call",
|
tag: "tool_call",
|
||||||
toolCallId: "call_truncated_status",
|
toolCallId: "call_truncated_status",
|
||||||
status: "in_progress",
|
status: "in_progress",
|
||||||
title: longTitle,
|
title: longTitle,
|
||||||
text: `${longTitle} (in_progress)`,
|
text: `${longTitle} (in_progress)`,
|
||||||
});
|
});
|
||||||
await projector.onEvent({
|
await emitToolLifecycleEvent(projector, {
|
||||||
type: "tool_call",
|
|
||||||
tag: "tool_call_update",
|
tag: "tool_call_update",
|
||||||
toolCallId: "call_truncated_status",
|
toolCallId: "call_truncated_status",
|
||||||
status: "completed",
|
status: "completed",
|
||||||
@@ -541,19 +559,15 @@ describe("createAcpReplyProjector", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("suppresses exact duplicate status updates when repeatSuppression is enabled", async () => {
|
it("suppresses exact duplicate status updates when repeatSuppression is enabled", async () => {
|
||||||
const { deliveries, projector } = createProjectorHarness({
|
const { deliveries, projector } = createProjectorHarness(
|
||||||
acp: {
|
createLiveCfgOverrides({
|
||||||
enabled: true,
|
coalesceIdleMs: 0,
|
||||||
stream: {
|
maxChunkChars: 256,
|
||||||
coalesceIdleMs: 0,
|
tagVisibility: {
|
||||||
maxChunkChars: 256,
|
available_commands_update: true,
|
||||||
deliveryMode: "live",
|
|
||||||
tagVisibility: {
|
|
||||||
available_commands_update: true,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
}),
|
||||||
});
|
);
|
||||||
|
|
||||||
await projector.onEvent({
|
await projector.onEvent({
|
||||||
type: "status",
|
type: "status",
|
||||||
@@ -649,16 +663,7 @@ describe("createAcpReplyProjector", () => {
|
|||||||
|
|
||||||
it("inserts a space boundary before visible text after hidden tool updates by default", async () => {
|
it("inserts a space boundary before visible text after hidden tool updates by default", async () => {
|
||||||
await runHiddenBoundaryCase({
|
await runHiddenBoundaryCase({
|
||||||
cfgOverrides: {
|
cfgOverrides: createHiddenBoundaryCfg(),
|
||||||
acp: {
|
|
||||||
enabled: true,
|
|
||||||
stream: {
|
|
||||||
coalesceIdleMs: 0,
|
|
||||||
maxChunkChars: 256,
|
|
||||||
deliveryMode: "live",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
toolCallId: "call_hidden_1",
|
toolCallId: "call_hidden_1",
|
||||||
expectedText: "fallback. I don't",
|
expectedText: "fallback. I don't",
|
||||||
});
|
});
|
||||||
@@ -666,20 +671,12 @@ describe("createAcpReplyProjector", () => {
|
|||||||
|
|
||||||
it("preserves hidden boundary across nonterminal hidden tool updates", async () => {
|
it("preserves hidden boundary across nonterminal hidden tool updates", async () => {
|
||||||
await runHiddenBoundaryCase({
|
await runHiddenBoundaryCase({
|
||||||
cfgOverrides: {
|
cfgOverrides: createHiddenBoundaryCfg({
|
||||||
acp: {
|
tagVisibility: {
|
||||||
enabled: true,
|
tool_call: false,
|
||||||
stream: {
|
tool_call_update: false,
|
||||||
coalesceIdleMs: 0,
|
|
||||||
maxChunkChars: 256,
|
|
||||||
deliveryMode: "live",
|
|
||||||
tagVisibility: {
|
|
||||||
tool_call: false,
|
|
||||||
tool_call_update: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
}),
|
||||||
toolCallId: "hidden_boundary_1",
|
toolCallId: "hidden_boundary_1",
|
||||||
includeNonTerminalUpdate: true,
|
includeNonTerminalUpdate: true,
|
||||||
expectedText: "fallback. I don't",
|
expectedText: "fallback. I don't",
|
||||||
@@ -688,17 +685,9 @@ describe("createAcpReplyProjector", () => {
|
|||||||
|
|
||||||
it("supports hiddenBoundarySeparator=space", async () => {
|
it("supports hiddenBoundarySeparator=space", async () => {
|
||||||
await runHiddenBoundaryCase({
|
await runHiddenBoundaryCase({
|
||||||
cfgOverrides: {
|
cfgOverrides: createHiddenBoundaryCfg({
|
||||||
acp: {
|
hiddenBoundarySeparator: "space",
|
||||||
enabled: true,
|
}),
|
||||||
stream: {
|
|
||||||
coalesceIdleMs: 0,
|
|
||||||
maxChunkChars: 256,
|
|
||||||
deliveryMode: "live",
|
|
||||||
hiddenBoundarySeparator: "space",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
toolCallId: "call_hidden_2",
|
toolCallId: "call_hidden_2",
|
||||||
expectedText: "fallback. I don't",
|
expectedText: "fallback. I don't",
|
||||||
});
|
});
|
||||||
@@ -706,17 +695,9 @@ describe("createAcpReplyProjector", () => {
|
|||||||
|
|
||||||
it("supports hiddenBoundarySeparator=none", async () => {
|
it("supports hiddenBoundarySeparator=none", async () => {
|
||||||
await runHiddenBoundaryCase({
|
await runHiddenBoundaryCase({
|
||||||
cfgOverrides: {
|
cfgOverrides: createHiddenBoundaryCfg({
|
||||||
acp: {
|
hiddenBoundarySeparator: "none",
|
||||||
enabled: true,
|
}),
|
||||||
stream: {
|
|
||||||
coalesceIdleMs: 0,
|
|
||||||
maxChunkChars: 256,
|
|
||||||
deliveryMode: "live",
|
|
||||||
hiddenBoundarySeparator: "none",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
toolCallId: "call_hidden_3",
|
toolCallId: "call_hidden_3",
|
||||||
expectedText: "fallback.I don't",
|
expectedText: "fallback.I don't",
|
||||||
});
|
});
|
||||||
@@ -724,16 +705,7 @@ describe("createAcpReplyProjector", () => {
|
|||||||
|
|
||||||
it("does not duplicate newlines when previous visible text already ends with newline", async () => {
|
it("does not duplicate newlines when previous visible text already ends with newline", async () => {
|
||||||
await runHiddenBoundaryCase({
|
await runHiddenBoundaryCase({
|
||||||
cfgOverrides: {
|
cfgOverrides: createHiddenBoundaryCfg(),
|
||||||
acp: {
|
|
||||||
enabled: true,
|
|
||||||
stream: {
|
|
||||||
coalesceIdleMs: 0,
|
|
||||||
maxChunkChars: 256,
|
|
||||||
deliveryMode: "live",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
toolCallId: "call_hidden_4",
|
toolCallId: "call_hidden_4",
|
||||||
firstText: "fallback.\n",
|
firstText: "fallback.\n",
|
||||||
expectedText: "fallback.\nI don't",
|
expectedText: "fallback.\nI don't",
|
||||||
|
|||||||
@@ -157,6 +157,27 @@ describe("typing controller", () => {
|
|||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function createTestTypingController() {
|
||||||
|
const onReplyStart = vi.fn();
|
||||||
|
const typing = createTypingController({
|
||||||
|
onReplyStart,
|
||||||
|
typingIntervalSeconds: 1,
|
||||||
|
typingTtlMs: 30_000,
|
||||||
|
});
|
||||||
|
return { typing, onReplyStart };
|
||||||
|
}
|
||||||
|
|
||||||
|
function markTypingState(
|
||||||
|
typing: ReturnType<typeof createTypingController>,
|
||||||
|
state: "run" | "idle",
|
||||||
|
) {
|
||||||
|
if (state === "run") {
|
||||||
|
typing.markRunComplete();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
typing.markDispatchIdle();
|
||||||
|
}
|
||||||
|
|
||||||
it("stops only after both run completion and dispatcher idle are set (any order)", async () => {
|
it("stops only after both run completion and dispatcher idle are set (any order)", async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const cases = [
|
const cases = [
|
||||||
@@ -165,12 +186,7 @@ describe("typing controller", () => {
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
for (const testCase of cases) {
|
for (const testCase of cases) {
|
||||||
const onReplyStart = vi.fn();
|
const { typing, onReplyStart } = createTestTypingController();
|
||||||
const typing = createTypingController({
|
|
||||||
onReplyStart,
|
|
||||||
typingIntervalSeconds: 1,
|
|
||||||
typingTtlMs: 30_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
await typing.startTypingLoop();
|
await typing.startTypingLoop();
|
||||||
expect(onReplyStart, testCase.name).toHaveBeenCalledTimes(1);
|
expect(onReplyStart, testCase.name).toHaveBeenCalledTimes(1);
|
||||||
@@ -178,19 +194,11 @@ describe("typing controller", () => {
|
|||||||
await vi.advanceTimersByTimeAsync(2_000);
|
await vi.advanceTimersByTimeAsync(2_000);
|
||||||
expect(onReplyStart, testCase.name).toHaveBeenCalledTimes(3);
|
expect(onReplyStart, testCase.name).toHaveBeenCalledTimes(3);
|
||||||
|
|
||||||
if (testCase.first === "run") {
|
markTypingState(typing, testCase.first);
|
||||||
typing.markRunComplete();
|
|
||||||
} else {
|
|
||||||
typing.markDispatchIdle();
|
|
||||||
}
|
|
||||||
await vi.advanceTimersByTimeAsync(2_000);
|
await vi.advanceTimersByTimeAsync(2_000);
|
||||||
expect(onReplyStart, testCase.name).toHaveBeenCalledTimes(testCase.first === "run" ? 3 : 5);
|
expect(onReplyStart, testCase.name).toHaveBeenCalledTimes(testCase.first === "run" ? 3 : 5);
|
||||||
|
|
||||||
if (testCase.second === "run") {
|
markTypingState(typing, testCase.second);
|
||||||
typing.markRunComplete();
|
|
||||||
} else {
|
|
||||||
typing.markDispatchIdle();
|
|
||||||
}
|
|
||||||
await vi.advanceTimersByTimeAsync(2_000);
|
await vi.advanceTimersByTimeAsync(2_000);
|
||||||
expect(onReplyStart, testCase.name).toHaveBeenCalledTimes(testCase.first === "run" ? 3 : 5);
|
expect(onReplyStart, testCase.name).toHaveBeenCalledTimes(testCase.first === "run" ? 3 : 5);
|
||||||
}
|
}
|
||||||
@@ -198,12 +206,7 @@ describe("typing controller", () => {
|
|||||||
|
|
||||||
it("does not start typing after run completion", async () => {
|
it("does not start typing after run completion", async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const onReplyStart = vi.fn();
|
const { typing, onReplyStart } = createTestTypingController();
|
||||||
const typing = createTypingController({
|
|
||||||
onReplyStart,
|
|
||||||
typingIntervalSeconds: 1,
|
|
||||||
typingTtlMs: 30_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
typing.markRunComplete();
|
typing.markRunComplete();
|
||||||
await typing.startTypingOnText("late text");
|
await typing.startTypingOnText("late text");
|
||||||
@@ -213,12 +216,7 @@ describe("typing controller", () => {
|
|||||||
|
|
||||||
it("does not restart typing after it has stopped", async () => {
|
it("does not restart typing after it has stopped", async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const onReplyStart = vi.fn();
|
const { typing, onReplyStart } = createTestTypingController();
|
||||||
const typing = createTypingController({
|
|
||||||
onReplyStart,
|
|
||||||
typingIntervalSeconds: 1,
|
|
||||||
typingTtlMs: 30_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
await typing.startTypingLoop();
|
await typing.startTypingLoop();
|
||||||
expect(onReplyStart).toHaveBeenCalledTimes(1);
|
expect(onReplyStart).toHaveBeenCalledTimes(1);
|
||||||
@@ -358,6 +356,21 @@ describe("parseAudioTag", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("resolveResponsePrefixTemplate", () => {
|
describe("resolveResponsePrefixTemplate", () => {
|
||||||
|
function expectResolvedTemplateCases<
|
||||||
|
T extends ReadonlyArray<{
|
||||||
|
name: string;
|
||||||
|
template: string | undefined;
|
||||||
|
values: Parameters<typeof resolveResponsePrefixTemplate>[1];
|
||||||
|
expected: string | undefined;
|
||||||
|
}>,
|
||||||
|
>(cases: T) {
|
||||||
|
for (const testCase of cases) {
|
||||||
|
expect(resolveResponsePrefixTemplate(testCase.template, testCase.values), testCase.name).toBe(
|
||||||
|
testCase.expected,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
it("resolves known variables, aliases, and case-insensitive tokens", () => {
|
it("resolves known variables, aliases, and case-insensitive tokens", () => {
|
||||||
const cases = [
|
const cases = [
|
||||||
{
|
{
|
||||||
@@ -420,11 +433,7 @@ describe("resolveResponsePrefixTemplate", () => {
|
|||||||
expected: "[OpenClaw] anthropic/claude-opus-4-5 (think:high)",
|
expected: "[OpenClaw] anthropic/claude-opus-4-5 (think:high)",
|
||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
for (const testCase of cases) {
|
expectResolvedTemplateCases(cases);
|
||||||
expect(resolveResponsePrefixTemplate(testCase.template, testCase.values), testCase.name).toBe(
|
|
||||||
testCase.expected,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves unresolved/unknown placeholders and handles static inputs", () => {
|
it("preserves unresolved/unknown placeholders and handles static inputs", () => {
|
||||||
@@ -450,11 +459,7 @@ describe("resolveResponsePrefixTemplate", () => {
|
|||||||
expected: "[gpt-5.2 | {provider}]",
|
expected: "[gpt-5.2 | {provider}]",
|
||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
for (const testCase of cases) {
|
expectResolvedTemplateCases(cases);
|
||||||
expect(resolveResponsePrefixTemplate(testCase.template, testCase.values), testCase.name).toBe(
|
|
||||||
testCase.expected,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -556,16 +561,32 @@ describe("block reply coalescer", () => {
|
|||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("coalesces chunks within the idle window", async () => {
|
function createBlockCoalescerHarness(config: {
|
||||||
vi.useFakeTimers();
|
minChars: number;
|
||||||
|
maxChars: number;
|
||||||
|
idleMs: number;
|
||||||
|
joiner: string;
|
||||||
|
flushOnEnqueue?: boolean;
|
||||||
|
}) {
|
||||||
const flushes: string[] = [];
|
const flushes: string[] = [];
|
||||||
const coalescer = createBlockReplyCoalescer({
|
const coalescer = createBlockReplyCoalescer({
|
||||||
config: { minChars: 1, maxChars: 200, idleMs: 100, joiner: " " },
|
config,
|
||||||
shouldAbort: () => false,
|
shouldAbort: () => false,
|
||||||
onFlush: (payload) => {
|
onFlush: (payload) => {
|
||||||
flushes.push(payload.text ?? "");
|
flushes.push(payload.text ?? "");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
return { flushes, coalescer };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("coalesces chunks within the idle window", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const { flushes, coalescer } = createBlockCoalescerHarness({
|
||||||
|
minChars: 1,
|
||||||
|
maxChars: 200,
|
||||||
|
idleMs: 100,
|
||||||
|
joiner: " ",
|
||||||
|
});
|
||||||
|
|
||||||
coalescer.enqueue({ text: "Hello" });
|
coalescer.enqueue({ text: "Hello" });
|
||||||
coalescer.enqueue({ text: "world" });
|
coalescer.enqueue({ text: "world" });
|
||||||
@@ -577,13 +598,11 @@ describe("block reply coalescer", () => {
|
|||||||
|
|
||||||
it("waits until minChars before idle flush", async () => {
|
it("waits until minChars before idle flush", async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const flushes: string[] = [];
|
const { flushes, coalescer } = createBlockCoalescerHarness({
|
||||||
const coalescer = createBlockReplyCoalescer({
|
minChars: 10,
|
||||||
config: { minChars: 10, maxChars: 200, idleMs: 50, joiner: " " },
|
maxChars: 200,
|
||||||
shouldAbort: () => false,
|
idleMs: 50,
|
||||||
onFlush: (payload) => {
|
joiner: " ",
|
||||||
flushes.push(payload.text ?? "");
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
coalescer.enqueue({ text: "short" });
|
coalescer.enqueue({ text: "short" });
|
||||||
@@ -598,13 +617,11 @@ describe("block reply coalescer", () => {
|
|||||||
|
|
||||||
it("still accumulates when flushOnEnqueue is not set (default)", async () => {
|
it("still accumulates when flushOnEnqueue is not set (default)", async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const flushes: string[] = [];
|
const { flushes, coalescer } = createBlockCoalescerHarness({
|
||||||
const coalescer = createBlockReplyCoalescer({
|
minChars: 1,
|
||||||
config: { minChars: 1, maxChars: 2000, idleMs: 100, joiner: "\n\n" },
|
maxChars: 2000,
|
||||||
shouldAbort: () => false,
|
idleMs: 100,
|
||||||
onFlush: (payload) => {
|
joiner: "\n\n",
|
||||||
flushes.push(payload.text ?? "");
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
coalescer.enqueue({ text: "First paragraph" });
|
coalescer.enqueue({ text: "First paragraph" });
|
||||||
@@ -630,14 +647,7 @@ describe("block reply coalescer", () => {
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
for (const testCase of cases) {
|
for (const testCase of cases) {
|
||||||
const flushes: string[] = [];
|
const { flushes, coalescer } = createBlockCoalescerHarness(testCase.config);
|
||||||
const coalescer = createBlockReplyCoalescer({
|
|
||||||
config: testCase.config,
|
|
||||||
shouldAbort: () => false,
|
|
||||||
onFlush: (payload) => {
|
|
||||||
flushes.push(payload.text ?? "");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
for (const input of testCase.inputs) {
|
for (const input of testCase.inputs) {
|
||||||
coalescer.enqueue({ text: input });
|
coalescer.enqueue({ text: input });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,6 +130,43 @@ async function setupCronTestRun(params: {
|
|||||||
return { prevSkipCron, dir };
|
return { prevSkipCron, dir };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function expectCronJobIdFromResponse(response: { ok?: unknown; payload?: unknown }) {
|
||||||
|
expect(response.ok).toBe(true);
|
||||||
|
const value = (response.payload as { id?: unknown } | null)?.id;
|
||||||
|
const id = typeof value === "string" ? value : "";
|
||||||
|
expect(id.length > 0).toBe(true);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addMainSystemEventCronJob(params: { ws: unknown; name: string; text?: string }) {
|
||||||
|
const response = await rpcReq(params.ws, "cron.add", {
|
||||||
|
name: params.name,
|
||||||
|
enabled: true,
|
||||||
|
schedule: { kind: "every", everyMs: 60_000 },
|
||||||
|
sessionTarget: "main",
|
||||||
|
wakeMode: "next-heartbeat",
|
||||||
|
payload: { kind: "systemEvent", text: params.text ?? "hello" },
|
||||||
|
});
|
||||||
|
return expectCronJobIdFromResponse(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWebhookCall(index: number) {
|
||||||
|
const [args] = fetchWithSsrFGuardMock.mock.calls[index] as unknown as [
|
||||||
|
{
|
||||||
|
url?: string;
|
||||||
|
init?: {
|
||||||
|
method?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
body?: string;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const url = args.url ?? "";
|
||||||
|
const init = args.init ?? {};
|
||||||
|
const body = JSON.parse(init.body ?? "{}") as Record<string, unknown>;
|
||||||
|
return { url, init, body };
|
||||||
|
}
|
||||||
|
|
||||||
describe("gateway server cron", () => {
|
describe("gateway server cron", () => {
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
if (!cronSuiteTempRootPromise) {
|
if (!cronSuiteTempRootPromise) {
|
||||||
@@ -215,18 +252,7 @@ describe("gateway server cron", () => {
|
|||||||
expect(wrappedPayload?.wakeMode).toBe("now");
|
expect(wrappedPayload?.wakeMode).toBe("now");
|
||||||
expect((wrappedPayload?.schedule as { kind?: unknown } | undefined)?.kind).toBe("at");
|
expect((wrappedPayload?.schedule as { kind?: unknown } | undefined)?.kind).toBe("at");
|
||||||
|
|
||||||
const patchRes = await rpcReq(ws, "cron.add", {
|
const patchJobId = await addMainSystemEventCronJob({ ws, name: "patch test" });
|
||||||
name: "patch test",
|
|
||||||
enabled: true,
|
|
||||||
schedule: { kind: "every", everyMs: 60_000 },
|
|
||||||
sessionTarget: "main",
|
|
||||||
wakeMode: "next-heartbeat",
|
|
||||||
payload: { kind: "systemEvent", text: "hello" },
|
|
||||||
});
|
|
||||||
expect(patchRes.ok).toBe(true);
|
|
||||||
const patchJobIdValue = (patchRes.payload as { id?: unknown } | null)?.id;
|
|
||||||
const patchJobId = typeof patchJobIdValue === "string" ? patchJobIdValue : "";
|
|
||||||
expect(patchJobId.length > 0).toBe(true);
|
|
||||||
|
|
||||||
const atMs = Date.now() + 1_000;
|
const atMs = Date.now() + 1_000;
|
||||||
const updateRes = await rpcReq(ws, "cron.update", {
|
const updateRes = await rpcReq(ws, "cron.update", {
|
||||||
@@ -344,18 +370,7 @@ describe("gateway server cron", () => {
|
|||||||
expect(legacyDeliveryPatched?.delivery?.to).toBe("+15550001111");
|
expect(legacyDeliveryPatched?.delivery?.to).toBe("+15550001111");
|
||||||
expect(legacyDeliveryPatched?.delivery?.bestEffort).toBe(true);
|
expect(legacyDeliveryPatched?.delivery?.bestEffort).toBe(true);
|
||||||
|
|
||||||
const rejectRes = await rpcReq(ws, "cron.add", {
|
const rejectJobId = await addMainSystemEventCronJob({ ws, name: "patch reject" });
|
||||||
name: "patch reject",
|
|
||||||
enabled: true,
|
|
||||||
schedule: { kind: "every", everyMs: 60_000 },
|
|
||||||
sessionTarget: "main",
|
|
||||||
wakeMode: "next-heartbeat",
|
|
||||||
payload: { kind: "systemEvent", text: "hello" },
|
|
||||||
});
|
|
||||||
expect(rejectRes.ok).toBe(true);
|
|
||||||
const rejectJobIdValue = (rejectRes.payload as { id?: unknown } | null)?.id;
|
|
||||||
const rejectJobId = typeof rejectJobIdValue === "string" ? rejectJobIdValue : "";
|
|
||||||
expect(rejectJobId.length > 0).toBe(true);
|
|
||||||
|
|
||||||
const rejectUpdateRes = await rpcReq(ws, "cron.update", {
|
const rejectUpdateRes = await rpcReq(ws, "cron.update", {
|
||||||
id: rejectJobId,
|
id: rejectJobId,
|
||||||
@@ -365,18 +380,7 @@ describe("gateway server cron", () => {
|
|||||||
});
|
});
|
||||||
expect(rejectUpdateRes.ok).toBe(false);
|
expect(rejectUpdateRes.ok).toBe(false);
|
||||||
|
|
||||||
const jobIdRes = await rpcReq(ws, "cron.add", {
|
const jobId = await addMainSystemEventCronJob({ ws, name: "jobId test" });
|
||||||
name: "jobId test",
|
|
||||||
enabled: true,
|
|
||||||
schedule: { kind: "every", everyMs: 60_000 },
|
|
||||||
sessionTarget: "main",
|
|
||||||
wakeMode: "next-heartbeat",
|
|
||||||
payload: { kind: "systemEvent", text: "hello" },
|
|
||||||
});
|
|
||||||
expect(jobIdRes.ok).toBe(true);
|
|
||||||
const jobIdValue = (jobIdRes.payload as { id?: unknown } | null)?.id;
|
|
||||||
const jobId = typeof jobIdValue === "string" ? jobIdValue : "";
|
|
||||||
expect(jobId.length > 0).toBe(true);
|
|
||||||
|
|
||||||
const jobIdUpdateRes = await rpcReq(ws, "cron.update", {
|
const jobIdUpdateRes = await rpcReq(ws, "cron.update", {
|
||||||
jobId,
|
jobId,
|
||||||
@@ -387,18 +391,7 @@ describe("gateway server cron", () => {
|
|||||||
});
|
});
|
||||||
expect(jobIdUpdateRes.ok).toBe(true);
|
expect(jobIdUpdateRes.ok).toBe(true);
|
||||||
|
|
||||||
const disableRes = await rpcReq(ws, "cron.add", {
|
const disableJobId = await addMainSystemEventCronJob({ ws, name: "disable test" });
|
||||||
name: "disable test",
|
|
||||||
enabled: true,
|
|
||||||
schedule: { kind: "every", everyMs: 60_000 },
|
|
||||||
sessionTarget: "main",
|
|
||||||
wakeMode: "next-heartbeat",
|
|
||||||
payload: { kind: "systemEvent", text: "hello" },
|
|
||||||
});
|
|
||||||
expect(disableRes.ok).toBe(true);
|
|
||||||
const disableJobIdValue = (disableRes.payload as { id?: unknown } | null)?.id;
|
|
||||||
const disableJobId = typeof disableJobIdValue === "string" ? disableJobIdValue : "";
|
|
||||||
expect(disableJobId.length > 0).toBe(true);
|
|
||||||
|
|
||||||
const disableUpdateRes = await rpcReq(ws, "cron.update", {
|
const disableUpdateRes = await rpcReq(ws, "cron.update", {
|
||||||
id: disableJobId,
|
id: disableJobId,
|
||||||
@@ -601,23 +594,12 @@ describe("gateway server cron", () => {
|
|||||||
() => fetchWithSsrFGuardMock.mock.calls.length === 1,
|
() => fetchWithSsrFGuardMock.mock.calls.length === 1,
|
||||||
CRON_WAIT_TIMEOUT_MS,
|
CRON_WAIT_TIMEOUT_MS,
|
||||||
);
|
);
|
||||||
const [notifyArgs] = fetchWithSsrFGuardMock.mock.calls[0] as unknown as [
|
const notifyCall = getWebhookCall(0);
|
||||||
{
|
expect(notifyCall.url).toBe("https://example.invalid/cron-finished");
|
||||||
url?: string;
|
expect(notifyCall.init.method).toBe("POST");
|
||||||
init?: {
|
expect(notifyCall.init.headers?.Authorization).toBe("Bearer cron-webhook-token");
|
||||||
method?: string;
|
expect(notifyCall.init.headers?.["Content-Type"]).toBe("application/json");
|
||||||
headers?: Record<string, string>;
|
const notifyBody = notifyCall.body;
|
||||||
body?: string;
|
|
||||||
};
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const notifyUrl = notifyArgs.url ?? "";
|
|
||||||
const notifyInit = notifyArgs.init ?? {};
|
|
||||||
expect(notifyUrl).toBe("https://example.invalid/cron-finished");
|
|
||||||
expect(notifyInit.method).toBe("POST");
|
|
||||||
expect(notifyInit.headers?.Authorization).toBe("Bearer cron-webhook-token");
|
|
||||||
expect(notifyInit.headers?.["Content-Type"]).toBe("application/json");
|
|
||||||
const notifyBody = JSON.parse(notifyInit.body ?? "{}");
|
|
||||||
expect(notifyBody.action).toBe("finished");
|
expect(notifyBody.action).toBe("finished");
|
||||||
expect(notifyBody.jobId).toBe(notifyJobId);
|
expect(notifyBody.jobId).toBe(notifyJobId);
|
||||||
|
|
||||||
@@ -632,22 +614,11 @@ describe("gateway server cron", () => {
|
|||||||
() => fetchWithSsrFGuardMock.mock.calls.length === 2,
|
() => fetchWithSsrFGuardMock.mock.calls.length === 2,
|
||||||
CRON_WAIT_TIMEOUT_MS,
|
CRON_WAIT_TIMEOUT_MS,
|
||||||
);
|
);
|
||||||
const [legacyArgs] = fetchWithSsrFGuardMock.mock.calls[1] as unknown as [
|
const legacyCall = getWebhookCall(1);
|
||||||
{
|
expect(legacyCall.url).toBe("https://legacy.example.invalid/cron-finished");
|
||||||
url?: string;
|
expect(legacyCall.init.method).toBe("POST");
|
||||||
init?: {
|
expect(legacyCall.init.headers?.Authorization).toBe("Bearer cron-webhook-token");
|
||||||
method?: string;
|
const legacyBody = legacyCall.body;
|
||||||
headers?: Record<string, string>;
|
|
||||||
body?: string;
|
|
||||||
};
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const legacyUrl = legacyArgs.url ?? "";
|
|
||||||
const legacyInit = legacyArgs.init ?? {};
|
|
||||||
expect(legacyUrl).toBe("https://legacy.example.invalid/cron-finished");
|
|
||||||
expect(legacyInit.method).toBe("POST");
|
|
||||||
expect(legacyInit.headers?.Authorization).toBe("Bearer cron-webhook-token");
|
|
||||||
const legacyBody = JSON.parse(legacyInit.body ?? "{}");
|
|
||||||
expect(legacyBody.action).toBe("finished");
|
expect(legacyBody.action).toBe("finished");
|
||||||
expect(legacyBody.jobId).toBe("legacy-notify-job");
|
expect(legacyBody.jobId).toBe("legacy-notify-job");
|
||||||
|
|
||||||
@@ -706,18 +677,9 @@ describe("gateway server cron", () => {
|
|||||||
() => fetchWithSsrFGuardMock.mock.calls.length === 1,
|
() => fetchWithSsrFGuardMock.mock.calls.length === 1,
|
||||||
CRON_WAIT_TIMEOUT_MS,
|
CRON_WAIT_TIMEOUT_MS,
|
||||||
);
|
);
|
||||||
const [failureDestArgs] = fetchWithSsrFGuardMock.mock.calls[0] as unknown as [
|
const failureDestCall = getWebhookCall(0);
|
||||||
{
|
expect(failureDestCall.url).toBe("https://example.invalid/failure-destination");
|
||||||
url?: string;
|
const failureDestBody = failureDestCall.body;
|
||||||
init?: {
|
|
||||||
method?: string;
|
|
||||||
headers?: Record<string, string>;
|
|
||||||
body?: string;
|
|
||||||
};
|
|
||||||
},
|
|
||||||
];
|
|
||||||
expect(failureDestArgs.url).toBe("https://example.invalid/failure-destination");
|
|
||||||
const failureDestBody = JSON.parse(failureDestArgs.init?.body ?? "{}");
|
|
||||||
expect(failureDestBody.message).toBe(
|
expect(failureDestBody.message).toBe(
|
||||||
'Cron job "failure destination webhook" failed: unknown error',
|
'Cron job "failure destination webhook" failed: unknown error',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -79,6 +79,10 @@ const whatsappChunkConfig: OpenClawConfig = {
|
|||||||
channels: { whatsapp: { textChunkLimit: 4000 } },
|
channels: { whatsapp: { textChunkLimit: 4000 } },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type DeliverOutboundArgs = Parameters<typeof deliverOutboundPayloads>[0];
|
||||||
|
type DeliverOutboundPayload = DeliverOutboundArgs["payloads"][number];
|
||||||
|
type DeliverSession = DeliverOutboundArgs["session"];
|
||||||
|
|
||||||
async function deliverWhatsAppPayload(params: {
|
async function deliverWhatsAppPayload(params: {
|
||||||
sendWhatsApp: NonNullable<
|
sendWhatsApp: NonNullable<
|
||||||
NonNullable<Parameters<typeof deliverOutboundPayloads>[0]["deps"]>["sendWhatsApp"]
|
NonNullable<Parameters<typeof deliverOutboundPayloads>[0]["deps"]>["sendWhatsApp"]
|
||||||
@@ -95,6 +99,24 @@ async function deliverWhatsAppPayload(params: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function deliverTelegramPayload(params: {
|
||||||
|
sendTelegram: NonNullable<NonNullable<DeliverOutboundArgs["deps"]>["sendTelegram"]>;
|
||||||
|
payload: DeliverOutboundPayload;
|
||||||
|
cfg?: OpenClawConfig;
|
||||||
|
accountId?: string;
|
||||||
|
session?: DeliverSession;
|
||||||
|
}) {
|
||||||
|
return deliverOutboundPayloads({
|
||||||
|
cfg: params.cfg ?? telegramChunkConfig,
|
||||||
|
channel: "telegram",
|
||||||
|
to: "123",
|
||||||
|
payloads: [params.payload],
|
||||||
|
deps: { sendTelegram: params.sendTelegram },
|
||||||
|
...(params.accountId ? { accountId: params.accountId } : {}),
|
||||||
|
...(params.session ? { session: params.session } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function runChunkedWhatsAppDelivery(params?: {
|
async function runChunkedWhatsAppDelivery(params?: {
|
||||||
mirror?: Parameters<typeof deliverOutboundPayloads>[0]["mirror"];
|
mirror?: Parameters<typeof deliverOutboundPayloads>[0]["mirror"];
|
||||||
}) {
|
}) {
|
||||||
@@ -128,6 +150,42 @@ async function deliverSingleWhatsAppForHookTest(params?: { sessionKey?: string }
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function runBestEffortPartialFailureDelivery() {
|
||||||
|
const sendWhatsApp = vi
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValueOnce(new Error("fail"))
|
||||||
|
.mockResolvedValueOnce({ messageId: "w2", toJid: "jid" });
|
||||||
|
const onError = vi.fn();
|
||||||
|
const cfg: OpenClawConfig = {};
|
||||||
|
const results = await deliverOutboundPayloads({
|
||||||
|
cfg,
|
||||||
|
channel: "whatsapp",
|
||||||
|
to: "+1555",
|
||||||
|
payloads: [{ text: "a" }, { text: "b" }],
|
||||||
|
deps: { sendWhatsApp },
|
||||||
|
bestEffort: true,
|
||||||
|
onError,
|
||||||
|
});
|
||||||
|
return { sendWhatsApp, onError, results };
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectSuccessfulWhatsAppInternalHookPayload(
|
||||||
|
expected: Partial<{
|
||||||
|
content: string;
|
||||||
|
messageId: string;
|
||||||
|
isGroup: boolean;
|
||||||
|
groupId: string;
|
||||||
|
}>,
|
||||||
|
) {
|
||||||
|
return expect.objectContaining({
|
||||||
|
to: "+1555",
|
||||||
|
success: true,
|
||||||
|
channelId: "whatsapp",
|
||||||
|
conversationId: "+1555",
|
||||||
|
...expected,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
describe("deliverOutboundPayloads", () => {
|
describe("deliverOutboundPayloads", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
setActivePluginRegistry(defaultRegistry);
|
setActivePluginRegistry(defaultRegistry);
|
||||||
@@ -217,13 +275,10 @@ describe("deliverOutboundPayloads", () => {
|
|||||||
it("passes explicit accountId to sendTelegram", async () => {
|
it("passes explicit accountId to sendTelegram", async () => {
|
||||||
const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", chatId: "c1" });
|
const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", chatId: "c1" });
|
||||||
|
|
||||||
await deliverOutboundPayloads({
|
await deliverTelegramPayload({
|
||||||
cfg: telegramChunkConfig,
|
sendTelegram,
|
||||||
channel: "telegram",
|
|
||||||
to: "123",
|
|
||||||
accountId: "default",
|
accountId: "default",
|
||||||
payloads: [{ text: "hi" }],
|
payload: { text: "hi" },
|
||||||
deps: { sendTelegram },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(sendTelegram).toHaveBeenCalledWith(
|
expect(sendTelegram).toHaveBeenCalledWith(
|
||||||
@@ -236,17 +291,12 @@ describe("deliverOutboundPayloads", () => {
|
|||||||
it("preserves HTML text for telegram sendPayload channelData path", async () => {
|
it("preserves HTML text for telegram sendPayload channelData path", async () => {
|
||||||
const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", chatId: "c1" });
|
const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", chatId: "c1" });
|
||||||
|
|
||||||
await deliverOutboundPayloads({
|
await deliverTelegramPayload({
|
||||||
cfg: telegramChunkConfig,
|
sendTelegram,
|
||||||
channel: "telegram",
|
payload: {
|
||||||
to: "123",
|
text: "<b>hello</b>",
|
||||||
payloads: [
|
channelData: { telegram: { buttons: [] } },
|
||||||
{
|
},
|
||||||
text: "<b>hello</b>",
|
|
||||||
channelData: { telegram: { buttons: [] } },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
deps: { sendTelegram },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(sendTelegram).toHaveBeenCalledTimes(1);
|
expect(sendTelegram).toHaveBeenCalledTimes(1);
|
||||||
@@ -260,13 +310,10 @@ describe("deliverOutboundPayloads", () => {
|
|||||||
it("scopes media local roots to the active agent workspace when agentId is provided", async () => {
|
it("scopes media local roots to the active agent workspace when agentId is provided", async () => {
|
||||||
const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", chatId: "c1" });
|
const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", chatId: "c1" });
|
||||||
|
|
||||||
await deliverOutboundPayloads({
|
await deliverTelegramPayload({
|
||||||
cfg: telegramChunkConfig,
|
sendTelegram,
|
||||||
channel: "telegram",
|
|
||||||
to: "123",
|
|
||||||
session: { agentId: "work" },
|
session: { agentId: "work" },
|
||||||
payloads: [{ text: "hi", mediaUrl: "file:///tmp/f.png" }],
|
payload: { text: "hi", mediaUrl: "file:///tmp/f.png" },
|
||||||
deps: { sendTelegram },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(sendTelegram).toHaveBeenCalledWith(
|
expect(sendTelegram).toHaveBeenCalledWith(
|
||||||
@@ -282,12 +329,9 @@ describe("deliverOutboundPayloads", () => {
|
|||||||
it("includes OpenClaw tmp root in telegram mediaLocalRoots", async () => {
|
it("includes OpenClaw tmp root in telegram mediaLocalRoots", async () => {
|
||||||
const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", chatId: "c1" });
|
const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", chatId: "c1" });
|
||||||
|
|
||||||
await deliverOutboundPayloads({
|
await deliverTelegramPayload({
|
||||||
cfg: telegramChunkConfig,
|
sendTelegram,
|
||||||
channel: "telegram",
|
payload: { text: "hi", mediaUrl: "https://example.com/x.png" },
|
||||||
to: "123",
|
|
||||||
payloads: [{ text: "hi", mediaUrl: "https://example.com/x.png" }],
|
|
||||||
deps: { sendTelegram },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(sendTelegram).toHaveBeenCalledWith(
|
expect(sendTelegram).toHaveBeenCalledWith(
|
||||||
@@ -613,22 +657,7 @@ describe("deliverOutboundPayloads", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("continues on errors when bestEffort is enabled", async () => {
|
it("continues on errors when bestEffort is enabled", async () => {
|
||||||
const sendWhatsApp = vi
|
const { sendWhatsApp, onError, results } = await runBestEffortPartialFailureDelivery();
|
||||||
.fn()
|
|
||||||
.mockRejectedValueOnce(new Error("fail"))
|
|
||||||
.mockResolvedValueOnce({ messageId: "w2", toJid: "jid" });
|
|
||||||
const onError = vi.fn();
|
|
||||||
const cfg: OpenClawConfig = {};
|
|
||||||
|
|
||||||
const results = await deliverOutboundPayloads({
|
|
||||||
cfg,
|
|
||||||
channel: "whatsapp",
|
|
||||||
to: "+1555",
|
|
||||||
payloads: [{ text: "a" }, { text: "b" }],
|
|
||||||
deps: { sendWhatsApp },
|
|
||||||
bestEffort: true,
|
|
||||||
onError,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(sendWhatsApp).toHaveBeenCalledTimes(2);
|
expect(sendWhatsApp).toHaveBeenCalledTimes(2);
|
||||||
expect(onError).toHaveBeenCalledTimes(1);
|
expect(onError).toHaveBeenCalledTimes(1);
|
||||||
@@ -650,12 +679,8 @@ describe("deliverOutboundPayloads", () => {
|
|||||||
"message",
|
"message",
|
||||||
"sent",
|
"sent",
|
||||||
"agent:main:main",
|
"agent:main:main",
|
||||||
expect.objectContaining({
|
expectSuccessfulWhatsAppInternalHookPayload({
|
||||||
to: "+1555",
|
|
||||||
content: "abcd",
|
content: "abcd",
|
||||||
success: true,
|
|
||||||
channelId: "whatsapp",
|
|
||||||
conversationId: "+1555",
|
|
||||||
messageId: "w2",
|
messageId: "w2",
|
||||||
isGroup: true,
|
isGroup: true,
|
||||||
groupId: "whatsapp:group:123",
|
groupId: "whatsapp:group:123",
|
||||||
@@ -679,14 +704,7 @@ describe("deliverOutboundPayloads", () => {
|
|||||||
"message",
|
"message",
|
||||||
"sent",
|
"sent",
|
||||||
"agent:main:main",
|
"agent:main:main",
|
||||||
expect.objectContaining({
|
expectSuccessfulWhatsAppInternalHookPayload({ content: "hello", messageId: "w1" }),
|
||||||
to: "+1555",
|
|
||||||
content: "hello",
|
|
||||||
success: true,
|
|
||||||
channelId: "whatsapp",
|
|
||||||
conversationId: "+1555",
|
|
||||||
messageId: "w1",
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
expect(internalHookMocks.triggerInternalHook).toHaveBeenCalledTimes(1);
|
expect(internalHookMocks.triggerInternalHook).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
@@ -711,22 +729,7 @@ describe("deliverOutboundPayloads", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("calls failDelivery instead of ackDelivery on bestEffort partial failure", async () => {
|
it("calls failDelivery instead of ackDelivery on bestEffort partial failure", async () => {
|
||||||
const sendWhatsApp = vi
|
const { onError } = await runBestEffortPartialFailureDelivery();
|
||||||
.fn()
|
|
||||||
.mockRejectedValueOnce(new Error("fail"))
|
|
||||||
.mockResolvedValueOnce({ messageId: "w2", toJid: "jid" });
|
|
||||||
const onError = vi.fn();
|
|
||||||
const cfg: OpenClawConfig = {};
|
|
||||||
|
|
||||||
await deliverOutboundPayloads({
|
|
||||||
cfg,
|
|
||||||
channel: "whatsapp",
|
|
||||||
to: "+1555",
|
|
||||||
payloads: [{ text: "a" }, { text: "b" }],
|
|
||||||
deps: { sendWhatsApp },
|
|
||||||
bestEffort: true,
|
|
||||||
onError,
|
|
||||||
});
|
|
||||||
|
|
||||||
// onError was called for the first payload's failure.
|
// onError was called for the first payload's failure.
|
||||||
expect(onError).toHaveBeenCalledTimes(1);
|
expect(onError).toHaveBeenCalledTimes(1);
|
||||||
|
|||||||
Reference in New Issue
Block a user