refactor: dedupe agent and browser cli helpers

This commit is contained in:
Peter Steinberger
2026-03-03 00:14:48 +00:00
parent fe14be2352
commit fd3ca8a34c
46 changed files with 1051 additions and 1117 deletions

View File

@@ -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", () => {

View File

@@ -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);
});

View 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(" ");
}

View File

@@ -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({

View File

@@ -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);
});

View File

@@ -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.

View File

@@ -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 () => {

View File

@@ -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;
}

View File

@@ -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");

View File

@@ -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 [];
}

View File

@@ -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);
});
},
);
});

View File

@@ -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", () => {

View File

@@ -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([]);
}
});

View File

@@ -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);

View File

@@ -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,
);
});
});
});

View File

@@ -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([]);

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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 });
});
});

View File

@@ -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(

View File

@@ -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

View File

@@ -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);
}