mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-10 04:22:43 +00:00
refactor: dedupe agent and browser cli helpers
This commit is contained in:
@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { buildSystemRunPreparePayload } from "../test-utils/system-run-prepare-payload.js";
|
||||
|
||||
vi.mock("./tools/gateway.js", () => ({
|
||||
callGatewayTool: vi.fn(),
|
||||
@@ -38,20 +39,7 @@ function buildPreparedSystemRunPayload(rawInvokeParams: unknown) {
|
||||
};
|
||||
};
|
||||
const params = invoke.params ?? {};
|
||||
const argv = Array.isArray(params.command) ? params.command.map(String) : [];
|
||||
const rawCommand = typeof params.rawCommand === "string" ? params.rawCommand : null;
|
||||
return {
|
||||
payload: {
|
||||
cmdText: rawCommand ?? argv.join(" "),
|
||||
plan: {
|
||||
argv,
|
||||
cwd: typeof params.cwd === "string" ? params.cwd : null,
|
||||
rawCommand,
|
||||
agentId: typeof params.agentId === "string" ? params.agentId : null,
|
||||
sessionKey: typeof params.sessionKey === "string" ? params.sessionKey : null,
|
||||
},
|
||||
},
|
||||
};
|
||||
return buildSystemRunPreparePayload(params);
|
||||
}
|
||||
|
||||
describe("exec approvals", () => {
|
||||
|
||||
@@ -2,6 +2,10 @@ import { completeSimple, type Model } from "@mariozechner/pi-ai";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isTruthyEnvValue } from "../infra/env.js";
|
||||
import { BYTEPLUS_CODING_BASE_URL, BYTEPLUS_DEFAULT_COST } from "./byteplus-models.js";
|
||||
import {
|
||||
createSingleUserPromptMessage,
|
||||
extractNonEmptyAssistantText,
|
||||
} from "./live-test-helpers.js";
|
||||
|
||||
const BYTEPLUS_KEY = process.env.BYTEPLUS_API_KEY ?? "";
|
||||
const BYTEPLUS_CODING_MODEL = process.env.BYTEPLUS_CODING_MODEL?.trim() || "ark-code-latest";
|
||||
@@ -27,21 +31,12 @@ describeLive("byteplus coding plan live", () => {
|
||||
const res = await completeSimple(
|
||||
model,
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: "Reply with the word ok.",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
messages: createSingleUserPromptMessage(),
|
||||
},
|
||||
{ apiKey: BYTEPLUS_KEY, maxTokens: 64 },
|
||||
);
|
||||
|
||||
const text = res.content
|
||||
.filter((block) => block.type === "text")
|
||||
.map((block) => block.text.trim())
|
||||
.join(" ");
|
||||
const text = extractNonEmptyAssistantText(res.content);
|
||||
expect(text.length).toBeGreaterThan(0);
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
24
src/agents/live-test-helpers.ts
Normal file
24
src/agents/live-test-helpers.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export const LIVE_OK_PROMPT = "Reply with the word ok.";
|
||||
|
||||
export function createSingleUserPromptMessage(content = LIVE_OK_PROMPT) {
|
||||
return [
|
||||
{
|
||||
role: "user" as const,
|
||||
content,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function extractNonEmptyAssistantText(
|
||||
content: Array<{
|
||||
type?: string;
|
||||
text?: string;
|
||||
}>,
|
||||
) {
|
||||
return content
|
||||
.filter((block) => block.type === "text")
|
||||
.map((block) => block.text?.trim() ?? "")
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
}
|
||||
@@ -32,6 +32,14 @@ describe("Ollama auto-discovery", () => {
|
||||
originalFetch = globalThis.fetch;
|
||||
}
|
||||
|
||||
function mockOllamaUnreachable() {
|
||||
globalThis.fetch = vi
|
||||
.fn()
|
||||
.mockRejectedValue(
|
||||
new Error("connect ECONNREFUSED 127.0.0.1:11434"),
|
||||
) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
it("auto-registers ollama provider when models are discovered locally", async () => {
|
||||
setupDiscoveryEnv();
|
||||
globalThis.fetch = vi.fn().mockImplementation(async (url: string | URL) => {
|
||||
@@ -62,11 +70,7 @@ describe("Ollama auto-discovery", () => {
|
||||
it("does not warn when Ollama is unreachable and not explicitly configured", async () => {
|
||||
setupDiscoveryEnv();
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
globalThis.fetch = vi
|
||||
.fn()
|
||||
.mockRejectedValue(
|
||||
new Error("connect ECONNREFUSED 127.0.0.1:11434"),
|
||||
) as unknown as typeof fetch;
|
||||
mockOllamaUnreachable();
|
||||
|
||||
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
|
||||
const providers = await resolveImplicitProviders({ agentDir });
|
||||
@@ -82,11 +86,7 @@ describe("Ollama auto-discovery", () => {
|
||||
it("warns when Ollama is unreachable and explicitly configured", async () => {
|
||||
setupDiscoveryEnv();
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
globalThis.fetch = vi
|
||||
.fn()
|
||||
.mockRejectedValue(
|
||||
new Error("connect ECONNREFUSED 127.0.0.1:11434"),
|
||||
) as unknown as typeof fetch;
|
||||
mockOllamaUnreachable();
|
||||
|
||||
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
|
||||
await resolveImplicitProviders({
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { completeSimple, type Model } from "@mariozechner/pi-ai";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isTruthyEnvValue } from "../infra/env.js";
|
||||
import {
|
||||
createSingleUserPromptMessage,
|
||||
extractNonEmptyAssistantText,
|
||||
} from "./live-test-helpers.js";
|
||||
|
||||
const MOONSHOT_KEY = process.env.MOONSHOT_API_KEY ?? "";
|
||||
const MOONSHOT_BASE_URL = process.env.MOONSHOT_BASE_URL?.trim() || "https://api.moonshot.ai/v1";
|
||||
@@ -27,21 +31,12 @@ describeLive("moonshot live", () => {
|
||||
const res = await completeSimple(
|
||||
model,
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: "Reply with the word ok.",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
messages: createSingleUserPromptMessage(),
|
||||
},
|
||||
{ apiKey: MOONSHOT_KEY, maxTokens: 64 },
|
||||
);
|
||||
|
||||
const text = res.content
|
||||
.filter((block) => block.type === "text")
|
||||
.map((block) => block.text.trim())
|
||||
.join(" ");
|
||||
const text = extractNonEmptyAssistantText(res.content);
|
||||
expect(text.length).toBeGreaterThan(0);
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
@@ -171,6 +171,20 @@ function buildManager(opts?: ConstructorParameters<typeof OpenAIWebSocketManager
|
||||
});
|
||||
}
|
||||
|
||||
function attachErrorCollector(manager: OpenAIWebSocketManager) {
|
||||
const errors: Error[] = [];
|
||||
manager.on("error", (e) => errors.push(e));
|
||||
return errors;
|
||||
}
|
||||
|
||||
async function connectManagerAndGetSocket(manager: OpenAIWebSocketManager) {
|
||||
const connectPromise = manager.connect("sk-test");
|
||||
const sock = lastSocket();
|
||||
sock.simulateOpen();
|
||||
await connectPromise;
|
||||
return sock;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -576,13 +590,8 @@ describe("OpenAIWebSocketManager", () => {
|
||||
describe("error handling", () => {
|
||||
it("emits error event on malformed JSON message", async () => {
|
||||
const manager = buildManager();
|
||||
const p = manager.connect("sk-test");
|
||||
const sock = lastSocket();
|
||||
sock.simulateOpen();
|
||||
await p;
|
||||
|
||||
const errors: Error[] = [];
|
||||
manager.on("error", (e) => errors.push(e));
|
||||
const sock = await connectManagerAndGetSocket(manager);
|
||||
const errors = attachErrorCollector(manager);
|
||||
|
||||
sock.emit("message", Buffer.from("not valid json{{{{"));
|
||||
|
||||
@@ -592,13 +601,8 @@ describe("OpenAIWebSocketManager", () => {
|
||||
|
||||
it("emits error event when message has no type field", async () => {
|
||||
const manager = buildManager();
|
||||
const p = manager.connect("sk-test");
|
||||
const sock = lastSocket();
|
||||
sock.simulateOpen();
|
||||
await p;
|
||||
|
||||
const errors: Error[] = [];
|
||||
manager.on("error", (e) => errors.push(e));
|
||||
const sock = await connectManagerAndGetSocket(manager);
|
||||
const errors = attachErrorCollector(manager);
|
||||
|
||||
sock.emit("message", Buffer.from(JSON.stringify({ foo: "bar" })));
|
||||
|
||||
@@ -611,9 +615,7 @@ describe("OpenAIWebSocketManager", () => {
|
||||
const p = manager.connect("sk-test").catch(() => {
|
||||
/* ignore rejection */
|
||||
});
|
||||
|
||||
const errors: Error[] = [];
|
||||
manager.on("error", (e) => errors.push(e));
|
||||
const errors = attachErrorCollector(manager);
|
||||
|
||||
lastSocket().simulateError(new Error("SSL handshake failed"));
|
||||
await p;
|
||||
@@ -626,9 +628,7 @@ describe("OpenAIWebSocketManager", () => {
|
||||
const p = manager.connect("sk-test").catch(() => {
|
||||
/* ignore rejection */
|
||||
});
|
||||
|
||||
const errors: Error[] = [];
|
||||
manager.on("error", (e) => errors.push(e));
|
||||
const errors = attachErrorCollector(manager);
|
||||
|
||||
// Fire two errors in quick succession — previously the second would
|
||||
// be unhandled because .once("error") removed the handler after #1.
|
||||
|
||||
@@ -107,6 +107,24 @@ describe("openclaw-tools: subagents (sessions_spawn allowlist)", () => {
|
||||
expect(getChildSessionKey()?.startsWith(`agent:${params.agentId}:subagent:`)).toBe(true);
|
||||
}
|
||||
|
||||
async function expectInvalidAgentId(callId: string, agentId: string) {
|
||||
setSessionsSpawnConfigOverride({
|
||||
session: { mainKey: "main", scope: "per-sender" },
|
||||
agents: {
|
||||
list: [{ id: "main", subagents: { allowAgents: ["*"] } }],
|
||||
},
|
||||
});
|
||||
const tool = await getSessionsSpawnTool({
|
||||
agentSessionKey: "main",
|
||||
agentChannel: "whatsapp",
|
||||
});
|
||||
const result = await tool.execute(callId, { task: "do thing", agentId });
|
||||
const details = result.details as { status?: string; error?: string };
|
||||
expect(details.status).toBe("error");
|
||||
expect(details.error).toContain("Invalid agentId");
|
||||
expect(callGatewayMock).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetSessionsSpawnConfigOverride();
|
||||
resetSubagentRegistryForTests();
|
||||
@@ -237,45 +255,11 @@ describe("openclaw-tools: subagents (sessions_spawn allowlist)", () => {
|
||||
});
|
||||
|
||||
it("rejects agentId containing path separators (#31311)", async () => {
|
||||
setSessionsSpawnConfigOverride({
|
||||
session: { mainKey: "main", scope: "per-sender" },
|
||||
agents: {
|
||||
list: [{ id: "main", subagents: { allowAgents: ["*"] } }],
|
||||
},
|
||||
});
|
||||
const tool = await getSessionsSpawnTool({
|
||||
agentSessionKey: "main",
|
||||
agentChannel: "whatsapp",
|
||||
});
|
||||
const result = await tool.execute("call-path", {
|
||||
task: "do thing",
|
||||
agentId: "../../../etc/passwd",
|
||||
});
|
||||
const details = result.details as { status?: string; error?: string };
|
||||
expect(details.status).toBe("error");
|
||||
expect(details.error).toContain("Invalid agentId");
|
||||
expect(callGatewayMock).not.toHaveBeenCalled();
|
||||
await expectInvalidAgentId("call-path", "../../../etc/passwd");
|
||||
});
|
||||
|
||||
it("rejects agentId exceeding 64 characters (#31311)", async () => {
|
||||
setSessionsSpawnConfigOverride({
|
||||
session: { mainKey: "main", scope: "per-sender" },
|
||||
agents: {
|
||||
list: [{ id: "main", subagents: { allowAgents: ["*"] } }],
|
||||
},
|
||||
});
|
||||
const tool = await getSessionsSpawnTool({
|
||||
agentSessionKey: "main",
|
||||
agentChannel: "whatsapp",
|
||||
});
|
||||
const result = await tool.execute("call-long", {
|
||||
task: "do thing",
|
||||
agentId: "a".repeat(65),
|
||||
});
|
||||
const details = result.details as { status?: string; error?: string };
|
||||
expect(details.status).toBe("error");
|
||||
expect(details.error).toContain("Invalid agentId");
|
||||
expect(callGatewayMock).not.toHaveBeenCalled();
|
||||
await expectInvalidAgentId("call-long", "a".repeat(65));
|
||||
});
|
||||
|
||||
it("accepts well-formed agentId with hyphens and underscores (#31311)", async () => {
|
||||
|
||||
@@ -9,6 +9,16 @@ type RelativePathOptions = {
|
||||
includeRootInError?: boolean;
|
||||
};
|
||||
|
||||
function throwPathEscapesBoundary(params: {
|
||||
options?: RelativePathOptions;
|
||||
rootResolved: string;
|
||||
candidate: string;
|
||||
}): never {
|
||||
const boundary = params.options?.boundaryLabel ?? "workspace root";
|
||||
const suffix = params.options?.includeRootInError ? ` (${params.rootResolved})` : "";
|
||||
throw new Error(`Path escapes ${boundary}${suffix}: ${params.candidate}`);
|
||||
}
|
||||
|
||||
function toRelativePathUnderRoot(params: {
|
||||
root: string;
|
||||
candidate: string;
|
||||
@@ -29,14 +39,18 @@ function toRelativePathUnderRoot(params: {
|
||||
if (params.options?.allowRoot) {
|
||||
return "";
|
||||
}
|
||||
const boundary = params.options?.boundaryLabel ?? "workspace root";
|
||||
const suffix = params.options?.includeRootInError ? ` (${rootResolved})` : "";
|
||||
throw new Error(`Path escapes ${boundary}${suffix}: ${params.candidate}`);
|
||||
throwPathEscapesBoundary({
|
||||
options: params.options,
|
||||
rootResolved,
|
||||
candidate: params.candidate,
|
||||
});
|
||||
}
|
||||
if (relative.startsWith("..") || path.win32.isAbsolute(relative)) {
|
||||
const boundary = params.options?.boundaryLabel ?? "workspace root";
|
||||
const suffix = params.options?.includeRootInError ? ` (${rootResolved})` : "";
|
||||
throw new Error(`Path escapes ${boundary}${suffix}: ${params.candidate}`);
|
||||
throwPathEscapesBoundary({
|
||||
options: params.options,
|
||||
rootResolved,
|
||||
candidate: params.candidate,
|
||||
});
|
||||
}
|
||||
return relative;
|
||||
}
|
||||
@@ -48,14 +62,18 @@ function toRelativePathUnderRoot(params: {
|
||||
if (params.options?.allowRoot) {
|
||||
return "";
|
||||
}
|
||||
const boundary = params.options?.boundaryLabel ?? "workspace root";
|
||||
const suffix = params.options?.includeRootInError ? ` (${rootResolved})` : "";
|
||||
throw new Error(`Path escapes ${boundary}${suffix}: ${params.candidate}`);
|
||||
throwPathEscapesBoundary({
|
||||
options: params.options,
|
||||
rootResolved,
|
||||
candidate: params.candidate,
|
||||
});
|
||||
}
|
||||
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
||||
const boundary = params.options?.boundaryLabel ?? "workspace root";
|
||||
const suffix = params.options?.includeRootInError ? ` (${rootResolved})` : "";
|
||||
throw new Error(`Path escapes ${boundary}${suffix}: ${params.candidate}`);
|
||||
throwPathEscapesBoundary({
|
||||
options: params.options,
|
||||
rootResolved,
|
||||
candidate: params.candidate,
|
||||
});
|
||||
}
|
||||
return relative;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,29 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { EmbeddedBlockChunker } from "./pi-embedded-block-chunker.js";
|
||||
|
||||
function createFlushOnParagraphChunker(params: { minChars: number; maxChars: number }) {
|
||||
return new EmbeddedBlockChunker({
|
||||
minChars: params.minChars,
|
||||
maxChars: params.maxChars,
|
||||
breakPreference: "paragraph",
|
||||
flushOnParagraph: true,
|
||||
});
|
||||
}
|
||||
|
||||
function drainChunks(chunker: EmbeddedBlockChunker) {
|
||||
const chunks: string[] = [];
|
||||
chunker.drain({ force: false, emit: (chunk) => chunks.push(chunk) });
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function expectFlushAtFirstParagraphBreak(text: string) {
|
||||
const chunker = createFlushOnParagraphChunker({ minChars: 100, maxChars: 200 });
|
||||
chunker.append(text);
|
||||
const chunks = drainChunks(chunker);
|
||||
expect(chunks).toEqual(["First paragraph."]);
|
||||
expect(chunker.bufferedText).toBe("Second paragraph.");
|
||||
}
|
||||
|
||||
describe("EmbeddedBlockChunker", () => {
|
||||
it("breaks at paragraph boundary right after fence close", () => {
|
||||
const chunker = new EmbeddedBlockChunker({
|
||||
@@ -21,8 +44,7 @@ describe("EmbeddedBlockChunker", () => {
|
||||
|
||||
chunker.append(text);
|
||||
|
||||
const chunks: string[] = [];
|
||||
chunker.drain({ force: false, emit: (chunk) => chunks.push(chunk) });
|
||||
const chunks = drainChunks(chunker);
|
||||
|
||||
expect(chunks.length).toBe(1);
|
||||
expect(chunks[0]).toContain("console.log");
|
||||
@@ -32,37 +54,11 @@ describe("EmbeddedBlockChunker", () => {
|
||||
});
|
||||
|
||||
it("flushes paragraph boundaries before minChars when flushOnParagraph is set", () => {
|
||||
const chunker = new EmbeddedBlockChunker({
|
||||
minChars: 100,
|
||||
maxChars: 200,
|
||||
breakPreference: "paragraph",
|
||||
flushOnParagraph: true,
|
||||
});
|
||||
|
||||
chunker.append("First paragraph.\n\nSecond paragraph.");
|
||||
|
||||
const chunks: string[] = [];
|
||||
chunker.drain({ force: false, emit: (chunk) => chunks.push(chunk) });
|
||||
|
||||
expect(chunks).toEqual(["First paragraph."]);
|
||||
expect(chunker.bufferedText).toBe("Second paragraph.");
|
||||
expectFlushAtFirstParagraphBreak("First paragraph.\n\nSecond paragraph.");
|
||||
});
|
||||
|
||||
it("treats blank lines with whitespace as paragraph boundaries when flushOnParagraph is set", () => {
|
||||
const chunker = new EmbeddedBlockChunker({
|
||||
minChars: 100,
|
||||
maxChars: 200,
|
||||
breakPreference: "paragraph",
|
||||
flushOnParagraph: true,
|
||||
});
|
||||
|
||||
chunker.append("First paragraph.\n \nSecond paragraph.");
|
||||
|
||||
const chunks: string[] = [];
|
||||
chunker.drain({ force: false, emit: (chunk) => chunks.push(chunk) });
|
||||
|
||||
expect(chunks).toEqual(["First paragraph."]);
|
||||
expect(chunker.bufferedText).toBe("Second paragraph.");
|
||||
expectFlushAtFirstParagraphBreak("First paragraph.\n \nSecond paragraph.");
|
||||
});
|
||||
|
||||
it("falls back to maxChars when flushOnParagraph is set and no paragraph break exists", () => {
|
||||
@@ -75,8 +71,7 @@ describe("EmbeddedBlockChunker", () => {
|
||||
|
||||
chunker.append("abcdefghijKLMNOP");
|
||||
|
||||
const chunks: string[] = [];
|
||||
chunker.drain({ force: false, emit: (chunk) => chunks.push(chunk) });
|
||||
const chunks = drainChunks(chunker);
|
||||
|
||||
expect(chunks).toEqual(["abcdefghij"]);
|
||||
expect(chunker.bufferedText).toBe("KLMNOP");
|
||||
@@ -92,8 +87,7 @@ describe("EmbeddedBlockChunker", () => {
|
||||
|
||||
chunker.append("abcdefghijk\n\nRest");
|
||||
|
||||
const chunks: string[] = [];
|
||||
chunker.drain({ force: false, emit: (chunk) => chunks.push(chunk) });
|
||||
const chunks = drainChunks(chunker);
|
||||
|
||||
expect(chunks.every((chunk) => chunk.length <= 10)).toBe(true);
|
||||
expect(chunks).toEqual(["abcdefghij", "k"]);
|
||||
@@ -121,8 +115,7 @@ describe("EmbeddedBlockChunker", () => {
|
||||
|
||||
chunker.append(text);
|
||||
|
||||
const chunks: string[] = [];
|
||||
chunker.drain({ force: false, emit: (chunk) => chunks.push(chunk) });
|
||||
const chunks = drainChunks(chunker);
|
||||
|
||||
expect(chunks).toEqual(["Intro\n```js\nconst a = 1;\n\nconst b = 2;\n```"]);
|
||||
expect(chunker.bufferedText).toBe("After fence");
|
||||
|
||||
@@ -200,7 +200,7 @@ function stripStaleAssistantUsageBeforeLatestCompaction(messages: AgentMessage[]
|
||||
return touched ? out : messages;
|
||||
}
|
||||
|
||||
function findUnsupportedSchemaKeywords(schema: unknown, path: string): string[] {
|
||||
export function findUnsupportedSchemaKeywords(schema: unknown, path: string): string[] {
|
||||
if (!schema || typeof schema !== "object") {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -482,40 +482,39 @@ describe("compaction-safeguard double-compaction guard", () => {
|
||||
});
|
||||
});
|
||||
|
||||
async function expectWorkspaceSummaryEmptyForAgentsAlias(
|
||||
createAlias: (outsidePath: string, agentsPath: string) => void,
|
||||
) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-compaction-summary-"));
|
||||
const prevCwd = process.cwd();
|
||||
try {
|
||||
const outside = path.join(root, "outside-secret.txt");
|
||||
fs.writeFileSync(outside, "secret");
|
||||
createAlias(outside, path.join(root, "AGENTS.md"));
|
||||
process.chdir(root);
|
||||
await expect(readWorkspaceContextForSummary()).resolves.toBe("");
|
||||
} finally {
|
||||
process.chdir(prevCwd);
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
describe("readWorkspaceContextForSummary", () => {
|
||||
it.runIf(process.platform !== "win32")(
|
||||
"returns empty when AGENTS.md is a symlink escape",
|
||||
async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-compaction-summary-"));
|
||||
const prevCwd = process.cwd();
|
||||
try {
|
||||
const outside = path.join(root, "outside-secret.txt");
|
||||
fs.writeFileSync(outside, "secret");
|
||||
fs.symlinkSync(outside, path.join(root, "AGENTS.md"));
|
||||
process.chdir(root);
|
||||
await expect(readWorkspaceContextForSummary()).resolves.toBe("");
|
||||
} finally {
|
||||
process.chdir(prevCwd);
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
await expectWorkspaceSummaryEmptyForAgentsAlias((outside, agentsPath) => {
|
||||
fs.symlinkSync(outside, agentsPath);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.runIf(process.platform !== "win32")(
|
||||
"returns empty when AGENTS.md is a hardlink alias",
|
||||
async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-compaction-summary-"));
|
||||
const prevCwd = process.cwd();
|
||||
try {
|
||||
const outside = path.join(root, "outside-secret.txt");
|
||||
fs.writeFileSync(outside, "secret");
|
||||
fs.linkSync(outside, path.join(root, "AGENTS.md"));
|
||||
process.chdir(root);
|
||||
await expect(readWorkspaceContextForSummary()).resolves.toBe("");
|
||||
} finally {
|
||||
process.chdir(prevCwd);
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
await expectWorkspaceSummaryEmptyForAgentsAlias((outside, agentsPath) => {
|
||||
fs.linkSync(outside, agentsPath);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -28,6 +28,16 @@ describe("Agent-specific tool filtering", () => {
|
||||
stat: async () => null,
|
||||
};
|
||||
|
||||
function expectReadOnlyToolSet(toolNames: string[], extraDenied: string[] = []) {
|
||||
expect(toolNames).toContain("read");
|
||||
expect(toolNames).not.toContain("exec");
|
||||
expect(toolNames).not.toContain("write");
|
||||
expect(toolNames).not.toContain("apply_patch");
|
||||
for (const toolName of extraDenied) {
|
||||
expect(toolNames).not.toContain(toolName);
|
||||
}
|
||||
}
|
||||
|
||||
async function withApplyPatchEscapeCase(
|
||||
opts: { workspaceOnly?: boolean },
|
||||
run: (params: {
|
||||
@@ -250,12 +260,10 @@ describe("Agent-specific tool filtering", () => {
|
||||
agentDir: "/tmp/agent-restricted",
|
||||
});
|
||||
|
||||
const toolNames = tools.map((t) => t.name);
|
||||
expect(toolNames).toContain("read");
|
||||
expect(toolNames).not.toContain("exec");
|
||||
expect(toolNames).not.toContain("write");
|
||||
expect(toolNames).not.toContain("apply_patch");
|
||||
expect(toolNames).not.toContain("edit");
|
||||
expectReadOnlyToolSet(
|
||||
tools.map((t) => t.name),
|
||||
["edit"],
|
||||
);
|
||||
});
|
||||
|
||||
it("should apply provider-specific tool policy", () => {
|
||||
@@ -279,11 +287,7 @@ describe("Agent-specific tool filtering", () => {
|
||||
modelId: "claude-opus-4-6-thinking",
|
||||
});
|
||||
|
||||
const toolNames = tools.map((t) => t.name);
|
||||
expect(toolNames).toContain("read");
|
||||
expect(toolNames).not.toContain("exec");
|
||||
expect(toolNames).not.toContain("write");
|
||||
expect(toolNames).not.toContain("apply_patch");
|
||||
expectReadOnlyToolSet(tools.map((t) => t.name));
|
||||
});
|
||||
|
||||
it("should apply provider-specific tool profile overrides", () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Type } from "@sinclair/typebox";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import "./test-helpers/fast-coding-tools.js";
|
||||
import { createOpenClawTools } from "./openclaw-tools.js";
|
||||
import { findUnsupportedSchemaKeywords } from "./pi-embedded-runner/google.js";
|
||||
import { __testing, createOpenClawCodingTools } from "./pi-tools.js";
|
||||
import { createOpenClawReadTool, createSandboxedReadTool } from "./pi-tools.read.js";
|
||||
import { createHostSandboxFsBridge } from "./test-helpers/host-sandbox-fs-bridge.js";
|
||||
@@ -444,75 +445,12 @@ describe("createOpenClawCodingTools", () => {
|
||||
expect(names.has("read")).toBe(false);
|
||||
});
|
||||
it("removes unsupported JSON Schema keywords for Cloud Code Assist API compatibility", () => {
|
||||
// Helper to recursively check schema for unsupported keywords
|
||||
const unsupportedKeywords = new Set([
|
||||
"patternProperties",
|
||||
"additionalProperties",
|
||||
"$schema",
|
||||
"$id",
|
||||
"$ref",
|
||||
"$defs",
|
||||
"definitions",
|
||||
"examples",
|
||||
"minLength",
|
||||
"maxLength",
|
||||
"minimum",
|
||||
"maximum",
|
||||
"multipleOf",
|
||||
"pattern",
|
||||
"format",
|
||||
"minItems",
|
||||
"maxItems",
|
||||
"uniqueItems",
|
||||
"minProperties",
|
||||
"maxProperties",
|
||||
]);
|
||||
|
||||
const findUnsupportedKeywords = (schema: unknown, path: string): string[] => {
|
||||
const found: string[] = [];
|
||||
if (!schema || typeof schema !== "object") {
|
||||
return found;
|
||||
}
|
||||
if (Array.isArray(schema)) {
|
||||
schema.forEach((item, i) => {
|
||||
found.push(...findUnsupportedKeywords(item, `${path}[${i}]`));
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
const record = schema as Record<string, unknown>;
|
||||
const properties =
|
||||
record.properties &&
|
||||
typeof record.properties === "object" &&
|
||||
!Array.isArray(record.properties)
|
||||
? (record.properties as Record<string, unknown>)
|
||||
: undefined;
|
||||
if (properties) {
|
||||
for (const [key, value] of Object.entries(properties)) {
|
||||
found.push(...findUnsupportedKeywords(value, `${path}.properties.${key}`));
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(record)) {
|
||||
if (key === "properties") {
|
||||
continue;
|
||||
}
|
||||
if (unsupportedKeywords.has(key)) {
|
||||
found.push(`${path}.${key}`);
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
found.push(...findUnsupportedKeywords(value, `${path}.${key}`));
|
||||
}
|
||||
}
|
||||
return found;
|
||||
};
|
||||
|
||||
const googleTools = createOpenClawCodingTools({
|
||||
modelProvider: "google",
|
||||
senderIsOwner: true,
|
||||
});
|
||||
for (const tool of googleTools) {
|
||||
const violations = findUnsupportedKeywords(tool.parameters, `${tool.name}.parameters`);
|
||||
const violations = findUnsupportedSchemaKeywords(tool.parameters, `${tool.name}.parameters`);
|
||||
expect(violations).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -763,6 +763,12 @@ function createSandboxEditOperations(params: SandboxToolParams) {
|
||||
} as const;
|
||||
}
|
||||
|
||||
async function writeHostFile(absolutePath: string, content: string) {
|
||||
const resolved = path.resolve(absolutePath);
|
||||
await fs.mkdir(path.dirname(resolved), { recursive: true });
|
||||
await fs.writeFile(resolved, content, "utf-8");
|
||||
}
|
||||
|
||||
function createHostWriteOperations(root: string, options?: { workspaceOnly?: boolean }) {
|
||||
const workspaceOnly = options?.workspaceOnly ?? false;
|
||||
|
||||
@@ -773,12 +779,7 @@ function createHostWriteOperations(root: string, options?: { workspaceOnly?: boo
|
||||
const resolved = path.resolve(dir);
|
||||
await fs.mkdir(resolved, { recursive: true });
|
||||
},
|
||||
writeFile: async (absolutePath: string, content: string) => {
|
||||
const resolved = path.resolve(absolutePath);
|
||||
const dir = path.dirname(resolved);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.writeFile(resolved, content, "utf-8");
|
||||
},
|
||||
writeFile: writeHostFile,
|
||||
} as const;
|
||||
}
|
||||
|
||||
@@ -812,12 +813,7 @@ function createHostEditOperations(root: string, options?: { workspaceOnly?: bool
|
||||
const resolved = path.resolve(absolutePath);
|
||||
return await fs.readFile(resolved);
|
||||
},
|
||||
writeFile: async (absolutePath: string, content: string) => {
|
||||
const resolved = path.resolve(absolutePath);
|
||||
const dir = path.dirname(resolved);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.writeFile(resolved, content, "utf-8");
|
||||
},
|
||||
writeFile: writeHostFile,
|
||||
access: async (absolutePath: string) => {
|
||||
const resolved = path.resolve(absolutePath);
|
||||
await fs.access(resolved);
|
||||
|
||||
@@ -21,6 +21,35 @@ async function withTempDir<T>(prefix: string, fn: (dir: string) => Promise<T>) {
|
||||
}
|
||||
}
|
||||
|
||||
function createExecTool(workspaceDir: string) {
|
||||
const tools = createOpenClawCodingTools({
|
||||
workspaceDir,
|
||||
exec: { host: "gateway", ask: "off", security: "full" },
|
||||
});
|
||||
const execTool = tools.find((tool) => tool.name === "exec");
|
||||
expect(execTool).toBeDefined();
|
||||
return execTool;
|
||||
}
|
||||
|
||||
async function expectExecCwdResolvesTo(
|
||||
execTool: ReturnType<typeof createExecTool>,
|
||||
callId: string,
|
||||
params: { command: string; workdir?: string },
|
||||
expectedDir: string,
|
||||
) {
|
||||
const result = await execTool?.execute(callId, params);
|
||||
const cwd =
|
||||
result?.details && typeof result.details === "object" && "cwd" in result.details
|
||||
? (result.details as { cwd?: string }).cwd
|
||||
: undefined;
|
||||
expect(cwd).toBeTruthy();
|
||||
const [resolvedOutput, resolvedExpected] = await Promise.all([
|
||||
fs.realpath(String(cwd)),
|
||||
fs.realpath(expectedDir),
|
||||
]);
|
||||
expect(resolvedOutput).toBe(resolvedExpected);
|
||||
}
|
||||
|
||||
describe("workspace path resolution", () => {
|
||||
it("resolves relative read/write/edit paths against workspaceDir even after cwd changes", async () => {
|
||||
await withTempDir("openclaw-ws-", async (workspaceDir) => {
|
||||
@@ -88,53 +117,21 @@ describe("workspace path resolution", () => {
|
||||
|
||||
it("defaults exec cwd to workspaceDir when workdir is omitted", async () => {
|
||||
await withTempDir("openclaw-ws-", async (workspaceDir) => {
|
||||
const tools = createOpenClawCodingTools({
|
||||
workspaceDir,
|
||||
exec: { host: "gateway", ask: "off", security: "full" },
|
||||
});
|
||||
const execTool = tools.find((tool) => tool.name === "exec");
|
||||
expect(execTool).toBeDefined();
|
||||
|
||||
const result = await execTool?.execute("ws-exec", {
|
||||
command: "echo ok",
|
||||
});
|
||||
const cwd =
|
||||
result?.details && typeof result.details === "object" && "cwd" in result.details
|
||||
? (result.details as { cwd?: string }).cwd
|
||||
: undefined;
|
||||
expect(cwd).toBeTruthy();
|
||||
const [resolvedOutput, resolvedWorkspace] = await Promise.all([
|
||||
fs.realpath(String(cwd)),
|
||||
fs.realpath(workspaceDir),
|
||||
]);
|
||||
expect(resolvedOutput).toBe(resolvedWorkspace);
|
||||
const execTool = createExecTool(workspaceDir);
|
||||
await expectExecCwdResolvesTo(execTool, "ws-exec", { command: "echo ok" }, workspaceDir);
|
||||
});
|
||||
});
|
||||
|
||||
it("lets exec workdir override the workspace default", async () => {
|
||||
await withTempDir("openclaw-ws-", async (workspaceDir) => {
|
||||
await withTempDir("openclaw-override-", async (overrideDir) => {
|
||||
const tools = createOpenClawCodingTools({
|
||||
workspaceDir,
|
||||
exec: { host: "gateway", ask: "off", security: "full" },
|
||||
});
|
||||
const execTool = tools.find((tool) => tool.name === "exec");
|
||||
expect(execTool).toBeDefined();
|
||||
|
||||
const result = await execTool?.execute("ws-exec-override", {
|
||||
command: "echo ok",
|
||||
workdir: overrideDir,
|
||||
});
|
||||
const cwd =
|
||||
result?.details && typeof result.details === "object" && "cwd" in result.details
|
||||
? (result.details as { cwd?: string }).cwd
|
||||
: undefined;
|
||||
expect(cwd).toBeTruthy();
|
||||
const [resolvedOutput, resolvedOverride] = await Promise.all([
|
||||
fs.realpath(String(cwd)),
|
||||
fs.realpath(overrideDir),
|
||||
]);
|
||||
expect(resolvedOutput).toBe(resolvedOverride);
|
||||
const execTool = createExecTool(workspaceDir);
|
||||
await expectExecCwdResolvesTo(
|
||||
execTool,
|
||||
"ws-exec-override",
|
||||
{ command: "echo ok", workdir: overrideDir },
|
||||
overrideDir,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,6 +26,31 @@ function appendToolResultText(sm: SessionManager, text: string) {
|
||||
);
|
||||
}
|
||||
|
||||
function appendAssistantToolCall(
|
||||
sm: SessionManager,
|
||||
params: { id: string; name: string; withArguments?: boolean },
|
||||
) {
|
||||
const toolCall: {
|
||||
type: "toolCall";
|
||||
id: string;
|
||||
name: string;
|
||||
arguments?: Record<string, never>;
|
||||
} = {
|
||||
type: "toolCall",
|
||||
id: params.id,
|
||||
name: params.name,
|
||||
};
|
||||
if (params.withArguments !== false) {
|
||||
toolCall.arguments = {};
|
||||
}
|
||||
sm.appendMessage(
|
||||
asAppendMessage({
|
||||
role: "assistant",
|
||||
content: [toolCall],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function getPersistedMessages(sm: SessionManager): AgentMessage[] {
|
||||
return sm
|
||||
.getEntries()
|
||||
@@ -273,19 +298,8 @@ describe("installSessionToolResultGuard", () => {
|
||||
const sm = SessionManager.inMemory();
|
||||
installSessionToolResultGuard(sm);
|
||||
|
||||
sm.appendMessage(
|
||||
asAppendMessage({
|
||||
role: "assistant",
|
||||
content: [{ type: "toolCall", id: "call_1", name: "read", arguments: {} }],
|
||||
}),
|
||||
);
|
||||
|
||||
sm.appendMessage(
|
||||
asAppendMessage({
|
||||
role: "assistant",
|
||||
content: [{ type: "toolCall", id: "call_2", name: "read" }],
|
||||
}),
|
||||
);
|
||||
appendAssistantToolCall(sm, { id: "call_1", name: "read" });
|
||||
appendAssistantToolCall(sm, { id: "call_2", name: "read", withArguments: false });
|
||||
|
||||
expectPersistedRoles(sm, ["assistant", "toolResult"]);
|
||||
});
|
||||
@@ -297,19 +311,8 @@ describe("installSessionToolResultGuard", () => {
|
||||
allowedToolNames: ["read"],
|
||||
});
|
||||
|
||||
sm.appendMessage(
|
||||
asAppendMessage({
|
||||
role: "assistant",
|
||||
content: [{ type: "toolCall", id: "call_1", name: "read", arguments: {} }],
|
||||
}),
|
||||
);
|
||||
|
||||
sm.appendMessage(
|
||||
asAppendMessage({
|
||||
role: "assistant",
|
||||
content: [{ type: "toolCall", id: "call_2", name: "write", arguments: {} }],
|
||||
}),
|
||||
);
|
||||
appendAssistantToolCall(sm, { id: "call_1", name: "read" });
|
||||
appendAssistantToolCall(sm, { id: "call_2", name: "write" });
|
||||
|
||||
expectPersistedRoles(sm, ["assistant"]);
|
||||
expect(guard.getPendingIds()).toEqual([]);
|
||||
|
||||
@@ -65,6 +65,28 @@ function mockAgentStartFailure() {
|
||||
});
|
||||
}
|
||||
|
||||
async function runSessionThreadSpawnAndGetError(params: {
|
||||
toolCallId: string;
|
||||
spawningResult: { status: "error"; error: string } | { status: "ok"; threadBindingReady: false };
|
||||
}): Promise<{ error?: string; childSessionKey?: string }> {
|
||||
hookRunnerMocks.runSubagentSpawning.mockResolvedValueOnce(params.spawningResult);
|
||||
const tool = await getSessionsSpawnTool({
|
||||
agentSessionKey: "main",
|
||||
agentChannel: "discord",
|
||||
agentAccountId: "work",
|
||||
agentTo: "channel:123",
|
||||
});
|
||||
|
||||
const result = await tool.execute(params.toolCallId, {
|
||||
task: "do thing",
|
||||
runTimeoutSeconds: 1,
|
||||
thread: true,
|
||||
mode: "session",
|
||||
});
|
||||
expect(result.details).toMatchObject({ status: "error" });
|
||||
return result.details as { error?: string; childSessionKey?: string };
|
||||
}
|
||||
|
||||
describe("sessions_spawn subagent lifecycle hooks", () => {
|
||||
beforeEach(() => {
|
||||
resetSubagentRegistryForTests();
|
||||
@@ -214,26 +236,13 @@ describe("sessions_spawn subagent lifecycle hooks", () => {
|
||||
});
|
||||
|
||||
it("returns error when thread binding cannot be created", async () => {
|
||||
hookRunnerMocks.runSubagentSpawning.mockResolvedValueOnce({
|
||||
status: "error",
|
||||
error: "Unable to create or bind a Discord thread for this subagent session.",
|
||||
const details = await runSessionThreadSpawnAndGetError({
|
||||
toolCallId: "call4",
|
||||
spawningResult: {
|
||||
status: "error",
|
||||
error: "Unable to create or bind a Discord thread for this subagent session.",
|
||||
},
|
||||
});
|
||||
const tool = await getSessionsSpawnTool({
|
||||
agentSessionKey: "main",
|
||||
agentChannel: "discord",
|
||||
agentAccountId: "work",
|
||||
agentTo: "channel:123",
|
||||
});
|
||||
|
||||
const result = await tool.execute("call4", {
|
||||
task: "do thing",
|
||||
runTimeoutSeconds: 1,
|
||||
thread: true,
|
||||
mode: "session",
|
||||
});
|
||||
|
||||
expect(result.details).toMatchObject({ status: "error" });
|
||||
const details = result.details as { error?: string; childSessionKey?: string };
|
||||
expect(details.error).toMatch(/thread/i);
|
||||
expect(hookRunnerMocks.runSubagentSpawned).not.toHaveBeenCalled();
|
||||
expectSessionsDeleteWithoutAgentStart();
|
||||
@@ -245,26 +254,13 @@ describe("sessions_spawn subagent lifecycle hooks", () => {
|
||||
});
|
||||
|
||||
it("returns error when thread binding is not marked ready", async () => {
|
||||
hookRunnerMocks.runSubagentSpawning.mockResolvedValueOnce({
|
||||
status: "ok",
|
||||
threadBindingReady: false,
|
||||
const details = await runSessionThreadSpawnAndGetError({
|
||||
toolCallId: "call4b",
|
||||
spawningResult: {
|
||||
status: "ok",
|
||||
threadBindingReady: false,
|
||||
},
|
||||
});
|
||||
const tool = await getSessionsSpawnTool({
|
||||
agentSessionKey: "main",
|
||||
agentChannel: "discord",
|
||||
agentAccountId: "work",
|
||||
agentTo: "channel:123",
|
||||
});
|
||||
|
||||
const result = await tool.execute("call4b", {
|
||||
task: "do thing",
|
||||
runTimeoutSeconds: 1,
|
||||
thread: true,
|
||||
mode: "session",
|
||||
});
|
||||
|
||||
expect(result.details).toMatchObject({ status: "error" });
|
||||
const details = result.details as { error?: string; childSessionKey?: string };
|
||||
expect(details.error).toMatch(/unable to create or bind a thread/i);
|
||||
expect(hookRunnerMocks.runSubagentSpawned).not.toHaveBeenCalled();
|
||||
expectSessionsDeleteWithoutAgentStart();
|
||||
|
||||
@@ -28,15 +28,25 @@ describe("mapQueueOutcomeToDeliveryResult", () => {
|
||||
});
|
||||
|
||||
describe("runSubagentAnnounceDispatch", () => {
|
||||
it("uses queue-first ordering for non-completion mode", async () => {
|
||||
const queue = vi.fn(async () => "none" as const);
|
||||
const direct = vi.fn(async () => ({ delivered: true, path: "direct" as const }));
|
||||
|
||||
async function runNonCompletionDispatch(params: {
|
||||
queueOutcome: "none" | "queued" | "steered";
|
||||
directDelivered?: boolean;
|
||||
}) {
|
||||
const queue = vi.fn(async () => params.queueOutcome);
|
||||
const direct = vi.fn(async () => ({
|
||||
delivered: params.directDelivered ?? true,
|
||||
path: "direct" as const,
|
||||
}));
|
||||
const result = await runSubagentAnnounceDispatch({
|
||||
expectsCompletionMessage: false,
|
||||
queue,
|
||||
direct,
|
||||
});
|
||||
return { queue, direct, result };
|
||||
}
|
||||
|
||||
it("uses queue-first ordering for non-completion mode", async () => {
|
||||
const { queue, direct, result } = await runNonCompletionDispatch({ queueOutcome: "none" });
|
||||
|
||||
expect(queue).toHaveBeenCalledTimes(1);
|
||||
expect(direct).toHaveBeenCalledTimes(1);
|
||||
@@ -49,14 +59,7 @@ describe("runSubagentAnnounceDispatch", () => {
|
||||
});
|
||||
|
||||
it("short-circuits direct send when non-completion queue delivers", async () => {
|
||||
const queue = vi.fn(async () => "queued" as const);
|
||||
const direct = vi.fn(async () => ({ delivered: true, path: "direct" as const }));
|
||||
|
||||
const result = await runSubagentAnnounceDispatch({
|
||||
expectsCompletionMessage: false,
|
||||
queue,
|
||||
direct,
|
||||
});
|
||||
const { queue, direct, result } = await runNonCompletionDispatch({ queueOutcome: "queued" });
|
||||
|
||||
expect(queue).toHaveBeenCalledTimes(1);
|
||||
expect(direct).not.toHaveBeenCalled();
|
||||
|
||||
@@ -115,6 +115,16 @@ describe("subagent registry persistence", () => {
|
||||
return registryPath;
|
||||
};
|
||||
|
||||
const readPersistedRun = async <T>(
|
||||
registryPath: string,
|
||||
runId: string,
|
||||
): Promise<T | undefined> => {
|
||||
const parsed = JSON.parse(await fs.readFile(registryPath, "utf8")) as {
|
||||
runs?: Record<string, unknown>;
|
||||
};
|
||||
return parsed.runs?.[runId] as T | undefined;
|
||||
};
|
||||
|
||||
const createPersistedEndedRun = (params: {
|
||||
runId: string;
|
||||
childSessionKey: string;
|
||||
@@ -316,11 +326,12 @@ describe("subagent registry persistence", () => {
|
||||
await restartRegistryAndFlush();
|
||||
|
||||
expect(announceSpy).toHaveBeenCalledTimes(1);
|
||||
const afterFirst = JSON.parse(await fs.readFile(registryPath, "utf8")) as {
|
||||
runs: Record<string, { cleanupHandled?: boolean; cleanupCompletedAt?: number }>;
|
||||
};
|
||||
expect(afterFirst.runs["run-3"].cleanupHandled).toBe(false);
|
||||
expect(afterFirst.runs["run-3"].cleanupCompletedAt).toBeUndefined();
|
||||
const afterFirst = await readPersistedRun<{
|
||||
cleanupHandled?: boolean;
|
||||
cleanupCompletedAt?: number;
|
||||
}>(registryPath, "run-3");
|
||||
expect(afterFirst?.cleanupHandled).toBe(false);
|
||||
expect(afterFirst?.cleanupCompletedAt).toBeUndefined();
|
||||
|
||||
announceSpy.mockResolvedValueOnce(true);
|
||||
await restartRegistryAndFlush();
|
||||
@@ -345,10 +356,8 @@ describe("subagent registry persistence", () => {
|
||||
await restartRegistryAndFlush();
|
||||
|
||||
expect(announceSpy).toHaveBeenCalledTimes(1);
|
||||
const afterFirst = JSON.parse(await fs.readFile(registryPath, "utf8")) as {
|
||||
runs: Record<string, { cleanupHandled?: boolean }>;
|
||||
};
|
||||
expect(afterFirst.runs["run-4"]?.cleanupHandled).toBe(false);
|
||||
const afterFirst = await readPersistedRun<{ cleanupHandled?: boolean }>(registryPath, "run-4");
|
||||
expect(afterFirst?.cleanupHandled).toBe(false);
|
||||
|
||||
announceSpy.mockResolvedValueOnce(true);
|
||||
await restartRegistryAndFlush();
|
||||
|
||||
@@ -28,6 +28,27 @@ describe("cron tool", () => {
|
||||
return params?.payload?.text ?? "";
|
||||
}
|
||||
|
||||
function expectSingleGatewayCallMethod(method: string) {
|
||||
expect(callGatewayMock).toHaveBeenCalledTimes(1);
|
||||
const call = readGatewayCall(0);
|
||||
expect(call.method).toBe(method);
|
||||
return call.params;
|
||||
}
|
||||
|
||||
function buildReminderAgentTurnJob(overrides: Record<string, unknown> = {}): {
|
||||
name: string;
|
||||
schedule: { at: string };
|
||||
payload: { kind: "agentTurn"; message: string };
|
||||
delivery?: { mode: string; to?: string };
|
||||
} {
|
||||
return {
|
||||
name: "reminder",
|
||||
schedule: { at: new Date(123).toISOString() },
|
||||
payload: { kind: "agentTurn", message: "hello" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function executeAddAndReadDelivery(params: {
|
||||
callId: string;
|
||||
agentSessionKey: string;
|
||||
@@ -37,9 +58,7 @@ describe("cron tool", () => {
|
||||
await tool.execute(params.callId, {
|
||||
action: "add",
|
||||
job: {
|
||||
name: "reminder",
|
||||
schedule: { at: new Date(123).toISOString() },
|
||||
payload: { kind: "agentTurn", message: "hello" },
|
||||
...buildReminderAgentTurnJob(),
|
||||
...(params.delivery !== undefined ? { delivery: params.delivery } : {}),
|
||||
},
|
||||
});
|
||||
@@ -114,13 +133,8 @@ describe("cron tool", () => {
|
||||
const tool = createCronTool();
|
||||
await tool.execute("call1", args);
|
||||
|
||||
expect(callGatewayMock).toHaveBeenCalledTimes(1);
|
||||
const call = callGatewayMock.mock.calls[0]?.[0] as {
|
||||
method?: string;
|
||||
params?: unknown;
|
||||
};
|
||||
expect(call.method).toBe(`cron.${action}`);
|
||||
expect(call.params).toEqual(expectedParams);
|
||||
const params = expectSingleGatewayCallMethod(`cron.${action}`);
|
||||
expect(params).toEqual(expectedParams);
|
||||
});
|
||||
|
||||
it("prefers jobId over id when both are provided", async () => {
|
||||
@@ -131,10 +145,7 @@ describe("cron tool", () => {
|
||||
id: "job-legacy",
|
||||
});
|
||||
|
||||
const call = callGatewayMock.mock.calls[0]?.[0] as {
|
||||
params?: unknown;
|
||||
};
|
||||
expect(call?.params).toEqual({ id: "job-primary", mode: "force" });
|
||||
expect(readGatewayCall().params).toEqual({ id: "job-primary", mode: "force" });
|
||||
});
|
||||
|
||||
it("supports due-only run mode", async () => {
|
||||
@@ -145,10 +156,7 @@ describe("cron tool", () => {
|
||||
runMode: "due",
|
||||
});
|
||||
|
||||
const call = callGatewayMock.mock.calls[0]?.[0] as {
|
||||
params?: unknown;
|
||||
};
|
||||
expect(call?.params).toEqual({ id: "job-due", mode: "due" });
|
||||
expect(readGatewayCall().params).toEqual({ id: "job-due", mode: "due" });
|
||||
});
|
||||
|
||||
it("normalizes cron.add job payloads", async () => {
|
||||
@@ -164,13 +172,8 @@ describe("cron tool", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(callGatewayMock).toHaveBeenCalledTimes(1);
|
||||
const call = callGatewayMock.mock.calls[0]?.[0] as {
|
||||
method?: string;
|
||||
params?: unknown;
|
||||
};
|
||||
expect(call.method).toBe("cron.add");
|
||||
expect(call.params).toEqual({
|
||||
const params = expectSingleGatewayCallMethod("cron.add");
|
||||
expect(params).toEqual({
|
||||
name: "wake-up",
|
||||
enabled: true,
|
||||
deleteAfterRun: true,
|
||||
@@ -367,15 +370,12 @@ describe("cron tool", () => {
|
||||
payload: { kind: "agentTurn", message: "do stuff" },
|
||||
});
|
||||
|
||||
expect(callGatewayMock).toHaveBeenCalledTimes(1);
|
||||
const call = callGatewayMock.mock.calls[0]?.[0] as {
|
||||
method?: string;
|
||||
params?: { name?: string; sessionTarget?: string; payload?: { kind?: string } };
|
||||
};
|
||||
expect(call.method).toBe("cron.add");
|
||||
expect(call.params?.name).toBe("flat-job");
|
||||
expect(call.params?.sessionTarget).toBe("isolated");
|
||||
expect(call.params?.payload?.kind).toBe("agentTurn");
|
||||
const params = expectSingleGatewayCallMethod("cron.add") as
|
||||
| { name?: string; sessionTarget?: string; payload?: { kind?: string } }
|
||||
| undefined;
|
||||
expect(params?.name).toBe("flat-job");
|
||||
expect(params?.sessionTarget).toBe("isolated");
|
||||
expect(params?.payload?.kind).toBe("agentTurn");
|
||||
});
|
||||
|
||||
it("recovers flat params when job is empty object", async () => {
|
||||
@@ -391,15 +391,12 @@ describe("cron tool", () => {
|
||||
payload: { kind: "systemEvent", text: "wake up" },
|
||||
});
|
||||
|
||||
expect(callGatewayMock).toHaveBeenCalledTimes(1);
|
||||
const call = callGatewayMock.mock.calls[0]?.[0] as {
|
||||
method?: string;
|
||||
params?: { name?: string; sessionTarget?: string; payload?: { text?: string } };
|
||||
};
|
||||
expect(call.method).toBe("cron.add");
|
||||
expect(call.params?.name).toBe("empty-job");
|
||||
expect(call.params?.sessionTarget).toBe("main");
|
||||
expect(call.params?.payload?.text).toBe("wake up");
|
||||
const params = expectSingleGatewayCallMethod("cron.add") as
|
||||
| { name?: string; sessionTarget?: string; payload?: { text?: string } }
|
||||
| undefined;
|
||||
expect(params?.name).toBe("empty-job");
|
||||
expect(params?.sessionTarget).toBe("main");
|
||||
expect(params?.payload?.text).toBe("wake up");
|
||||
});
|
||||
|
||||
it("recovers flat message shorthand as agentTurn payload", async () => {
|
||||
@@ -412,16 +409,13 @@ describe("cron tool", () => {
|
||||
message: "do stuff",
|
||||
});
|
||||
|
||||
expect(callGatewayMock).toHaveBeenCalledTimes(1);
|
||||
const call = callGatewayMock.mock.calls[0]?.[0] as {
|
||||
method?: string;
|
||||
params?: { payload?: { kind?: string; message?: string }; sessionTarget?: string };
|
||||
};
|
||||
expect(call.method).toBe("cron.add");
|
||||
const params = expectSingleGatewayCallMethod("cron.add") as
|
||||
| { payload?: { kind?: string; message?: string }; sessionTarget?: string }
|
||||
| undefined;
|
||||
// normalizeCronJobCreate infers agentTurn from message and isolated from agentTurn
|
||||
expect(call.params?.payload?.kind).toBe("agentTurn");
|
||||
expect(call.params?.payload?.message).toBe("do stuff");
|
||||
expect(call.params?.sessionTarget).toBe("isolated");
|
||||
expect(params?.payload?.kind).toBe("agentTurn");
|
||||
expect(params?.payload?.message).toBe("do stuff");
|
||||
expect(params?.sessionTarget).toBe("isolated");
|
||||
});
|
||||
|
||||
it("does not recover flat params when no meaningful job field is present", async () => {
|
||||
@@ -486,9 +480,7 @@ describe("cron tool", () => {
|
||||
tool.execute("call-webhook-missing", {
|
||||
action: "add",
|
||||
job: {
|
||||
name: "reminder",
|
||||
schedule: { at: new Date(123).toISOString() },
|
||||
payload: { kind: "agentTurn", message: "hello" },
|
||||
...buildReminderAgentTurnJob(),
|
||||
delivery: { mode: "webhook" },
|
||||
},
|
||||
}),
|
||||
@@ -503,9 +495,7 @@ describe("cron tool", () => {
|
||||
tool.execute("call-webhook-invalid", {
|
||||
action: "add",
|
||||
job: {
|
||||
name: "reminder",
|
||||
schedule: { at: new Date(123).toISOString() },
|
||||
payload: { kind: "agentTurn", message: "hello" },
|
||||
...buildReminderAgentTurnJob(),
|
||||
delivery: { mode: "webhook", to: "ftp://example.invalid/cron-finished" },
|
||||
},
|
||||
}),
|
||||
@@ -524,15 +514,12 @@ describe("cron tool", () => {
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
expect(callGatewayMock).toHaveBeenCalledTimes(1);
|
||||
const call = callGatewayMock.mock.calls[0]?.[0] as {
|
||||
method?: string;
|
||||
params?: { id?: string; patch?: { name?: string; enabled?: boolean } };
|
||||
};
|
||||
expect(call.method).toBe("cron.update");
|
||||
expect(call.params?.id).toBe("job-1");
|
||||
expect(call.params?.patch?.name).toBe("new-name");
|
||||
expect(call.params?.patch?.enabled).toBe(false);
|
||||
const params = expectSingleGatewayCallMethod("cron.update") as
|
||||
| { id?: string; patch?: { name?: string; enabled?: boolean } }
|
||||
| undefined;
|
||||
expect(params?.id).toBe("job-1");
|
||||
expect(params?.patch?.name).toBe("new-name");
|
||||
expect(params?.patch?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("recovers additional flat patch params for update action", async () => {
|
||||
@@ -546,16 +533,17 @@ describe("cron tool", () => {
|
||||
failureAlert: { after: 3, cooldownMs: 60_000 },
|
||||
});
|
||||
|
||||
const call = callGatewayMock.mock.calls[0]?.[0] as {
|
||||
method?: string;
|
||||
params?: {
|
||||
id?: string;
|
||||
patch?: { sessionTarget?: string; failureAlert?: { after?: number; cooldownMs?: number } };
|
||||
};
|
||||
};
|
||||
expect(call.method).toBe("cron.update");
|
||||
expect(call.params?.id).toBe("job-2");
|
||||
expect(call.params?.patch?.sessionTarget).toBe("main");
|
||||
expect(call.params?.patch?.failureAlert).toEqual({ after: 3, cooldownMs: 60_000 });
|
||||
const params = expectSingleGatewayCallMethod("cron.update") as
|
||||
| {
|
||||
id?: string;
|
||||
patch?: {
|
||||
sessionTarget?: string;
|
||||
failureAlert?: { after?: number; cooldownMs?: number };
|
||||
};
|
||||
}
|
||||
| undefined;
|
||||
expect(params?.id).toBe("job-2");
|
||||
expect(params?.patch?.sessionTarget).toBe("main");
|
||||
expect(params?.patch?.failureAlert).toEqual({ after: 3, cooldownMs: 60_000 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -60,32 +60,38 @@ export function coercePdfAssistantText(params: {
|
||||
provider: string;
|
||||
model: string;
|
||||
}): string {
|
||||
const stop = params.message.stopReason;
|
||||
const label = `${params.provider}/${params.model}`;
|
||||
const errorMessage = params.message.errorMessage?.trim();
|
||||
if (stop === "error" || stop === "aborted") {
|
||||
const fail = (message?: string) => {
|
||||
throw new Error(
|
||||
errorMessage
|
||||
? `PDF model failed (${params.provider}/${params.model}): ${errorMessage}`
|
||||
: `PDF model failed (${params.provider}/${params.model})`,
|
||||
message ? `PDF model failed (${label}): ${message}` : `PDF model failed (${label})`,
|
||||
);
|
||||
};
|
||||
if (params.message.stopReason === "error" || params.message.stopReason === "aborted") {
|
||||
fail(errorMessage);
|
||||
}
|
||||
if (errorMessage) {
|
||||
throw new Error(`PDF model failed (${params.provider}/${params.model}): ${errorMessage}`);
|
||||
fail(errorMessage);
|
||||
}
|
||||
const text = extractAssistantText(params.message);
|
||||
if (text.trim()) {
|
||||
return text.trim();
|
||||
const trimmed = text.trim();
|
||||
if (trimmed) {
|
||||
return trimmed;
|
||||
}
|
||||
throw new Error(`PDF model returned no text (${params.provider}/${params.model}).`);
|
||||
throw new Error(`PDF model returned no text (${label}).`);
|
||||
}
|
||||
|
||||
export function coercePdfModelConfig(cfg?: OpenClawConfig): PdfModelConfig {
|
||||
const primary = resolveAgentModelPrimaryValue(cfg?.agents?.defaults?.pdfModel);
|
||||
const fallbacks = resolveAgentModelFallbackValues(cfg?.agents?.defaults?.pdfModel);
|
||||
return {
|
||||
...(primary?.trim() ? { primary: primary.trim() } : {}),
|
||||
...(fallbacks.length > 0 ? { fallbacks } : {}),
|
||||
};
|
||||
const modelConfig: PdfModelConfig = {};
|
||||
if (primary?.trim()) {
|
||||
modelConfig.primary = primary.trim();
|
||||
}
|
||||
if (fallbacks.length > 0) {
|
||||
modelConfig.fallbacks = fallbacks;
|
||||
}
|
||||
return modelConfig;
|
||||
}
|
||||
|
||||
export function resolvePdfToolMaxTokens(
|
||||
|
||||
@@ -89,9 +89,14 @@ export async function handleTelegramAction(
|
||||
mediaLocalRoots?: readonly string[];
|
||||
},
|
||||
): Promise<AgentToolResult<unknown>> {
|
||||
const action = readStringParam(params, "action", { required: true });
|
||||
const accountId = readStringParam(params, "accountId");
|
||||
const isActionEnabled = createTelegramActionGate({ cfg, accountId });
|
||||
const { action, accountId } = {
|
||||
action: readStringParam(params, "action", { required: true }),
|
||||
accountId: readStringParam(params, "accountId"),
|
||||
};
|
||||
const isActionEnabled = createTelegramActionGate({
|
||||
cfg,
|
||||
accountId,
|
||||
});
|
||||
|
||||
if (action === "react") {
|
||||
// All react failures return soft results (jsonResult with ok:false) instead
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { completeSimple, getModel } from "@mariozechner/pi-ai";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isTruthyEnvValue } from "../infra/env.js";
|
||||
import {
|
||||
createSingleUserPromptMessage,
|
||||
extractNonEmptyAssistantText,
|
||||
} from "./live-test-helpers.js";
|
||||
|
||||
const ZAI_KEY = process.env.ZAI_API_KEY ?? process.env.Z_AI_API_KEY ?? "";
|
||||
const LIVE = isTruthyEnvValue(process.env.ZAI_LIVE_TEST) || isTruthyEnvValue(process.env.LIVE);
|
||||
@@ -12,20 +16,11 @@ async function expectModelReturnsAssistantText(modelId: "glm-4.7" | "glm-4.7-fla
|
||||
const res = await completeSimple(
|
||||
model,
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: "Reply with the word ok.",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
messages: createSingleUserPromptMessage(),
|
||||
},
|
||||
{ apiKey: ZAI_KEY, maxTokens: 64 },
|
||||
);
|
||||
const text = res.content
|
||||
.filter((block) => block.type === "text")
|
||||
.map((block) => block.text.trim())
|
||||
.join(" ");
|
||||
const text = extractNonEmptyAssistantText(res.content);
|
||||
expect(text.length).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user